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

35 KiB
Raw Blame History

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 poneReferer: https://obsidian.md/en iframes
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:

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:

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)

// 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)

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
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

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():

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()):

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):

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:

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:

{
  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:

<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:[]`
9002 enableYouTubeInnertubeRule `urlFilter:" youtube.com/youtubei/", resourceTypes:["xmlhttprequest"], initiatorDomains:[chrome.runtime.id]`

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
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.