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:
+110
@@ -0,0 +1,110 @@
|
||||
# Referencia de configuración (`config.yaml`)
|
||||
|
||||
`config.yaml` es tu configuración privada (gitignored). La plantilla versionada `config.example.yaml` documenta todas las claves y es el punto de partida (`cp config.example.yaml config.yaml`). Todas las rutas del archivo se resuelven **relativas al CWD** — ejecuta siempre desde la raíz del repo.
|
||||
|
||||
Los defaults que siguen son los de `config.example.yaml` (entre paréntesis, cuando difiere, el default del código en `src/yt_scraper/config.py`).
|
||||
|
||||
## Bloque raíz — canal, idiomas y filtros
|
||||
|
||||
| Clave | Tipo | Default | Qué afecta |
|
||||
|---|---|---|---|
|
||||
| `channel_url` | str | `"https://www.youtube.com/@Nostal-Vlad/videos"` | Canal por defecto de `yt-scraper scrape` / webapp. Se ignora si pasas `--channel`/`-c`. Acepta URL completa, `/channel/UC...` o handle |
|
||||
| `languages` | dict \| list | `{es: any, es-419: any, en: any}` | Idiomas de subtítulos aceptables y su modo (ver abajo). Forma legacy: lista `["es","en"]` — todos con el modo de `prefer_manual`; evítala, un canal solo-auto quedaría en `no_subtitles` |
|
||||
| `prefer_manual` | bool | `true` | En modo `any`: prueba subtítulos manuales antes que los automáticos |
|
||||
| `include_shorts` | bool | `false` | Descubrir/procesar Shorts. En CLI: `--include-shorts` / `--no-shorts` |
|
||||
| `include_live` | bool | `true` | Procesar directos. En CLI: `--no-live` |
|
||||
| `min_duration_sec` | int | `30` (código: `0`) | Duración mínima del vídeo en segundos; filtra en discovery |
|
||||
|
||||
Modos por idioma de `languages`:
|
||||
|
||||
| Valor | Significado |
|
||||
|---|---|
|
||||
| `manual` | **Solo** subtítulos subidos por el creador; si solo hay automáticos → `no_subtitles` |
|
||||
| `auto` | **Solo** subtítulos automáticos (ASR) |
|
||||
| `any` | Manuales primero (según `prefer_manual`), automáticos como fallback. **Recomendado** |
|
||||
|
||||
Nota: `languages` es una preferencia entre idiomas que sabes leer, no una orden de traducir. El idioma hablado del vídeo siempre gana si está en la lista; nunca se guarda una traducción automática si existe el transcript original.
|
||||
|
||||
## Bloque `delay` — pacing y rate-limit
|
||||
|
||||
YouTube no publica límites; el techo práctico conocido (wiki de yt-dlp) es ~300 vídeos/hora en sesión sin cuenta. Estos valores apuntan por debajo. Con los defaults conservadores, una tirada real cuesta **del orden de 12–13 s por vídeo** (~270 vídeos/hora).
|
||||
|
||||
| Clave | Tipo | Default | Qué afecta |
|
||||
|---|---|---|---|
|
||||
| `min_seconds` | float | `1.5` | Pausa aleatoria mínima entre vídeos (el gap real se sortea entre `min` y `max`) |
|
||||
| `max_seconds` | float | `3.5` | Pausa aleatoria máxima entre vídeos |
|
||||
| `backoff_base` | float | `2.0` | Backoff tras un rate-limit: `min(base * 2**n + jitter, cap)`, con n = fallos consecutivos |
|
||||
| `backoff_cap` | float | `60.0` | Techo del backoff en segundos |
|
||||
| `throttle_threshold` | int | `3` | Rate-limits **consecutivos** que hacen parar la tirada (circuit breaker). Lo no procesado queda `pending` y es recuperable |
|
||||
| `min_request_interval` | float | `0.0` (example) | Separación mínima entre **cualquier** dos peticiones a YouTube del proceso (Pacer global, incluye los `/api/tools/*` fuera del job runner). `0` = desactivado. Es la única palanca que actúa en todas partes a la vez |
|
||||
| `audio_rate_limit` | int | `0` | Techo de bytes/segundo para descargas de audio (ratelimit de yt-dlp). `0` = ilimitado |
|
||||
|
||||
Referencia práctica: la config real del autor usa `min_request_interval: 2.5` para ser aún más conservador cuando la webapp y el CLI conviven.
|
||||
|
||||
## Bloque `yt_dlp` — reintentos y timeouts
|
||||
|
||||
| Clave | Tipo | Default | Qué afecta |
|
||||
|---|---|---|---|
|
||||
| `retries` | int | `10` | Reintentos de descarga; solo muerde en el camino de audio (el extractor de YouTube de yt-dlp no reintenta 403/429) |
|
||||
| `sleep_subrequests` | float | `2` | Segundos entre las peticiones HTTP **dentro** de una extracción (watch page, llamada player, continuations). Se reenvía a yt-dlp como `sleep_interval_requests` (nombre interno del proyecto; el yt-dlp no tiene ninguna opción llamada `sleep_subrequests`) |
|
||||
| `extractor_retries` | int | `3` | Reintentos durante la extracción (solo 5xx y red) |
|
||||
| `socket_timeout` | float | `30.0` | Timeout de socket; sin él una conexión colgada bloquea el worker para siempre |
|
||||
|
||||
## Bloque `sync` — discovery incremental
|
||||
|
||||
Re-escanear un canal trackeado lee la pestaña `/videos` (cronológica inversa) y corta al ver `overlap` vídeos ya conocidos: un sync rutinario cuesta 1 página, no el canal entero.
|
||||
|
||||
| Clave | Tipo | Default | Qué afecta |
|
||||
|---|---|---|---|
|
||||
| `incremental` | bool | `true` | Activar el modo ventana. `false` (o `--full` / "Full rescan" en la webapp) recorre todo el canal |
|
||||
| `window` | int | `30` | Entradas leídas en la primera pasada |
|
||||
| `max_window` | int | `300` | Techo: si TODA la ventana resulta nueva, se duplica hasta aquí antes de declarar el scan truncado (aviso "usa `--full`") |
|
||||
| `overlap` | int | `3` | Vídeos conocidos consecutivos que dan la sincronización por alcanzada |
|
||||
|
||||
## Rutas y plantillas
|
||||
|
||||
| Clave | Tipo | Default | Qué afecta |
|
||||
|---|---|---|---|
|
||||
| `database_path` | str | `"data/state.db"` | Ruta de la BD SQLite (WAL). Relativa al CWD |
|
||||
| `output_dir` | str | `"data/markdown"` | Directorio de notas; el resto de datos (`audio/`, `thumbnails/`, ...) se deriva de su padre |
|
||||
| `template_path` | str | `"templates/video.md.j2"` | Plantilla Jinja2 de la nota. OJO: el formato del `.md` es un contrato bidireccional (los regex de `segments.py` lo re-lean) |
|
||||
| `filename_template` | str | `"{upload_date}_{slug}"` | Patrón del nombre de las notas. Placeholders: `{upload_date}` (fecha de subida; `unknown-date` si se desconoce), `{slug}` (slug del título, máx 60), `{title}` (título crudo), `{video_id}` (id estable de YouTube — útil porque título y fecha son inestables) |
|
||||
|
||||
## Ejemplos
|
||||
|
||||
**Mínima** (defaults razonables, primer contacto):
|
||||
|
||||
```yaml
|
||||
channel_url: "https://www.youtube.com/@MiCanal/videos"
|
||||
languages:
|
||||
es: any
|
||||
```
|
||||
|
||||
**Conservadora** (muchos vídeos, webapp y CLI a la vez, o historial de rate-limits):
|
||||
|
||||
```yaml
|
||||
channel_url: "https://www.youtube.com/@MiCanal/videos"
|
||||
languages:
|
||||
es: any
|
||||
es-419: any
|
||||
en: any
|
||||
prefer_manual: true
|
||||
include_shorts: false
|
||||
min_duration_sec: 30
|
||||
|
||||
delay:
|
||||
min_seconds: 1.5
|
||||
max_seconds: 3.5
|
||||
backoff_base: 2.0
|
||||
backoff_cap: 60.0
|
||||
throttle_threshold: 3
|
||||
min_request_interval: 2.5 # pacer global: la palanca más eficaz contra el bot-wall
|
||||
|
||||
sync:
|
||||
incremental: true
|
||||
window: 30
|
||||
max_window: 300
|
||||
overlap: 3
|
||||
```
|
||||
|
||||
Si cambias los tiempos, vuelve a medir sobre tu canal: la constante es ~3 peticiones a `youtube.com` por vídeo y de ahí sale todo lo demás.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Guía de cookies
|
||||
|
||||
Las cookies son **tu sesión de YouTube**. Este scraper las usa (vía yt-dlp) para presentarse con esa sesión en cada petición.
|
||||
|
||||
## Por qué hacen falta
|
||||
|
||||
- **Vídeos de membresía** (`subscriber_only`): sin sesión es imposible descargarlos; con ella, si estás suscrito al canal, entran como cualquier otro.
|
||||
- **Menos bot-checks / rate-limit**: una sesión autenticada aguanta bastante más tráfico que una anónima antes de toparse con el muro (~300 vídeos/hora en sesión de invitado según la wiki de yt-dlp).
|
||||
- **Challenges y PO tokens**: parte de los retos anti-bot que YouTube sirve se suavizan cuando la petición viaja con cookies de una sesión real.
|
||||
|
||||
Sin cookies el scraper funciona igual; simplemente verás más `error` por throttling y los vídeos de membresía quedan bloqueados.
|
||||
|
||||
## Qué es un `cookies.txt` (formato Netscape)
|
||||
|
||||
Un archivo de texto plano con una cookie por línea, tabulador-separado:
|
||||
|
||||
```
|
||||
.youtube.com\tTRUE\t/\tTRUE\t1798761600\tSID\t"value"
|
||||
.youtube.com\tTRUE\t/\tTRUE\t0\t__Secure-3PSID\t"value"
|
||||
```
|
||||
|
||||
Detalles que importan aquí:
|
||||
|
||||
- Las líneas `#HttpOnly_...` **son datos**, no comentarios: las cookies de login (SID, HSID, ...) son HttpOnly y un export que las omita no sirve.
|
||||
- **Sesión válida** (criterio que aplica el vault al importar): el archivo contiene las tres `SID` + `HSID` + `SSID`, **o** al menos `LOGIN_INFO`. Las que debe traer un export correcto: `SID`, `HSID`, `SSID`, `SAPISID`, `LOGIN_INFO` (y normalmente también `APISID`, `__Secure-3PSID`, ...).
|
||||
- Un archivo con solo `__Secure-3PSID` (export parcial de algunas extensiones) **no es una sesión**: YouTube lo trata como anónimo y la membresía sigue bloqueada.
|
||||
|
||||
## Cómo exportarlas (paso a paso)
|
||||
|
||||
1. Inicia sesión en [youtube.com](https://www.youtube.com) en tu navegador (Chrome, Brave o Firefox).
|
||||
2. Instala la extensión **"Get cookies.txt LOCALLY"** (Chrome Web Store / Firefox Add-ons). La palabra LOCALLY importa: exporta en tu máquina sin mandar nada a un servidor.
|
||||
3. Con youtube.com abierto, abre la extensión y exporta las cookies de **youtube.com** (formato Netscape por defecto).
|
||||
4. Guarda el archivo (`cookies.txt`). Verifica que aparecen `SID`, `HSID`, `SSID`, `SAPISID` y `LOGIN_INFO`.
|
||||
|
||||
## Cómo importarlas
|
||||
|
||||
### Webapp (recomendado)
|
||||
|
||||
Sección **Cookies** de la UI:
|
||||
|
||||
- **Arrastrar y soltar** el `cookies.txt` (o selección manual). El vault lo copia a `cookies/<uuid>.txt`, analiza sesión/caducidad y lo registra con etiqueta.
|
||||
- **Importar desde navegador**: lee las cookies directamente del navegador local (Brave por defecto). **El navegador debe estar cerrado por completo** — Chromium bloquea el archivo de cookies si el proceso vive, y el error que verás es "cookie store locked". Al importar así, la activación es automática.
|
||||
- Si no hay ninguna activa, la primera que subas se activa sola.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
yt-scraper --cookies ruta/a/cookies.txt scrape # usar un archivo concreto
|
||||
yt-scraper --cookies-from-browser brave scrape # chrome|firefox|edge|brave
|
||||
```
|
||||
|
||||
Además, al arrancar el CLI o la webapp, cualquier `.txt` suelto en `cookies/` se adopta automáticamente al vault (`auto_import_dir`, idempotente).
|
||||
|
||||
## Activación
|
||||
|
||||
Hay **exactamente una cookie activa** a la vez: CLI, webapp y watch usan esa si no se pasa `--cookies`. En la webapp puedes cambiar la activa (botón *activate*), probarla (*test* comprueba que la sesión sigue viva) y borrar las demás.
|
||||
|
||||
## Rotación y caducidad
|
||||
|
||||
- Las cookies de login **caducan** (meses) o se invalidan si cierras sesión / cambias contraseña en ese navegador. Cuando el scrape vuelva a ver bloqueos de membresía o un chorreo de rate-limits, re-exporta y sube un archivo nuevo.
|
||||
- El vault marca el estado `expired` según la fecha de expiración del propio archivo; el criterio `has_session` (SID+HSID+SSID o LOGIN_INFO) se comprueba en la importación y en el listado.
|
||||
- No pasa nada por tener varias en el vault: solo la activa se usa.
|
||||
|
||||
## Seguridad
|
||||
|
||||
- **Son equivalentes a tu contraseña de Google para YouTube.** Quien tenga el archivo puede usar tu sesión.
|
||||
- `cookies/` está en `.gitignore` — nunca las commitees, ni las pegues en un chat, ni las subas a ningún sitio.
|
||||
- Si sospechas una fuga: cierra la sesión de YouTube en ese navegador (invalida las cookies) y re-exporta.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Getting Started — máquina nueva desde cero
|
||||
|
||||
De un equipo vacío a la webapp corriendo y el primer scrape hecho. Una sección por OS; salta a la tuya. Todo lo demás (arquitectura, CLI completo, config) está en el [README](../README.md).
|
||||
|
||||
Regla transversal: **todos los comandos se ejecutan desde la raíz del repo** (`yt-channel-scraper/`), porque las rutas de `config.yaml` se resuelven relativas al directorio de trabajo.
|
||||
|
||||
## Windows
|
||||
|
||||
### 1. Python
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
winget install Python.Python.3.12
|
||||
# cierra y reabre la terminal para que entre en PATH; verifica:
|
||||
python --version
|
||||
```
|
||||
|
||||
### 2. Obtener el código
|
||||
|
||||
```powershell
|
||||
git clone <URL-del-gitea> yt-channel-scraper
|
||||
cd yt-channel-scraper
|
||||
```
|
||||
|
||||
### 3. Entorno + dependencias
|
||||
|
||||
```powershell
|
||||
# Opción A: uv (recomendado; respeta uv.lock)
|
||||
winget install astral-sh.uv
|
||||
uv sync --all-extras
|
||||
|
||||
# Opción B: venv + pip
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -e ".[dev,web,analysis]"
|
||||
```
|
||||
|
||||
Extras: `dev` (tests), `web` (webapp), `analysis` (gráficos). Puedes instalar solo los que uses.
|
||||
|
||||
### 4. ffmpeg y node (opcionales)
|
||||
|
||||
- **ffmpeg**: solo necesario para `yt-scraper audio` / job de audio → `winget install Gyan.FFmpeg`.
|
||||
- **node**: yt-dlp lo usa para resolver challenges de YouTube → `winget install OpenJS.NodeJS.LTS`.
|
||||
|
||||
Reabre la terminal tras instalar y verifica con `ffmpeg -version` / `node -v`.
|
||||
|
||||
### 5. Configuración
|
||||
|
||||
```powershell
|
||||
copy config.example.yaml config.yaml
|
||||
notepad config.yaml
|
||||
```
|
||||
|
||||
Cambia al menos `channel_url` (URL `/videos` de tu canal). Referencia de todas las claves: [CONFIG.md](CONFIG.md).
|
||||
|
||||
### 6. Cookies (recomendado)
|
||||
|
||||
Sin cookies funciona, pero con sesión de YouTube reduce los bot-checks y desbloquea vídeos de membresía. Resumen: exporta `cookies.txt` desde tu navegador con la extensión "Get cookies.txt LOCALLY" (logueado a youtube.com) y arrástralo a la webapp, o usa `--cookies`. Guía completa: [COOKIES.md](COOKIES.md).
|
||||
|
||||
### 7. Primer scrape (sin descargar nada)
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\activate # si usaste venv; con uv: uv run yt-scraper ...
|
||||
yt-scraper scrape --dry-run --limit 10
|
||||
yt-scraper scrape --limit 3 # primeras 3 notas en data/markdown/<Canal>/
|
||||
```
|
||||
|
||||
### 8. Arrancar la webapp
|
||||
|
||||
Doble clic en `start-server.bat`. La primera vez pasa por `scripts/doctor.ps1`, que instala lo que falte (Python 3.12 vía winget + `pip install -e ".[web]"`) y delega en `scripts/start-server.ps1`: busca puerto libre (8000–8100), arranca uvicorn, registra el PID en `.run/server.info`, comprueba `/healthz` y abre el navegador. Para parar: `stop-server.bat`.
|
||||
|
||||
Manual (debug): `python -m uvicorn yt_scraper.webapp.app:app --host 127.0.0.1 --port 8000`.
|
||||
|
||||
## macOS (Apple Silicon)
|
||||
|
||||
### 1. Homebrew + Python
|
||||
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
brew install [email protected] ffmpeg node
|
||||
```
|
||||
|
||||
Todas las dependencias Python publican wheels arm64; no hay compilación.
|
||||
|
||||
### 2-3. Código y entorno
|
||||
|
||||
```bash
|
||||
git clone <URL-del-gitea> yt-channel-scraper
|
||||
cd yt-channel-scraper
|
||||
|
||||
# Opción A: uv
|
||||
brew install astral-sh.uv && uv sync --all-extras
|
||||
|
||||
# Opción B: venv + pip
|
||||
python3.12 -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[dev,web,analysis]"
|
||||
```
|
||||
|
||||
### 4. ffmpeg / node
|
||||
|
||||
Ya instalados en el paso 1 (si los saltaste: `brew install ffmpeg node`).
|
||||
|
||||
### 5-6. Config y cookies
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
$EDITOR config.yaml # cambia channel_url
|
||||
```
|
||||
|
||||
Cookies: [COOKIES.md](COOKIES.md).
|
||||
|
||||
### 7. Primer scrape
|
||||
|
||||
```bash
|
||||
yt-scraper scrape --dry-run --limit 10
|
||||
yt-scraper scrape --limit 3
|
||||
```
|
||||
|
||||
### 8. Arrancar la webapp
|
||||
|
||||
```bash
|
||||
make setup # primera vez: scripts/bootstrap.sh prepara todo lo que falte
|
||||
make serve # arranca uvicorn y abre el navegador
|
||||
make stop # para el servidor
|
||||
```
|
||||
|
||||
Equivalente sin Makefile: `scripts/bootstrap.sh && scripts/start-server.sh` / `scripts/stop-server.sh`.
|
||||
|
||||
## Linux (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-venv python3-pip git ffmpeg nodejs # nodejs si el repo lo empaqueta; si no, usa nodesource
|
||||
```
|
||||
|
||||
El resto es idéntico a macOS: `git clone` → `uv sync --all-extras` (o venv + `pip install -e ".[dev,web,analysis]"`) → `cp config.example.yaml config.yaml` → `yt-scraper scrape --dry-run` → `make serve`. En distros sin `make`: `scripts/bootstrap.sh` + `scripts/start-server.sh`.
|
||||
|
||||
Si tu distro trae Python < 3.10, instala uno moderno (pyenv, deadsnakes PPA en Ubuntu, o `brew` en Linux) — el paquete exige `>=3.10`.
|
||||
|
||||
## Verificación final
|
||||
|
||||
```bash
|
||||
uv run python -m pytest -q # debe pasar la suite completa (~240 tests, sin red)
|
||||
yt-scraper channels list # tras el primer scrape: tu canal con sus vídeos
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Síntoma | Causa | Arreglo |
|
||||
|---|---|---|
|
||||
| `ModuleNotFoundError: yt_scraper` tras mover/renombrar el repo | La instalación editable apunta a la ruta vieja | `pip install -e ".[dev,web,analysis]"` otra vez (o recrea el venv: `uv sync --all-extras`) |
|
||||
| `yt-scraper: command not found` | Venv sin activar, o instalaste sin `-e` | Activa el venv, o usa `uv run yt-scraper ...`, o `python -m yt_scraper.cli` |
|
||||
| Los paths apuntan a sitios raros (`data/` vacío en otro lado) | Comandos ejecutados fuera de la raíz del repo | `cd` a la raíz; las rutas son relativas al CWD |
|
||||
| `file cannot be loaded because running scripts is disabled` al lanzar un `.ps1` | PowerShell ExecutionPolicy restringido | `powershell -ExecutionPolicy Bypass -File scripts\start-server.ps1`, o `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` |
|
||||
| "puerto ocupado" / el navegador abre otra app | Resto de un servidor anterior en el puerto | Borra `.run/server.info` (estado obsoleto) o usa `stop-server.bat` / `make stop`; el arranque busca puerto libre 8000–8100 |
|
||||
| `uv: command not found` o trampolines rotos tras actualizar uv/Python | Los shims del venv quedaron inconsistentes | Ejecuta vía módulo: `uv run python -m pytest -q`, `python -m yt_scraper.cli` |
|
||||
| Tests de red fallan / scrape con muchos `error` seguidos | Rate-limit de YouTube (sin cookies es más frecuente) | Espera; revisa `delay` en [CONFIG.md](CONFIG.md); añade cookies ([COOKIES.md](COOKIES.md)) |
|
||||
| `ffmpeg no encontrado` en `yt-scraper audio` | ffmpeg no está en PATH | `winget install Gyan.FFmpeg` / `brew install ffmpeg` / `apt install ffmpeg`, y reabre la terminal |
|
||||
@@ -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.*
|
||||
@@ -0,0 +1,40 @@
|
||||
# Backlog
|
||||
|
||||
Destilado del histórico `OPPORTUNITIES.md`: qué sigue siendo candidato a construir y qué ya existe. Criterio de inclusión en "pendiente": no implementado hoy en el repo.
|
||||
|
||||
## Ya está hecho (no re-abrir)
|
||||
|
||||
| Feature (ítem original) | Dónde vive |
|
||||
|---|---|
|
||||
| `search` — búsqueda FTS5 en transcripciones | `yt-scraper search "..."` + webapp |
|
||||
| `export` json/csv/srt/html | `yt-scraper export --format ...` → `data/exports/` |
|
||||
| `audio` — descarga MP3 | `yt-scraper audio` + job de audio de la webapp (ffmpeg) |
|
||||
| `re-render` + `--backfill` | `yt-scraper re-render` |
|
||||
| Multi-canal | `yt-scraper channels add/list/remove`, `--all-channels` |
|
||||
| `watch` — monitoreo periódico | `yt-scraper watch --interval 6h` |
|
||||
| Análisis (top-words, wordcloud, timeline) | `yt-scraper analyze` → `data/analysis/` |
|
||||
| Miniaturas | `data/thumbnails/` + herramienta *Download thumbnails* de la webapp |
|
||||
| Vista grid de vídeos | Webapp (grid de tarjetas con miniatura) |
|
||||
| Cookies (vault, import, activación) | Webapp + flags `--cookies` / `--cookies-from-browser` — ver [COOKIES.md](COOKIES.md) |
|
||||
| Sync incremental de canales | `sync:` en config; discovery de ventana con overlap (no estaba en el original) |
|
||||
|
||||
## Pendiente
|
||||
|
||||
| Feature | Por qué (1 línea) | Esfuerzo |
|
||||
|---|---|---|
|
||||
| `clip` — texto entre dos timestamps (`yt-scraper clip <id> --from 01:38 --to 03:20`) | Citar fragmentos sin abrir el `.md`; los segmentos ya están en la BD | ~30 min |
|
||||
| `stats` como subcomando | La webapp tiene dashboard y el CLI imprime un resumen tras scrape, pero no existe `yt-scraper stats` con rango/wordcount/tags top | ~1 h |
|
||||
| Traducción de transcripciones (es→en o inversa) | Bibliotecas bilingües y notas para no hispanohablantes; hoy el picker rechaza traducciones por diseño (guarda el transcript real) | 3-4 h vía `deep-translator`; más si se quiere calidad |
|
||||
| Alertas al terminar un job (notificación de escritorio) | Jobs largos sin vigilancia; hoy solo SSE en la pestaña abierta | 2-3 h |
|
||||
| Export a formatos de note-taking (Obsidian Canvas, Anki) | El contenido ya está segmentado por capítulos; solo cambia la salida | 3-4 h |
|
||||
|
||||
## Fuera de alcance (por decisión de plataforma)
|
||||
|
||||
El spec (`docs/superpowers/specs/2026-07-26-platform-design.md` §14) excluye explícitamente features de IA. Reabrirlas exige cambiar el spec primero, no solo escribir código:
|
||||
|
||||
| Feature (ítem original) | Motivo de la exclusión |
|
||||
|---|---|
|
||||
| LLM summaries (`--summarize`) | Requiere API key / modelo local; rompe el "100% local, sin auth" |
|
||||
| Q&A / RAG (`yt-scraper ask`) | Ídem; FTS5 cubre la parte recuperativa sin LLM |
|
||||
| NER / extracción de entidades | Dependencia pesada (spaCy) y caso de uso cubierto por search + top-words |
|
||||
| Speaker diarization | Complejidad alta (audio models) para ganancia marginal en canales monohablante |
|
||||
Reference in New Issue
Block a user