wip: estado de trabajo pendiente antes de la vista grid (suite 230 verde)

This commit is contained in:
urieljareth
2026-08-22 19:37:23 -06:00
parent 4f5a68b572
commit 8a59b39c98
103 changed files with 70954 additions and 1825 deletions
+505
View File
@@ -0,0 +1,505 @@
# 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.*