Files
yt-channel-scraper/YOUTUBE-TRANSCRIPT-AUDIT.md
T

506 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Auditoría · Cómo la extensión obtiene la transcripción / información de vídeos de YouTube
> **Producto auditado:** *Obsidian Web Clipper* (Chrome / Chromium / Firefox / Safari) — versión `1.7.1` del paquete `D:\Obsidian Web Clipper - Chrome Web Store 1.7.1.0`.
> **Tipo de extensión:** MV3 (manifest v3) con service worker (`background.js`).
> **Alcance de la auditoría:** mecanismo end‑to‑end por el que la extensión extrae metadatos y la transcripción de un vídeo de YouTube (incluye short `youtu.be`, `youtube.com/watch?v=…` y `youtube.com/shorts/…`).
> **Fecha:** 2026‑07‑26.
> **Audiencia del documento:** LLMs / agentes de mantenimiento. Estructura deliberadamente declarativa, sin prosa narrativa.
---
## 0 · TL;DR (resumen ejecutable)
1. La extensión **no usa `timedtext`, `youtube-transcript` web, ni scraping de `ytd-transcript-segment-renderer` como ruta principal** cuando la URL es un watch normal: usa la **API privada `youtubei/v1/player`** (InnerTube) con cabeceras que imitan clientes oficiales de YouTube (ANDROID, IOS, WEB).
2. Para llegar a esa API sin ser bloqueada por CORS / firma, el `service worker` declara una regla `declarativeNetRequest` (id `9002`, nombre interno `enableYouTubeInnertubeRule`) que **fuerza `Origin: https://www.youtube.com` y `Referer: https://www.youtube.com/`** en toda petición XHR iniciada por la propia extensión hacia `||youtube.com/youtubei/`.
3. La capa de extracción es una clase `YoutubeExtractor` (en `popup.js` y replicada en `reader-page.js`, ambos `webpack` bundles de Defuddle) que:
- 1️⃣ parsea el JSON embebido `ytInitialPlayerResponse` del DOM para sacar `captionTracks` y `baseUrl` sin red.
- 2️⃣ si falla, abre el panel "Mostrar transcripción" del propio YouTube haciendo `click()` y espera con `MutationObserver`‑style polling (DOM scraping fallback).
- 3️⃣ si la transcripción automática no está disponible, llama a `youtubei/v1/player` con 3 identidades de cliente en cascada (ANDROID → IOS → WEB) hasta que una devuelve `captions.playerCaptionsTracklistRenderer.captionTracks`.
- 4️⃣ descarga la pista (`timedtext`-like `baseUrl` con sufijo `&fmt=…`) y la parsea como XML.
4. Los capítulos se extraen con una segunda ruta: `youtubei/v1/next` (también con cabeceras de cliente), o desde `ytInitialData` embebido (`playerOverlays.playerOverlayRenderer.decoratedPlayerBarRenderer.multiMarkersPlayerBarRenderer.markersMap`).
5. Toda la red de la popup se hace con `globalThis.fetch` directo (no hay proxy interno), aprovechando la regla DNR 9002. El background solo ofrece un *fallback* `sendNativeMessage` para hosts que devuelven CORS (por ejemplo Bilibili).
---
## 1 · Vista general de componentes (mapa de archivos)
| Archivo | Rol respecto a YouTube | Tamaño aprox. | Notas |
|---|---|---|---|
| `manifest.json` | Declara `host_permissions: ["<all_urls>","http://*/*","https://*/*"]` y `declarativeNetRequest`. | 89 líneas | Sin URL allow‑list específica de YouTube. |
| `background.js` (service worker) | Define la **regla DNR 9002** `enableYouTubeInnertubeRule` (set Origin/Referer para `||youtube.com/youtubei/`). Contiene además `enableYouTubeEmbedRule` (9001) que pone `Referer: https://obsidian.md/` en iframes `||youtube.com/embed/`. | ~1 archivo compilado | Comentario interno: `initiatorDomains: [chrome.runtime.id]` ⇒ sólo afecta peticiones de la propia extensión. |
| `content.js` (content script) | Sólo contiene el glue de highlights (`getClosestTextBlock` ignora elementos con clase `transcript-segment` para no romper la selección). **No extrae la transcripción.** | 1 bundle webpack | No realiza llamadas a YouTube. |
| `popup.js` | Contiene la clase `YoutubeExtractor` real (minificada) y todo el código de extracción. Se carga como `popup.html` y como `side-panel.html` (ver `<script type="module" src="popup.js">`). | ~2.5 MB minificado | Aquí vive toda la lógica de transcripción. |
| `reader-page.js` | Réplica exacta de los extractores (incluye otra copia de `YoutubeExtractor`). Se usa cuando se abre la URL en modo *Reader* (`reader.html?url=…`). | ~2.5 MB minificado | Mismo binario que popup. |
| `highlighter.js` | Sólo lógica de resaltado (no relevante para transcripción). | — | — |
| `reader-script.js` | Inyectado por background con `scripting.executeScript` para modo Reader. | — | — |
| `_locales/*/messages.json` | i18n; incluye claves `readerTranscripts`, `readerPinPlayer`, `readerHighlightActiveLine` (configuración visual de la transcripción, no de extracción). | — | — |
| `web_accessible_resources` | Lista `reader.css`, `reader-script.js`, `browser-polyfill.min.js`, `style.css`, `side-panel.html`, `flatten-shadow-dom.js`, `highlighter.css`. | — | `popup.js` **no** está en `web_accessible_resources`; por tanto la extracción no se hace desde un script inyectado en la página, sino desde la propia página de extensión. |
### 1.1 Flujo de control (quién llama a quién)
```
[user clicks action / shortcut / context menu]
└─ background.js (service worker)
└─ browser.action.openPopup() OR tabs.sendMessage("openPopup")
└─ popup.html (extension page, chrome-extension://<id>/popup.html)
└─ popup.js (module)
├─ Defuddle.parse(doc) ← extractor genérico
│ └─ para URL que matchea "youtube.com" o "youtu.be"
│ └─ new YoutubeExtractor(document, url, schemaOrg, options)
│ └─ extractAsync() ⇒ runExtractor()
│ ├─ extractTranscriptFromExistingDom() (1ª opción)
│ ├─ fetchTranscript() (2ª opción: red)
│ └─ extractTranscriptFromOpenedDom() (3ª opción: click en panel)
└─ resultado ⇒ variables { transcript, language } ⇒ se inyecta en la nota Markdown
(En paralelo, la regla DNR 9002 reescribe Origin/Referer de las XHR
lanzadas por la propia extensión hacia youtube.com/youtubei/v1/…)
```
> **Punto importante para LLMs:** la extracción de YouTube **no se ejecuta dentro de la página de YouTube** ni desde el content script. Se ejecuta en el contexto privilegiado de la extensión (`chrome-extension://`). La página de YouTube solo aporta el `document` con el HTML actual y, opcionalmente, el JSON embebido en los `<script>`.
---
## 2 · Punto de entrada: ¿cuándo se invoca la extracción?
`background.js` ofrece cuatro formas de abrir la popup, todas convergen al mismo punto:
| Acción del usuario | Mensaje / llamada en `background.js` | Destino final |
|---|---|---|
| Click en icono de la extensión | `action.onClicked` → `openPopup()` | `popup.html` |
| Atajo `Ctrl+Shift+O` (`_execute_action`) | `commands.onCommand` → `openPopup()` | `popup.html` |
| Atajo `Alt+Shift+O` (`quick_clip`) | `commands.onCommand` → `openPopup()` + 500 ms `triggerQuickClip` | `popup.html` |
| Menú contextual "Save this page" / "Add to highlights" | `contextMenus.onClicked` → `openPopup()` | `popup.html` |
| Behavior `embedded` | `tabs.sendMessage("toggle-iframe")` → `side-panel.html?context=iframe` | `side-panel.html` |
> `side-panel.html` y `popup.html` cargan **el mismo `popup.js`** (`<script type="module" src="popup.js">`). Por tanto, la lógica de YouTube es única y se invoca desde dos contenedores distintos.
Cuando la popup se carga, en `popup.js` se hace algo equivalente a:
```js
const defuddle = new Defuddle(document, { url: location.href });
const result = defuddle.parse(); // extracción síncrona
const asyncVars = await defuddle.fetchAsyncVariables({ language, fetch }); // asíncrono
```
`Defuddle` consulta su `ExtractorRegistry` (poblado en `ExtractorRegistry.initialize()`); para YouTube registra:
```js
this.register({ patterns: ["youtube.com","youtu.be"], extractor: YoutubeExtractor });
this.register({ patterns: ["m.youtube.com"], extractor: YoutubeExtractor }); // implícito
this.register({ patterns: [/youtube\.com\/shorts\//], extractor: YoutubeExtractor });
```
El extractor se instancia con `new YoutubeExtractor(document, url, schemaOrgData, options)`. Las `options` que recibe la popup le inyectan `language` (preferida por el usuario) y un `fetch` opcional; si no se inyecta, usa `globalThis.fetch`.
---
## 3 · `YoutubeExtractor` (la clase clave) — Anatomía
> **Ubicación física del código (minificado):**
> - En `popup.js`, la clase aparece aproximadamente entre los offsets `382 000`–`405 000` del bundle (texto buscado: `class YoutubeExtractor` o el alias `class A extends o.BaseExtractor`).
> - En `reader-page.js` es la **misma clase** con nombre `A` (mismo fingerprint de strings `transcript-segment-view-model`, `ytwTranscriptSegmentViewModelTimestamp`, `ytInitialPlayerResponse`).
> - En origen viene del paquete npm `defuddle` (≥ v0.x) — la extensión lo reempaqueta con webpack.
### 3.1 Identidad de cliente (constantes globales)
```js
// constantes a nivel de módulo, dentro del bundle de popup.js / reader-page.js
const TIMEOUT_MS = 4000; // f = 4e3
const PLAYER_URL = "https://www.youtube.com/youtubei/v1/player?prettyPrint=false"; // g
const NEXT_URL = "https://www.youtube.com/youtubei/v1/next?prettyPrint=false"; // usado en fetchChapters
const ANDROID_UA = "com.google.android.youtube/20.10.38 (Linux; U; Android 14)"; // v, usada en b/x
// Contextos de cliente (probados en cascada)
const ANDROID_CLIENT = { client: { clientName: "ANDROID", clientVersion: "20.10.38" } }; // b / x
const IOS_CLIENT = { client: { clientName: "IOS", clientVersion: "20.10.3" } }; // y
const WEB_CLIENT = { client: { clientName: "WEB", clientVersion: "2.20240101.00.00" } }; // w
```
### 3.2 Selectores DOM (definidos como objetos)
```js
const DESKTOP_SELECTORS = {
segments: "ytd-transcript-segment-renderer",
timestamp: ".segment-timestamp",
text: ".segment-text",
};
const MOBILE_SELECTORS = {
segments: "transcript-segment-view-model",
timestamp: ".ytwTranscriptSegmentViewModelTimestamp",
text: "span.yt-core-attributed-string",
chapters: "timeline-chapter-view-model h3",
};
```
`getTranscriptSelectors(container)` elige uno u otro mirando qué nodos existen. Si no existe ninguno devuelve `undefined` (⇒ no hay transcripción en el DOM todavía).
### 3.3 Métodos principales (firmas y propósito)
| Método | Tipo | Propósito |
|---|---|---|
| `getVideoId()` | síncrono | Devuelve el id de 11 chars. Soporta `youtube.com/watch?v=…`, `youtu.be/…`, `youtube.com/shorts/…`. Cachea en `this._videoId`. |
| `canExtractAsync()` | síncrono | Devuelve `true` si la URL es de YouTube. |
| `extractAsync()` | async | Punto de entrada. Cadena: `extractTranscriptFromExistingDom()` → si vacío `fetchTranscript()` → si vacío `extractTranscriptFromOpenedDom()`. Devuelve `{ html, text, languageCode, … }` o `null`. |
| `extractTranscriptFromExistingDom()` | try/catch | Lee los segmentos si YouTube ya renderizó el panel de transcripción (panel abierto por el usuario o cargado por interacción previa). |
| `getTranscriptContainer()` | síncrono | Selector: `'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"] #segments-container'`; en `m.youtube.com`: `ytm-macro-markers-list-renderer .ytm-macro-markers-list-container`. |
| `buildTranscriptFromContainer(container, chapters)` | síncrono | Itera cada segmento, parsea timestamp (`parseTimestamp` acepta `h:mm:ss` o `mm:ss`), agrupa por hablante si detecta patrón (`groupTranscriptSegments` → `groupBySpeaker` o `groupBySentence`), y emite HTML `<p class="transcript-segment">` y texto plano `**HH:MM:SS** · texto`. Llama a `buildTranscript("youtube", groups, chapters)`. |
| `extractTranscriptFromOpenedDom()` | async | Si el panel no estaba abierto pero el DOM lo permite (`canOpenTranscriptPanel()`: `typeof MutationObserver === "function"`): hace **click programático** en `ytd-video-description-transcript-section-renderer button` (o equivalente mobile), espera el contenedor y re-usa `buildTranscriptFromContainer`. |
| `openMobileTranscriptPanel()` | async | Variante `m.youtube.com`: clicks en `button[aria-label="Show more"]` → espera `button[aria-label="View all"]` → click → espera segmentos. |
| `fetchTranscript()` | async | **Ruta de red principal**. Ver §3.4. |
| `fetchPlayerData(videoId)` | async | POST a `youtubei/v1/player` probando 3 clientes en cascada. Ver §3.4. |
| `fetchChapters(videoId)` | async | POST a `youtubei/v1/next` (cliente WEB) → extrae capítulos de `playerOverlays…multiMarkersPlayerBarRenderer.markersMap[*].value.chapters[*].chapterRenderer`; fallback a `engagementPanels[*].engagementPanelSectionListRenderer.content.macroMarkersListRenderer.contents[*].macroMarkersListItemRenderer`. Si ya están embebidos en `ytInitialData` no se hace red. |
| `getValidatedPlayerResponse()` | síncrono | Devuelve el JSON parseado de `ytInitialPlayerResponse` (parseado inline desde el `<script>`) **solo si** su `videoDetails.videoId` o `microformat.playerMicroformatRenderer.externalVideoId` coincide con `getVideoId()`. |
| `parseInlineJson(varName)` | síncrono | Itera todos los `<script>` del documento, encuentra el primero cuyo `textContent` contiene la variable global, **balancea llaves** manualmente y hace `JSON.parse`. Cachea el resultado en `this.inlineJsonCache` (Map). |
| `getCaptionTracks(playerResponse)` | síncrono | `playerResponse?.captions?.playerCaptionsTracklistRenderer?.captionTracks` (devuelve `[]` si no es array). |
| `pickCaptionTrack(tracks)` | síncrono | Si hay `options.language`: prefiere la pista exacta (`code === lang`) ⇒ mismo idioma base (`code.split("-")[0] === lang.split("-")[0]`) ⇒ mismo prefijo de idioma. Filtra `kind === "asr"` (auto‑generadas) si hay manuales. Si no, devuelve la primera no‑`asr`, o una con `languageCode === "en"`, o la primera. |
| `findPreferredCaptionTrack(tracks, lang)` | síncrono | Igual que el anterior pero con scoring explícito. |
| `getInlineCaptionTrack()` | síncrono | `getValidatedPlayerResponse()` → `getCaptionTracks()` → `pickCaptionTrack()`. Si hay `baseUrl`, devuelve la pista. |
| `fetchCaptionXml(track, chaptersPromise)` | async | `fetch(track.baseUrl, { headers: { "User-Agent":"Mozilla/5.0", "Accept-Language": lang } })` con `AbortSignal.timeout(4000)`. Devuelve solo si la URL acaba en `.youtube.com`. Pasa el texto a `parseTranscriptXml`. |
| `parseTranscriptXml(xml, lang, chapters)` | síncrono | Dos regex: `<p t="N">…<s>…</s>…</p>` (formato nuevo) y `<text start="N">…</text>` (formato legacy). Decodifica entidades (`decodeEntities`). Llama a `groupTranscriptSegments` y `buildTranscript`. |
| `decodeEntities(str)` | síncrono | Reemplazos para `&amp; &lt; &gt; &quot; &#39; &apos; &#xHH; &#NN;`. |
| `groupTranscriptSegments(segs)` | síncrono | Decide por regex CJK: si hay mezcla CJK/Latín → `groupBySpeaker` (split por `:` al inicio de línea); si no → `groupBySentence`. |
| `getVideoData()` | síncrono | Lee `<script type="application/ld+json">` buscando un `VideoObject` cuyo `embedUrl`/`url`/`@id` contenga el videoId. Fallback a `meta[property="og:title|og:description|og:image|og:url"]`. |
| `getChannelNameFromDom()` / `getChannelNameFromPlayerResponse()` | síncrono | `[itemprop="name"]` o `videoDetails.author`/`ownerChannelName`/`microformat.playerMicroformatRenderer.ownerChannelName`. |
| `getTranscriptLanguageCodeFromDom()` | síncrono | Lee el botón de `yt-sort-filter-sub-menu-renderer` en el footer del panel y compara con `name.simpleText`/`name.runs[].text` de cada caption track. |
| `getInlineChapters()` | síncrono | Desde `ytInitialData`; valida que el videoId en el JSON coincida con el de la URL; cae a `extractChaptersFromEngagementPanels`. |
| `buildResult(transcript)` | síncrono | Empaqueta `{ title, author, site:"YouTube", image, published, description }`, añade `transcript` y `language` al `variables`, prepende un `<iframe src="https://www.youtube.com/embed/{id}" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen>`. |
| `formatDescription(text)` | síncrono | `<p>…</p>` con `escapeHtml` y `<br>` por `\n`. |
### 3.4 `fetchTranscript()` — núcleo de la ruta de red
```js
async fetchTranscript() {
const videoId = this.getVideoId();
const chapters = this.fetchChapters(videoId); // (a)
const inlineTrack = this.getInlineCaptionTrack(); // (b) caption del JSON embebido
const inlineFetch = inlineTrack ? this.fetchCaptionXml(inlineTrack, chapters) : undefined; // (c)
const playerData = await this.fetchPlayerData(videoId); // (d) red: youtubei/v1/player
const picked = playerData ? this.pickCaptionTrack(this.getCaptionTracks(playerData)) : undefined;
const remoteFetch = (picked?.baseUrl && picked.baseUrl !== inlineTrack?.baseUrl)
? this.fetchCaptionXml(picked, chapters) // (e)
: undefined;
return (await remoteFetch) || (await inlineFetch);
}
```
Y `fetchPlayerData()`:
```js
async fetchPlayerData(videoId) {
// 1º intento: cliente IOS
try {
const r = await this.fetch(PLAYER_URL, {
method: "POST",
headers: { "Content-Type":"application/json", ...(lang && {"Accept-Language": lang}) },
signal: AbortSignal.timeout(TIMEOUT_MS),
body: JSON.stringify({ context: IOS_CLIENT, videoId })
});
if (r.ok) { const t = await r.json(); if (this.getCaptionTracks(t).length) return t; }
} catch {}
// 2º intento: cliente ANDROID con UA
try {
const r = await this.fetch(PLAYER_URL, {
method: "POST",
headers: { "Content-Type":"application/json", "User-Agent": ANDROID_UA, ...(lang && {"Accept-Language": lang}) },
signal: AbortSignal.timeout(TIMEOUT_MS),
body: JSON.stringify({ context: ANDROID_CLIENT, videoId })
});
if (r.ok) { const t = await r.json(); if (this.getCaptionTracks(t).length) return t; }
} catch {}
// 3º intento: cliente WEB clásico
try {
const r = await this.fetch(PLAYER_URL, {
method: "POST",
headers: { "Content-Type":"application/json" },
signal: AbortSignal.timeout(TIMEOUT_MS),
body: JSON.stringify({ context: WEB_CLIENT, videoId })
});
if (r.ok) { const t = await r.json(); if (this.getCaptionTracks(t).length) return t; }
} catch {}
// 4º fallback: parsear el JSON embebido en el HTML (sin red)
const inline = this.parseInlineJson("ytInitialPlayerResponse");
if (this.getCaptionTracks(inline).length) return inline;
}
```
> **Para LLMs:** los 3 contextos de cliente y el `User-Agent` ANDROID son los mismos que usa `youtube-dl`, `yt-dlp` y la mayoría de librerías no oficiales. Si YouTube empieza a rechazar la firma, el orden de los 3 intentos es lo que hay que tocar; también se puede añadir `TVHTML5_SIMPLY_EMBEDDED_PLAYER` u otros.
---
## 4 · Mecanismo de bypass de CORS / firma de YouTube
YouTube no expone `youtubei/v1/player` con CORS abierto, así que la extensión necesita que las peticiones se *vean* como originadas desde la propia web de YouTube. Hay **dos piezas** que lo permiten:
### 4.1 `enableYouTubeInnertubeRule` (DNR id `9002`)
En `background.js` (en la función `initialize()`):
```js
await dnr.updateSessionRules({
removeRuleIds: [9002],
addRules: [{
id: 9002,
priority: 1,
action: {
type: "modifyHeaders",
requestHeaders: [
{ header: "Origin", operation: "set", value: "https://www.youtube.com" },
{ header: "Referer", operation: "set", value: "https://www.youtube.com/" }
]
},
condition: {
urlFilter: "||youtube.com/youtubei/",
resourceTypes: ["xmlhttprequest"],
initiatorDomains: [ chrome.runtime.id ].filter(Boolean)
}
}]
});
```
> Como el popup (`chrome-extension://<id>/popup.html`) lanza `fetch` desde el contexto de la extensión, el `initiator` de la petición es el `extension_id` ⇒ la regla **solo** se aplica a las peticiones que dispara la propia extensión. Las peticiones que haga el content script de la página de YouTube no se tocan aquí.
### 4.2 `webRequest.onBeforeSendHeaders` (sólo Firefox / WebExtensions)
En `background.js` también hay (entre `try { … } catch {}` para tolerancia a Safari):
```js
browser_polyfill.webRequest.onBeforeSendHeaders.addListener(details => {
if (details.tabId > 0) {
const refHeader = details.requestHeaders.find(h => h.name.toLowerCase() === "referer");
const refValue = refHeader?.value || "";
const originHdr = details.requestHeaders.find(h => h.name.toLowerCase() === "origin");
const originValue = originHdr?.value || "";
if (!(refValue.startsWith("moz-extension://") || refValue.startsWith("safari-web-extension://"))) {
return { requestHeaders: details.requestHeaders }; // no tocar: es la propia web
}
}
const headers = details.requestHeaders || [];
const setHeader = (name, value) => {
const existing = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
existing ? existing.value = value : headers.push({ name, value });
};
setHeader("Origin", "https://www.youtube.com");
setHeader("Referer", "https://www.youtube.com/");
return { requestHeaders: headers };
}, { urls: ["*://www.youtube.com/*"] }, ["blocking","requestHeaders"]);
```
> **Para LLMs:** este listener sólo aplica a Firefox MV2 / WebExtensions, donde `declarativeNetRequest` no soporta `modifyHeaders` de la misma forma. **No se ejecuta en Chrome** (donde ya tenemos DNR 9002). En Safari se ignora silenciosamente (`catch` lo traga).
### 4.3 `enableYouTubeEmbedRule` (DNR id `9001`) — caso especial, no transcripción
No participa en la extracción de transcripción, pero la documentamos para que el lector no se confunda: cuando la popup muestra el `<iframe src="https://www.youtube.com/embed/…">` en modo *Reader*, background fuerza `Referer: https://obsidian.md/` para que el embed no se rompa en vídeos con restricción por referer.
---
## 5 · `fetchProxy` y `nativeFetch` — por qué existen (y por qué YouTube NO los usa)
En `background.js`:
```js
browser_polyfill.runtime.onMessage.addListener(request => {
if (request.action !== "fetchProxy") return;
return fetch(request.url, request.options)
.then(async resp => {
const text = await resp.text();
const looksLikeHTML = !resp.ok && (text.includes("Sorry") || text.includes("<html"));
if (!looksLikeHTML) return { ok: resp.ok, status: resp.status, text, finalUrl: resp.url };
return browser_polyfill.runtime.sendNativeMessage ? nativeFetch(request.url, request.options) : { ok:false, status:0, error:"CORS_PERMISSION_NEEDED" };
})
.catch(() => browser_polyfill.runtime.sendNativeMessage ? nativeFetch(request.url, request.options) : { ok:false, error:"CORS_PERMISSION_NEEDED" });
});
```
`nativeFetch` usa `runtime.sendNativeMessage("application.id", { type:"fetchRequest", url, method, headers, body })` que solo está disponible en Safari (App‑bound messaging) — sirve para que la app nativa de Mac de Safari haga la petición y devuelva el cuerpo.
> **Implicación para YouTube:** la popup **nunca** enruta sus llamadas a YouTube por `fetchProxy` ni por `nativeFetch`. Las llamadas van por `globalThis.fetch` directo, protegidas por la regla DNR 9002.
`fetchProxy` se usa en el extractor de **Bilibili** (`BilibiliExtractor`):
- Llama a `https://api.bilibili.com/x/player/wbi/v2?bvid=…&cid=…` desde la popup.
- Bilibili suele devolver CORS abierto, así que normalmente no hace falta el proxy. El proxy queda como fallback de seguridad.
---
## 6 · Estructura del output (qué se mete en la nota Markdown)
`YoutubeExtractor.buildResult(transcript)` produce:
```js
{
content: ` <iframe … src="https://www.youtube.com/embed/{id}" …></iframe><p>{descripción}</p>{transcript.html}`,
contentHtml: idem,
extractedContent: { videoId, author },
variables: {
title: e.name || "", // videoDetails.title
author: r, // canal
site: "YouTube",
image: Array.isArray(e.thumbnailUrl) ? e.thumbnailUrl[0] : "",
published: e.uploadDate, // microformat.publishDate o uploadDate
description: n.slice(0, 200).trim(),
transcript: transcript.text, // sólo si hay
language: transcript.languageCode // "en", "es", "es-419", ...
}
}
```
`transcript.text` es texto plano con formato Markdown:
```
**00:00** · Primera frase hablada
**00:04** · Segunda frase hablada
```
`transcript.html` es:
```html
<div class="youtube transcript">
<h2>Transcript</h2>
<p class="transcript-segment"><strong><span class="timestamp" data-timestamp="0">00:00</span></strong> · Primera frase hablada</p>
…
</div>
```
Los `transcript-segment` con `data-timestamp` permiten que el modo *Reader* haga **highlight de la línea activa mientras reproduce** (`readerHighlightActiveLine`, `readerPinPlayer`, `readerAutoScroll` — ver `_locales/*/messages.json`).
---
## 7 · Manejo de errores y degradación
| Escenario | Comportamiento observado |
|---|---|
| Vídeo sin subtítulos manuales ni auto‑generados | `getCaptionTracks([])` ⇒ `pickCaptionTrack(undefined)` ⇒ `null` ⇒ `buildResult` se llama con `transcript = undefined` ⇒ `variables.transcript` queda ausente. El iframe y los metadatos sí se guardan. |
| Vídeo con subtítulos sólo auto (`kind:"asr"`) | `pickCaptionTrack` la prefiere si no hay otra o si la opción `language` coincide; en otro caso la salta. |
| `fetchPlayerData` falla en los 3 clientes y no hay JSON embebido | `transcript` queda `undefined`. La popup sigue mostrando la nota con metadatos. |
| Red lenta / timeout 4 s | `AbortSignal.timeout(4000)` corta la petición. Se prueba el siguiente cliente. |
| CORS aún bloqueado pese a DNR 9002 | En Chrome MV3 no hay fallback automático desde background (no usa `fetchProxy` para YouTube). El error queda silenciado dentro del `try { … } catch {}` y la nota se guarda sin transcripción. |
| Página no es `youtube.com` (p.ej. embed de otro dominio) | `canExtractAsync()` devuelve `false`; Defuddle cae a su extractor genérico (`BbcodeDataExtractor` según `ExtractorRegistry.mappings[last]`). |
---
## 8 · Datos estáticos y constantes que un LLM debe conocer
### 8.1 Versiones de cliente (claves de la firma)
| Variable | Valor | Notas |
|---|---|---|
| `ANDROID_CLIENT_VERSION` | `20.10.38` | Cliente ANDROID. |
| `IOS_CLIENT_VERSION` | `20.10.3` | Cliente IOS. |
| `WEB_CLIENT_VERSION` | `2.20240101.00.00` | Cliente WEB clásico. |
| `ANDROID_USER_AGENT` | `com.google.android.youtube/20.10.38 (Linux; U; Android 14)` | Solo se envía en el 2º intento. |
| `PLAYER_ENDPOINT` | `https://www.youtube.com/youtubei/v1/player?prettyPrint=false` | — |
| `NEXT_ENDPOINT` | `https://www.youtube.com/youtubei/v1/next?prettyPrint=false` | Solo `fetchChapters`. |
| `TIMEOUT_MS` | `4000` | `AbortSignal.timeout`. |
| `LANG_HEADER` | `Accept-Language` (opcional) | Se añade sólo si `options.language` está definido. |
### 8.2 Reglas DNR declaradas por background
| id | Nombre interno | Trigger | Acción | Uso |
|---|---|---|---|---|
| `9001` | `enableYouTubeEmbedRule` | `urlFilter:"||youtube.com/embed/"`, `resourceTypes:["sub_frame"]`, `tabIds:[<sender tab>]` | `set Referer: https://obsidian.md/` | Solo embeds (no transcripción). |
| `9002` | `enableYouTubeInnertubeRule` | `urlFilter:"||youtube.com/youtubei/"`, `resourceTypes:["xmlhttprequest"]`, `initiatorDomains:[chrome.runtime.id]` | `set Origin: https://www.youtube.com`, `set Referer: https://www.youtube.com/` | **Clave para que funcione la extracción.** |
### 8.3 Selectores DOM relevantes
| Uso | Selector |
|---|---|
| Panel de transcripción (desktop) | `ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"] #segments-container` |
| Botón "Mostrar transcripción" | `ytd-video-description-transcript-section-renderer button` |
| Idioma seleccionado en panel | `ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"] #footer yt-sort-filter-sub-menu-renderer yt-dropdown-menu button` |
| Panel mobile | `ytm-macro-markers-list-renderer .ytm-macro-markers-list-container` |
| Botón "Show more" mobile | `button[aria-label="Show more"]` |
| Botón "View all" mobile | `button[aria-label="View all"]` |
| Segmento desktop | `ytd-transcript-segment-renderer` (timestamp `.segment-timestamp`, texto `.segment-text`) |
| Segmento mobile | `transcript-segment-view-model` (timestamp `.ytwTranscriptSegmentViewModelTimestamp`, texto `span.yt-core-attributed-string`) |
| Metadatos (LD+JSON) | `script[type="application/ld+json"]` buscando `VideoObject` |
| OpenGraph | `meta[property="og:title|og:description|og:image|og:url"]` |
| Nombre de canal (DOM) | `[itemprop="name"]` / `link[itemprop="name"]` / `a, span` |
| JSON embebido | `<script>` con `ytInitialPlayerResponse` o `ytInitialData` |
### 8.4 Mensajes i18n ligados a la transcripción
`_locales/*/messages.json` contiene (en cada idioma) entradas como:
- `readerTranscripts` → "Transcripts" / "Transcripciones" / "Transcriptions" / …
- `readerHighlightActiveLine` / `…Description` → toggle de la línea activa
- `readerPinPlayer` / `…Description` → fija el reproductor
- `readerAutoScroll` / `…Description` → auto‑scroll durante reproducción
- `readerThemeSection` → tema de la transcripción en el Reader
(Solo configuran el render, no el proceso de extracción.)
---
## 9 · Diagrama textual del flujo de red
```
┌──────────────────────────────────────────────────────┐
│ popup.html / side-panel.html (chrome-extension://) │
│ ┌────────────────┐ │
│ │ popup.js │ │
│ │ Defuddle + │ │
│ │ YoutubeExtractor │
│ └───┬────────────┘ │
│ │ globalThis.fetch (XHR) │
│ ▼ │
│ https://www.youtube.com/youtubei/v1/player?… │
│ Body: { context: <IOS|ANDROID|WEB>, videoId } │
└──────────────────────────────────────────────────────┘
│ (DNR rule 9002)
│ set Origin: https://www.youtube.com
│ set Referer: https://www.youtube.com/
▼
YouTube InnerTube API
│ JSON con captionTracks[*].baseUrl
▼
https://www.youtube.com/api/timedtext?…&fmt=…&v=…&lang=… (track.baseUrl)
│ Headers: User-Agent: Mozilla/5.0
│ Accept-Language: <opción del usuario>
▼
XML de subtítulos
│ parseTranscriptXml()
▼
{ html, text, languageCode }
│
▼
variables.transcript + variables.language
⇒ se inyecta en la nota Markdown
```
Paralelo: `fetchChapters` → `https://www.youtube.com/youtubei/v1/next?…` con `client: WEB` ⇒ `playerOverlays…markersMap` ⇒ capítulos embebidos como `## H2` dentro del HTML de la transcripción.
---
## 10 · Riesgos y consideraciones para mantenimiento
1. **Versiones hard‑coded de cliente (`20.10.38`, `20.10.3`, `2.20240101.00.00`)**: YouTube rota las firmas mensualmente. Si la transcripción deja de funcionar, lo primero a actualizar son estas tres constantes en `popup.js` y `reader-page.js` (buscar `clientVersion` y `20.10.38`).
2. **DNR `9002` solo en `xmlhttprequest`**: si YouTube migra a `fetch` puro o cambia el `resourceType`, la regla deja de aplicar. Verificar `chrome.declarativeNetRequest.getEnabledRulesets()` en la consola de la extensión.
3. **`fetchProxy` NO se usa para YouTube**: no intentes enrutar por ahí; el flujo correcto es `globalThis.fetch` + DNR.
4. **El content script no extrae transcripción**: si la popup falla, no hay fallback desde `content.js`. Mejorar la extracción implica tocar `popup.js` y `reader-page.js` (que son el mismo bundle).
5. **Cache `Map<key, transcript>`**: existe en `BilibiliExtractor.transcriptCache` (LRU con cap 300). `YoutubeExtractor` **no** cachea transcripciones; cada `extract` rehace la red.
6. **Permisos**: el manifest pide `<all_urls>` y `declarativeNetRequest`. Si se reduce a un `optional_host_permissions` específico de YouTube, la popup seguirá funcionando porque `youtubei` está cubierto por `host_permissions`, pero el `webRequest` listener de Firefox puede dejar de aplicar.
7. **Time limit de service worker (MV3)**: el SW se duerme tras 30 s. `enableYouTubeInnertubeRule` se registra en `initialize()` al arrancar y se elimina solo si se pide `disableYouTubeInnertubeRule` (que no existe en el código actual). En la práctica, la regla es *session‑scoped* y dura lo que dure la sesión de Chrome.
8. **`ytInitialPlayerResponse` puede no estar presente** en páginas con cookie consent previo o si el usuario está en `consent.youtube.com`. La cascada ANDROID/IOS/WEB lo cubre.
---
## 11 · Resumen para indexar/embeddings
> Texto generado para ser embeddings‑friendly. 7 frases autocontenidas.
1. La extensión **Obsidian Web Clipper 1.7.1** extrae la transcripción de YouTube desde la **popup** (contexto de la extensión, `chrome-extension://`), nunca desde el content script.
2. La clase **`YoutubeExtractor`** (minificada en `popup.js` y duplicada en `reader-page.js`, originalmente de Defuddle) ofrece una cascada de tres rutas: (a) parseo del JSON embebido `ytInitialPlayerResponse`, (b) scraping del panel de transcripción DOM con selectores `ytd-transcript-segment-renderer` / `transcript-segment-view-model`, (c) peticiones a la API privada **`youtubei/v1/player`** con contextos de cliente ANDROID / IOS / WEB.
3. El bypass de CORS / firma se hace con una regla **`declarativeNetRequest` id 9002** que fuerza `Origin: https://www.youtube.com` y `Referer: https://www.youtube.com/` para todas las XHR a `||youtube.com/youtubei/` iniciadas por la propia extensión; Firefox usa `webRequest.onBeforeSendHeaders` en su lugar.
4. La pista de subtítulos descargada (`track.baseUrl` con sufijo `fmt=`) es XML y se parsea con dos regex: `<p t="N">…<s>…</s>…</p>` (formato moderno) y `<text start="N">…</text>` (formato legacy), produciendo HTML con clase `transcript-segment` y texto plano con timestamps `HH:MM:SS`.
5. Los **capítulos** se extraen de `ytInitialData` o, en su defecto, de `youtubei/v1/next` con cliente WEB, parseando `playerOverlays.playerOverlayRenderer.decoratedPlayerBarRenderer.multiMarkersPlayerBarRenderer.markersMap`.
6. La popup usa `globalThis.fetch` directo (sin proxy) para YouTube; el `fetchProxy`/`nativeFetch` de `background.js` solo se utiliza como fallback CORS para Bilibili y otros hosts, no para YouTube.
7. El resultado (`{transcript.text, language}`) se inyecta en la nota Markdown final bajo la variable `transcript` / `language`; la nota incluye también un `<iframe src="https://www.youtube.com/embed/{id}">` y metadatos (`title`, `author`, `image`, `published`, `description`).
---
*Fin del informe.*