docs: documentacion completa para humanos y agentes IA
- README reescrito: ejemplos CLI corregidos (flags van en el subcomando scrape), webapp+doctor documentados, requisitos por OS, cookies, datos generados, migracion de maquina - CLAUDE.md actualizado: conteo de tests, refs de linea reparadas, scripts nuevos, checklist de actualizacion de docs - AGENTS.md nuevo: entrada estandar para cualquier agente de codigo (mapa de modulos, comandos, reglas de oro, punteros a docs) - docs/GETTING-STARTED.md: de cero a webapp por OS con troubleshooting - docs/CONFIG.md: referencia completa de las ~25 claves de config - docs/COOKIES.md: adquisicion, import (web/navegador/CLI), rotacion y seguridad - docs/backlog.md reemplaza OPPORTUNITIES.md (distilado: shipped vs pendiente) - auditorias movidas a docs/audits/ y CLIPPER-COMPARISON-AUDIT.md versionado
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
# Auditoría comparativa · Obsidian Web Clipper 1.7.1 vs `yt-channel-scraper`
|
||||
|
||||
> **Producto auditado:** *Obsidian Web Clipper* 1.7.1 (extensión MV3) en `D:\Obsidian Web Clipper - Chrome Web Store 1.7.1.0`.
|
||||
> **Sistema actual:** `yt-channel-scraper` (yt-dlp como única interfaz con YouTube; techo práctico ~300 videos/h).
|
||||
> **Alcance:** solo auditoría, comparación y análisis de mejoras. Sin cambios de código.
|
||||
> **Base:** verificación directa del bundle `popup.js` (offsets 380236–393083, clase `YoutubeExtractor`) además del doc previo `YOUTUBE-TRANSCRIPT-AUDIT.md`.
|
||||
> **Fecha:** 2026-09-01.
|
||||
|
||||
---
|
||||
|
||||
## 0 · TL;DR
|
||||
|
||||
La extensión resuelve el mismo problema (metadatos + transcripción de YouTube sin API oficial) con una filosofía de red **opuesta y complementaria** a la del scraper:
|
||||
|
||||
| | Scraper (hoy) | Extensión |
|
||||
|---|---|---|
|
||||
| Filosofía ante el fallo | **Backoff temporal**: esperar y reintentar más tarde (Pacer + ThrottleGuard, abort limpio) | **Rotación de identidad**: cambiar de cliente/recurso y degradar, casi nunca esperar |
|
||||
| Requests por video | 3 (watch + player + timedtext), medidos irreducibles vía yt-dlp (`config.yaml:21-33`) | 1–2 (player InnerTube + timedtext); el 3º (`next`) solo si faltan capítulos |
|
||||
| Identidad | 1 cookie activa, cliente yt-dlp default, sin rotación (`cookies.py:151-163`) | 3 clientes InnerTube en cascada (IOS → ANDROID+UA → WEB), **sin cookies** |
|
||||
| Retry del mismo recurso | Nunca (deliberado, `ratelimit.py:18-22`) | Nunca tampoco: 1 intento por cliente, error silenciado, siguiente |
|
||||
| Timeout | 15–30 s (`_yt_http.py:40`, `config.yaml:55`) | **4 s** por intento (`AbortSignal.timeout(4e3)`) |
|
||||
| Criterio de éxito | HTTP status (429/403 → clasificar y abortar) | **Contenido**: `r.ok && captionTracks.length > 0` — un 200 vacío se trata como fallo y se rota |
|
||||
| Degradación del resultado | `skip_reason` con taxonomía de causas | Siempre entrega nota con metadatos; transcripción es opcional |
|
||||
|
||||
Los insights accionables para el scraper están en §2, priorizados en §3.
|
||||
|
||||
---
|
||||
|
||||
## 1 · Qué hace la extensión (verificado en el bundle)
|
||||
|
||||
Cadena de extracción de `YoutubeExtractor.extractAsync()` (offset ~380236 de `popup.js`):
|
||||
|
||||
1. **DOM existente** (costo 0): segmentos ya renderizados en la página.
|
||||
2. **Ruta de red principal** `fetchTranscript()`:
|
||||
- `fetchChapters(videoId)` se **dispara sin await** (la promesa se resuelve en paralelo).
|
||||
- Track inline desde `ytInitialPlayerResponse` del DOM (costo 0), validando que `videoDetails.videoId` coincida con el de la URL (`getValidatedPlayerResponse`).
|
||||
- Si no hay inline: `fetchPlayerData(videoId)` → **POST a `youtubei/v1/player?prettyPrint=false`** con cascada:
|
||||
1. `{clientName:"IOS", clientVersion:"20.10.3"}` — sin UA especial.
|
||||
2. `{clientName:"ANDROID", clientVersion:"20.10.38"}` + `User-Agent: com.google.android.youtube/20.10.38 (Linux; U; Android 14)`.
|
||||
3. `{clientName:"WEB", clientVersion:"2.20240101.00.00"}`.
|
||||
4. Fallback final: JSON embebido del DOM.
|
||||
- Cada intento: timeout 4 s, `try{}catch{}` silenciado, y **se acepta solo si `captionTracks.length > 0`**.
|
||||
- Descarga del track: `GET track.baseUrl` con guard de host (`new URL(baseUrl).hostname.endsWith(".youtube.com")`), UA `Mozilla/5.0`, `Accept-Language` si hay idioma preferido, timeout 4 s.
|
||||
3. **Apertura programática del panel** de transcripción (click + polling `pollFor` cada 250 ms, máx 20 intentos) como último recurso.
|
||||
|
||||
Capítulos (`fetchChapters`): primero inline desde `ytInitialData` (`playerOverlays…multiMarkersPlayerBarRenderer.markersMap`); si vacío, POST a `youtubei/v1/next` con cliente WEB; segundo fallback `engagementPanels[*].macroMarkersListItemRenderer`.
|
||||
|
||||
Puntos de red relevantes:
|
||||
|
||||
- Los POST a InnerTube **no llevan cookies** (el fetch de la popup corre en contexto de extensión, `credentials` same-origin ⇒ youtube.com no recibe sesión). La ruta IOS/ANDROID funciona **anónima**.
|
||||
- El `Origin: https://www.youtube.com` / `Referer` los fuerza la regla DNR 9002 porque un browser no puede setear `Origin` — en Python sería simplemente otro header.
|
||||
- `BilibiliExtractor` (mismo bundle) sí cachea transcripciones: LRU `Map` con tope 300 entradas. `YoutubeExtractor` no cachea nada.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Insights accionables para el scraper
|
||||
|
||||
### I1 · Ruta InnerTube propia como *modo degradado* (impacto alto, esfuerzo medio)
|
||||
|
||||
**Evidencia:** la extensión obtiene metadatos completos + `captionTracks` con **un solo POST anónimo** a `youtubei/v1/player` con cliente IOS. `videoDetails` da título, autor, channelId, lengthSeconds, viewCount, keywords; `microformat` da publishDate, description, ownerChannelName. Nada de watch page.
|
||||
|
||||
**Aplicación:** hoy, cuando el ThrottleGuard trip (`ratelimit.py:221-283`), el job aborta limpio y todo queda `pending` hasta el siguiente pase del monitor. Una vía de salvage — POST directo a `player` (1 petición/video en vez de 3) para lo estrictamente necesario (transcripción + metadatos básicos) — permitiría **seguir produciendo a ⅓ del costo** durante los periodos en que la ruta completa (watch page incluida) está bloqueada. `_yt_http.py` ya es el lugar natural para ese cliente.
|
||||
|
||||
**Advertencias:**
|
||||
- `config.yaml:28-32` dice "3 requests irreducibles — no re-litigar". Esa medición fue sobre **yt-dlp restringido** (`player_skip=webpage`, single-client), que pierde pistas. La ruta de la extensión es distinta: una llamada InnerTube propia con aceptación por contenido. No la invalida, pero **habría que medirla** antes de tratarla como reemplazo; como modo degradado opcional el riesgo es acotado.
|
||||
- Las versiones de cliente hardcodeadas de la extensión (20.10.3 / 20.10.38 / 2.20240101) tienen más de un año de rotación. Si se implementa, tomar las versiones vigentes de yt-dlp (que ya las mantiene) en vez de hardcodear, o aceptar el mismo mantenimiento que la extensión.
|
||||
- YouTube exige PO tokens en algunos clientes para formats/streaming; para metadatos/captions la ruta IOS/ANDROID ha seguido funcionando sin ellos (es la evidencia de esta extensión), pero es el punto que puede romperse.
|
||||
|
||||
### I2 · Aceptación por contenido, no por status (impacto medio, esfuerzo bajo)
|
||||
|
||||
La extensión trata "HTTP 200 con respuesta inútil" como fallo y rota. El scraper ya clasifica causas (`skip_reason`, `describe_missing_subtitle`), pero la aceptación es binaria por status. Aplicable a: timedtext que devuelve 200 con cuerpo vacío/corrupto (hoy parsearía vacío y se marcaría "parsed empty" en vez de reintentable), y a cualquier futura llamada InnerTube propia.
|
||||
|
||||
### I3 · Timeout corto con fail-fast en timedtext (impacto medio, esfuerzo bajo)
|
||||
|
||||
`extract.py:301-321` baja subtítulos con timeout 15 s; `config.yaml:55` pone 30 s de socket. El punto donde el throttling "más aparece" es precisamente timedtext (`extract.py:302-307`). Un timeout más agresivo (configurable, p. ej. 6–8 s para json3/srv1 — payloads pequeños) convertiría cuelgues de 15–30 s en un fallo clasificable como reintentable casi inmediato, liberando el pacer antes. La extensión usa 4 s para todo.
|
||||
|
||||
### I4 · Rotación de cookie al hacer trip el ThrottleGuard (impacto alto, esfuerzo medio)
|
||||
|
||||
La extensión no rota cookies (viaja sobre la sesión real del usuario), pero su patrón estructural — *ante el bloqueo, cambiar de identidad en vez de solo esperar* — traducido al scraper es: el vault ya persiste múltiples cookies (`cookies/<uuid>.txt` + `cookies_meta`), pero `resolve_active_path` usa exactamente una (`cookies.py:151-163`). Al trip del breaker, cambiar a la siguiente cookie no expirada antes de rendirse al reloj multiplicaría el presupuesto efectivo por sesión sin nueva infraestructura. (Insight inspirado en el patrón de la extensión, no copiado de ella.)
|
||||
|
||||
### I5 · `youtubei/v1/next` para capítulos sin watch page (habilitador de I1)
|
||||
|
||||
Si algún día se activa la ruta de 1 petición (I1), los capítulos —que hoy llegan vía info de yt-dlp desde la watch page (`chapters.py:39-49`)— se recuperan con un POST a `next` (cliente WEB) parseando `playerOverlays…markersMap`, con fallback a `engagementPanels`. El bundle de la extensión contiene la implementación de referencia exacta (offset ~393083). Solo necesario para videos con capítulos; el resto no paga el request.
|
||||
|
||||
### I6 · Guard de host antes de descargar timedtext (hardening, esfuerzo mínimo)
|
||||
|
||||
`fetchCaptionXml` exige `hostname.endsWith(".youtube.com")` antes del GET al `baseUrl` del caption track. El scraper baja `pick.url` con `yt_get` sin validar host (`extract.py:301-321`). La URL viene de yt-dlp (confiable hoy), pero una línea de validación cierra la clase de riesgo "baseUrl corrupto/inyectado ⇒ GET con headers de navegador a un host arbitrario".
|
||||
|
||||
### I7 · Detalles menores de protocolo (gratis si se implementa I1)
|
||||
|
||||
- `?prettyPrint=false` en llamadas InnerTube: menos payload.
|
||||
- `Origin: https://www.youtube.com` como header explícito en POSTs propios.
|
||||
- Lanzar la descarga dependiente como promesa paralela (la extensencia dispara `fetchChapters` sin await): en el scraper el equivalente sería solapar la descarga de timedtext con el siguiente video del pacer **solo si** el presupuesto de requests ya lo contempla — cuidado: hoy el pacer es la política de cortesía, no paralelizar contra él.
|
||||
|
||||
### I8 · Paridades confirmadas (sin acción)
|
||||
|
||||
- Selección de pista por idioma: `pick_subtitle` del scraper (política por idioma, rechazo de `tlang=`, detección `-orig`, prioridad json3) es **más rica** que `pickCaptionTrack`/`findPreferredCaptionTrack` de la extensión.
|
||||
- Degradación graciosa del resultado: taxonomía `skip_reason` ≥ "nota siempre con metadatos" de la extensión.
|
||||
- Caching: SQLite del scraper > LRU 300 del BilibiliExtractor; YoutubeExtractor ni siquiera cachea.
|
||||
- Validación de JSON inline contra videoId (`getValidatedPlayerResponse`): patrón correcto a recordar **si** algún día se cachean player responses o se reutilizan continuations entre sesiones (evita atribuir a un video la respuesta de otro).
|
||||
|
||||
---
|
||||
|
||||
## 3 · Priorización sugerida
|
||||
|
||||
| # | Mejora | Contra qué límite ayuda | Esfuerzo | Riesgo |
|
||||
|---|---|---|---|---|
|
||||
| 1 | I3 timeout fail-fast en timedtext | Throughput bajo throttling | Bajo | Bajo (hacerlo configurable) |
|
||||
| 2 | I6 guard de host | Hardening | Mínimo | Nulo |
|
||||
| 3 | I4 rotación de cookies al trip del breaker | Techo de presupuesto por sesión | Medio | Medio (cuenta de la cookie expuesta al mismo ritmo) |
|
||||
| 4 | I1+I2+I5+I7 ruta InnerTube propia como modo degradado | Bot wall: seguir produciendo a ⅓ de costo | Medio-Alto | Medio (requiere medición; mantenimiento de client versions) |
|
||||
|
||||
## 4 · Qué NO copiar de la extensión
|
||||
|
||||
1. **Scraping del DOM del panel de transcripción** (clicks + polling): requiere un browser real con fingerprint real; el scraper es headless. Solo cobraría sentido con Playwright + perfil real, que es otra conversación.
|
||||
2. **Re-litigar los 3 requests/video vía yt-dlp** (`config.yaml:28-32`): la medición del repo sigue en pie para yt-dlp. La vía InnerTube propia es una **ruta paralela degradada**, no un reemplazo de la ruta completa.
|
||||
3. **Silenciado de errores** (`try{}catch{}` sin logging): la extensión degradea muda; el scraper necesita auditabilidad (`skip_reason` ya la da).
|
||||
4. **Versiones de cliente hardcodeadas**: la extensión las parchea por release; el scraper ya delega eso en yt-dlp. Cualquier ruta propia debe heredar las versiones de yt-dlp, no duplicarlas.
|
||||
|
||||
---
|
||||
|
||||
*Fin del informe.*
|
||||
@@ -0,0 +1,8 @@
|
||||
# Auditorías
|
||||
|
||||
Documentos de investigación que explican decisiones técnicas del proyecto mediante ingeniería inversa. No son specs ni manuales: son material de referencia.
|
||||
|
||||
- **`YOUTUBE-TRANSCRIPT-AUDIT.md`** — ingeniería inversa de la extensión *Obsidian Web Clipper* 1.7.1: cómo obtiene metadatos y transcripciones vía la API privada InnerTube (`youtubei/v1/player`) con clientes ANDROID/IOS/WEB. Es el contexto de *por qué* este scraper delega en `yt-dlp` (que implementa el mismo mecanismo) en vez de llamar a InnerTube directamente.
|
||||
- **`CLIPPER-COMPARISON-AUDIT.md`** — comparativa extensión vs `yt-channel-scraper`: filosofías de red opuestas (una petición por vídeo pinchado vs batch educado de canal) y qué ideas de una aplican a la otra.
|
||||
|
||||
Ambos provienen de la raíz del repo y se mantienen sin edición aquí.
|
||||
@@ -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 `& < > " ' ' &#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.*
|
||||
Reference in New Issue
Block a user