35 KiB
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.1del paqueteD:\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 shortyoutu.be,youtube.com/watch?v=…yyoutube.com/shorts/…). Fecha: 2026‑07‑26. Audiencia del documento: LLMs / agentes de mantenimiento. Estructura deliberadamente declarativa, sin prosa narrativa.
0 · TL;DR (resumen ejecutable)
- La extensión no usa
timedtext,youtube-transcriptweb, ni scraping deytd-transcript-segment-renderercomo ruta principal cuando la URL es un watch normal: usa la API privadayoutubei/v1/player(InnerTube) con cabeceras que imitan clientes oficiales de YouTube (ANDROID, IOS, WEB). - Para llegar a esa API sin ser bloqueada por CORS / firma, el
service workerdeclara una regladeclarativeNetRequest(id9002, nombre internoenableYouTubeInnertubeRule) que fuerzaOrigin: https://www.youtube.comyReferer: https://www.youtube.com/en toda petición XHR iniciada por la propia extensión hacia||youtube.com/youtubei/. - La capa de extracción es una clase
YoutubeExtractor(enpopup.jsy replicada enreader-page.js, amboswebpackbundles de Defuddle) que:- 1️⃣ parsea el JSON embebido
ytInitialPlayerResponsedel DOM para sacarcaptionTracksybaseUrlsin red. - 2️⃣ si falla, abre el panel "Mostrar transcripción" del propio YouTube haciendo
click()y espera conMutationObserver‑style polling (DOM scraping fallback). - 3️⃣ si la transcripción automática no está disponible, llama a
youtubei/v1/playercon 3 identidades de cliente en cascada (ANDROID → IOS → WEB) hasta que una devuelvecaptions.playerCaptionsTracklistRenderer.captionTracks. - 4️⃣ descarga la pista (
timedtext-likebaseUrlcon sufijo&fmt=…) y la parsea como XML.
- 1️⃣ parsea el JSON embebido
- Los capítulos se extraen con una segunda ruta:
youtubei/v1/next(también con cabeceras de cliente), o desdeytInitialDataembebido (playerOverlays.playerOverlayRenderer.decoratedPlayerBarRenderer.multiMarkersPlayerBarRenderer.markersMap). - Toda la red de la popup se hace con
globalThis.fetchdirecto (no hay proxy interno), aprovechando la regla DNR 9002. El background solo ofrece un fallbacksendNativeMessagepara 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 eldocumentcon 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.htmlypopup.htmlcargan el mismopopup.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 offsets382 000–405 000del bundle (texto buscado:class YoutubeExtractoro el aliasclass A extends o.BaseExtractor).- En
reader-page.jses la misma clase con nombreA(mismo fingerprint de stringstranscript-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 & < > " ' ' &#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-AgentANDROID son los mismos que usayoutube-dl,yt-dlpy 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ñadirTVHTML5_SIMPLY_EMBEDDED_PLAYERu 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) lanzafetchdesde el contexto de la extensión, elinitiatorde la petición es elextension_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
declarativeNetRequestno soportamodifyHeadersde la misma forma. No se ejecuta en Chrome (donde ya tenemos DNR 9002). En Safari se ignora silenciosamente (catchlo 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
fetchProxyni pornativeFetch. Las llamadas van porglobalThis.fetchdirecto, 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 activareaderPinPlayer/…Description→ fija el reproductorreaderAutoScroll/…Description→ auto‑scroll durante reproducciónreaderThemeSection→ 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
- 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 enpopup.jsyreader-page.js(buscarclientVersiony20.10.38). - DNR
9002solo enxmlhttprequest: si YouTube migra afetchpuro o cambia elresourceType, la regla deja de aplicar. Verificarchrome.declarativeNetRequest.getEnabledRulesets()en la consola de la extensión. fetchProxyNO se usa para YouTube: no intentes enrutar por ahí; el flujo correcto esglobalThis.fetch+ DNR.- El content script no extrae transcripción: si la popup falla, no hay fallback desde
content.js. Mejorar la extracción implica tocarpopup.jsyreader-page.js(que son el mismo bundle). - Cache
Map<key, transcript>: existe enBilibiliExtractor.transcriptCache(LRU con cap 300).YoutubeExtractorno cachea transcripciones; cadaextractrehace la red. - Permisos: el manifest pide
<all_urls>ydeclarativeNetRequest. Si se reduce a unoptional_host_permissionsespecífico de YouTube, la popup seguirá funcionando porqueyoutubeiestá cubierto porhost_permissions, pero elwebRequestlistener de Firefox puede dejar de aplicar. - Time limit de service worker (MV3): el SW se duerme tras 30 s.
enableYouTubeInnertubeRulese registra eninitialize()al arrancar y se elimina solo si se pidedisableYouTubeInnertubeRule(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. ytInitialPlayerResponsepuede no estar presente en páginas con cookie consent previo o si el usuario está enconsent.youtube.com. La cascada ANDROID/IOS/WEB lo cubre.
11 · Resumen para indexar/embeddings
Texto generado para ser embeddings‑friendly. 7 frases autocontenidas.
- 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. - La clase
YoutubeExtractor(minificada enpopup.jsy duplicada enreader-page.js, originalmente de Defuddle) ofrece una cascada de tres rutas: (a) parseo del JSON embebidoytInitialPlayerResponse, (b) scraping del panel de transcripción DOM con selectoresytd-transcript-segment-renderer/transcript-segment-view-model, (c) peticiones a la API privadayoutubei/v1/playercon contextos de cliente ANDROID / IOS / WEB. - El bypass de CORS / firma se hace con una regla
declarativeNetRequestid 9002 que fuerzaOrigin: https://www.youtube.comyReferer: https://www.youtube.com/para todas las XHR a||youtube.com/youtubei/iniciadas por la propia extensión; Firefox usawebRequest.onBeforeSendHeadersen su lugar. - La pista de subtítulos descargada (
track.baseUrlcon sufijofmt=) 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 clasetranscript-segmenty texto plano con timestampsHH:MM:SS. - Los capítulos se extraen de
ytInitialDatao, en su defecto, deyoutubei/v1/nextcon cliente WEB, parseandoplayerOverlays.playerOverlayRenderer.decoratedPlayerBarRenderer.multiMarkersPlayerBarRenderer.markersMap. - La popup usa
globalThis.fetchdirecto (sin proxy) para YouTube; elfetchProxy/nativeFetchdebackground.jssolo se utiliza como fallback CORS para Bilibili y otros hosts, no para YouTube. - El resultado (
{transcript.text, language}) se inyecta en la nota Markdown final bajo la variabletranscript/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.