Files
yt-channel-scraper/CLAUDE.md
T
urieljareth 450aee216d 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
2026-09-10 00:32:32 -06:00

295 lines
37 KiB
Markdown

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Qué es esto
Plataforma **100% local** de minería de contenido de creadores de YouTube: `yt-dlp` → transcripciones + metadatos → SQLite (con FTS5) → notas Markdown estilo Obsidian, más una webapp FastAPI/Alpine que expone todo eso. Sin auth, sin deploy remoto, sin features de IA (regla de alcance explícita en `docs/superpowers/specs/2026-07-26-platform-design.md` §14).
## Comandos
```bash
pip install -e ".[dev,web,analysis]" # Python >= 3.10; ffmpeg es requisito del sistema para audio
python -m pytest tests/ -q # suite completa (~240 tests en 24 archivos, sin red)
uv run python -m pytest -q # equivalente si usas uv
python -m pytest tests/test_store_platform.py -v # un archivo
python -m pytest tests/test_store_platform.py::test_dashboard_aggregates -v # un test
python -m pytest -m "not integration" # marker declarado en pyproject (aún sin uso)
yt-scraper --help # CLI (equivale a: python -m yt_scraper.cli)
yt-scraper scrape --dry-run --limit 10 # discovery sin descargar
yt-scraper search "vaporwave" # FTS5 sobre transcripciones
yt-scraper re-render --backfill # reimportar .md a la DB y regenerar Markdown
start-server.bat # webapp: busca puerto libre 8000-8100, arranca uvicorn, abre browser
stop-server.bat # mata el proceso registrado en .run/server.info y libera el puerto
scripts/doctor.ps1 # (primera vez, lo llama start-server.bat) instala Python 3.12 + deps si faltan
make setup / make serve / make stop / make test / make clean # macOS/Linux vía Makefile
scripts/bootstrap.sh # macOS/Linux: prepara python/ffmpeg/node (brew) + venv + extras
scripts/start-server.sh / scripts/stop-server.sh # equivalentes bash de los .ps1
python -m uvicorn yt_scraper.webapp.app:app --port 8765 # arranque manual (debug)
```
No hay linter ni formatter configurado. Los `.bat` de la raíz solo envuelven `scripts/*.ps1` (`start-server.bat` pasa primero por `scripts/doctor.ps1`); en macOS/Linux el equivalente son `scripts/bootstrap.sh` + `scripts/start-server.sh` / `stop-server.sh`, expuestos en el `Makefile` raíz.
**Ojo:** `yt-scraper` sin subcomando ejecuta un scrape completo del canal de `config.yaml` (`invoke_without_command=True` en [cli.py:40](src/yt_scraper/cli.py#L40)).
## Orientación antes de leer código
Existe un grafo de conocimiento en [graphify-out/](graphify-out/) (gitignored, regenerable). Recomendado —no obligatorio, no es un hook del repo— consultarlo antes de leer/grepear fuentes: `graphify query "<pregunta>"`, `graphify explain "<concepto>"`, `graphify path "<A>" "<B>"`. Si el grafo está desactualizado respecto a los archivos, `graphify update`. [graphify-out/GRAPH_REPORT.md](graphify-out/GRAPH_REPORT.md) resume comunidades y god nodes (`Store` es el más conectado con diferencia).
## Arquitectura
### Tres superficies, un solo pipeline
`cli.py` (Click+Rich), `webapp/` (FastAPI+SSE) y `monitor.py` (watch loop) son **fachadas**; las tres convergen en `pipeline.process_video(row, cfg, store, ...)`, la unidad de trabajo por vídeo: extract → parse → align chapters → persistir en DB → renderizar `.md` → `mark_done`. Devuelve `"done" | "no_subtitles" | "error"` y nunca lanza por fallo de un vídeo.
Consecuencia práctica: una feature nueva se implementa en `pipeline`/`store` y **se expone dos veces** (subcomando en `cli.py` + endpoint en `webapp/api.py`). No dupliques lógica en la capa web.
```
discover.py yt-dlp --flat-playlist → VideoRef[] (+ avatar; deep_channel_avatar como fallback)
extract.py yt-dlp extract_info → info dict + pick_subtitle (prioridad json3 > srv1 > srv3 > vtt > ttml)
parse.py JSON3/VTT → Segment{start,end,text}, con merge_adjacent
chapters.py align_chapters: capítulos ↔ segmentos → Section[]
render.py Jinja2 → .md (filtros format_timestamp / quote_yaml / to_json)
store.py SQLite: todo el SQL a mano, filas como dataclasses
segments.py camino inverso: .md → DB (backfill)
```
### `Store` es la única capa de datos
SQLite sin ORM, `sqlite3.Row`, WAL, `foreign_keys=ON`. Cada método abre y cierra su conexión vía el contextmanager `_cursor()` (commit al salir), así que es seguro desde el worker thread de jobs.
**Migraciones:** `_init_schema()` corre en **cada** instanciación de `Store`. Es `SCHEMA` base + `ALTER TABLE ADD COLUMN` idempotente guiado por los dicts `_VIDEO_COLUMNS` / `_CHANNEL_COLUMNS` + `_EXTRA_SCHEMA`. Para añadir una columna: agrégala al dict, no escribas script de migración.
**FTS5:** `transcript_fts` es una tabla **standalone** (columnas duplicadas como `UNINDEXED`), no external-content — pese a lo que dice el spec. Por eso `store_segments()` borra e inserta en `transcript_segments` **y** en `transcript_fts`; si escribes segmentos por otra vía, replica ambas. Las queries de usuario pasan por `_sanitize_fts()` (tokens entre comillas unidos con `AND`).
**Doble persistencia de transcripciones**, intencional: filas en `transcript_segments` (búsqueda, análisis, reader) *y* `segments_json`/`chapters_json` en `videos` (permite `re-render` sin volver a descargar). `pipeline.process_video()` escribe las dos.
### El Markdown es fuente de datos, no solo salida
`segments.backfill_from_markdown()` parsea `data/markdown/**/*.md` de vuelta a la DB (idempotente, salta vídeos que ya tienen segmentos). Se invoca al arrancar la webapp (el reconcile de arranque corre en un hilo de fondo, [webapp/app.py:47-53](src/yt_scraper/webapp/app.py#L47)) y con `re-render --backfill`.
Eso convierte el formato del `.md` en un **contrato bidireccional**: [templates/video.md.j2](templates/video.md.j2) escribe `**MM:SS** · texto` y `### Título (MM:SS)`; los regex `_SEG_LINE` / `_CHAPTER` / `_FRONTMATTER_KEY` de [segments.py:17](src/yt_scraper/segments.py#L17) los leen. Si tocas la plantilla, actualiza los regex en el mismo cambio o el backfill se rompe en silencio.
### Sincronización incremental (no recorras el canal entero)
Re-escanear un canal ya trackeado **no** pagina todo el canal. `discover.discover_incremental()` lee la pestaña `/videos` (cronológica inversa) con `playlistend` y corta en cuanto ve `sync.overlap` vídeos consecutivos que ya están en la DB. Si toda la ventana resulta nueva, la duplica (`window` → `max_window`) en vez de perderse subidas. Medido sobre los 4 canales reales: **21.3 s / 1661 entradas → 3.3 s / 120 entradas**, mismos vídeos nuevos detectados.
Detalle que condiciona el diseño: en modo flat, yt-dlp **no** devuelve `upload_date` ni `timestamp` para los entries de YouTube (verificado). Por eso el corte real se hace por **solapamiento de IDs**, no por fecha; `since=` existe como condición secundaria por si el extractor sí las trae. La fecha del último vídeo se guarda como marca de agua en `channels.last_video_date` (poblada desde `videos.upload_date`, que sí se conoce tras la extracción) y es lo que se muestra en la UI y en el CLI.
- El `keep=` que se pasa a `discover_incremental` **debe** replicar el filtro shorts/live bajo el que se pobló la DB (`jobs._keep_ref`). Si no, la cola de la ventana se llena de entradas que nunca podrán ser "conocidas" y la ventana crece sin motivo.
- `upsert_videos` usa `COALESCE` en `upload_date`/`duration`: el discovery flat manda `None` y sin eso cada sync borraría las fechas aprendidas en la extracción — justo la marca de agua.
- `video_count` se recalcula con `store.mark_channel_synced()`, nunca con `len(refs)`: la ventana son 30 y el canal puede tener 861.
- Sin historial local (canal nuevo) se recorre el canal completo, que es lo correcto en la primera pasada.
- Escotilla de escape: `--full` en el CLI, `opts["full"]` en los jobs, botones **Full rescan** / **Full** y el checkbox *Full channel rescan* en la webapp. Config en `sync:` de `config.yaml`.
- `/tools/sync-channels` y `/tools/avatars` solo quieren metadatos+avatar del objeto canal, así que llaman a `discover_channel(..., limit=1)`: una petición en vez de paginar el canal.
### El orden de la lista es cronológico, y la fecha casi siempre es inferida
La sección de vídeos ordena por defecto como YouTube: subida más reciente primero, **haya o no `.md`**. El problema es que la mayoría de las filas no tienen fecha — medido sobre la biblioteca real, **4541 de 4959** — porque el discovery flat no la trae y solo aparece con la extracción.
`videos.channel_seq` es la posición del vídeo en la pestaña `/videos` (mayor = más nuevo) y es lo único que sitúa a un vídeo sin extraer. `store.SORT_DATE_SQL` deriva de ahí la fecha de orden, en cascada: (1) su `upload_date`; (2) la del vídeo con fecha **inmediatamente superior** en su canal — no es anterior a esa, y el rank desempata; (3) la fecha más nueva conocida en el canal, para la racha que está por encima de todo lo datado; (4) `NO_DATE_SENTINEL` (`"00000000"`) cuando el canal entero está sin extraer. El resultado se expone como `sort_date` + `date_estimated`, y la UI lo pinta con `~`. El sentinel no sale nunca al cliente: es un rank, no una fecha.
- **El fallback anterior era `discovered_at`**, que fechaba "hoy" todo lo no scrapeado y clavaba el backlog entero encima de los vídeos realmente recientes. Es la razón de ser de todo esto.
- El paso (3) también es deliberado: con `'99999999'` un canal sin un solo vídeo extraído (Víctor Pérez, 212 vídeos) se adueñaba de la página 1 sobre canales con fechas reales. Un vídeo sin evidencia no adelanta a uno con evidencia; dentro de su canal el rank lo sigue ordenando bien.
- `upsert_videos` asigna `channel_seq` **por encima del máximo actual del canal**, no desde cero: un sync solo ve la ventana más nueva, y todo lo que no trajo es más viejo por construcción. Así ambas mitades quedan ordenadas sin repaginar el canal, y sincronizar repetidamente no reordena nada.
- Los dos lookups de `SORT_DATE_SQL` corren una vez por fila sin fecha, así que van sobre **índices parciales** (`idx_videos_dated_seq`, `idx_videos_dated`) que solo indexan las filas datadas. Sin el `WHERE` parcial, buscar "el vídeo datado que tengo encima" recorre todas las filas intermedias mirando la tabla: 2500 filas dentro de un canal cuyos 8 vídeos datados están arriba del todo es cuadrático. Medido: **576 ms → 22 ms por página**. El `WHERE` de la query tiene que estar escrito igual que el del índice o SQLite lo descarta en silencio; hay un test sobre `EXPLAIN QUERY PLAN` que lo fija.
- El orden lleva desempate total (`channel_seq`, `video_id`). Sin él, `LIMIT/OFFSET` repite o se salta filas entre páginas.
- `_order_clause` acepta las dos ortografías de cada clave. La webapp mandaba `view_count`/`like_count`/`duration` y el store solo conocía `views_desc`/`duration_desc`: **todo** sort que no fuera fecha caía en un `upload_date DESC` crudo que ignoraba la inferencia.
- Bases anteriores a la columna se siembran una sola vez en `_seed_channel_seq()`, rankeando por `discovered_at DESC, rowid ASC` — el orden en que se aprendieron: un batch de discovery comparte timestamp y se inserta de nuevo a viejo, y un sync incremental solo añade ids más nuevos que todo lo guardado.
**Verificado contra la lista real de YouTube** (`discover_channel(limit=N)` sobre los 7 canales, 1 petición por ventana), no contra la intuición:
| Canal | Filas comparadas | Pares invertidos |
|---|---|---|
| Alex Hormozi | 28 | 0 / 378 |
| Benjamín Cordero | 29 | 0 / 406 |
| Fazt | 26 | 0 / 325 |
| Nostal Vlad | 30 | 0 / 435 |
| Platzi | 29 | 0 / 406 |
| Víctor Pérez | **212 (canal completo, 0 fechas reales)** | **0 / 22 366** |
| Código Espinoza | 30 | 9 / 435 → **0 tras un sync** |
| Código Espinoza (fondo) | 200 | 0 / 19 900 |
- Víctor Pérez es la prueba fuerte del seed: 212 vídeos **sin una sola fecha real**, orden derivado enteramente del rank reconstruido, y **cada fila en el índice exacto** de YouTube.
- El único desvío (Espinoza, 9 pares) venía de un seed erróneo en la ventana más nueva, no del mecanismo: los rangos ahí no se habían observado. **Un pase normal de discovery (1 petición, 30 entradas) lo dejó en coincidencia exacta**, porque `upsert_videos` re-rankea toda la ventana, no solo lo nuevo. Para el fondo de un canal hace falta un **Full rescan**.
- Ese caso también decide el diseño: con rank puro (`channel_seq DESC` solo) Espinoza salía **peor**. La fecha real va primero justo para que lo que costó una extracción corrija un rank reconstruido, y el rank solo desempata cuando la fecha —que es solo día, sin hora— no puede.
- Scripts de la medición: comparan `query_videos` contra `discover_channel` y cuentan pares invertidos. Repetible con ~1 petición por canal.
**Lo que la coincidencia exacta NO demuestra.** Una auditoría posterior sobre la base viva encontró que **`channel_seq` no contiene ni una sola posición observada de YouTube**: el 100 % es la reconstrucción de `_rank_unranked`, y los rangos son perfectamente contiguos por canal (`MIN=1`, `MAX=n`, sin huecos) — la firma del seed, no de un sync, que deja huecos al solapar ventanas. Que acierte es una propiedad del historial de discovery, no del diseño. Y **Nostal Vlad la rompe**: sus 3 vídeos más nuevos tienen `channel_seq` **1, 2, 3** — el fondo del canal — porque un lote posterior trajo vídeos más viejos, justo la premisa que el seed asume falsa. Se muestra bien **solo porque esos 3 tienen fecha real** y la fecha manda. Sin fecha se hundirían. Un discovery lo re-observa.
#### Tres bugs del orden que la prueba de campo no cubría
Los tres se encontraron auditando la base viva, no razonando:
- **`sort=oldest` abría con lo que no tiene fecha.** `NO_DATE_SENTINEL` (`"00000000"`) es el valor más bajo, así que lo que lo mantiene fuera de la portada bajo el orden por defecto es exactamente lo que lo ponía **primero** en ascendente: 212 vídeos de un canal sin extraer por delante de una subida real de 2017. `_OLDEST_FIRST` lleva ahora `(sort_date = '00000000')` como primer término. "No lo sabemos" no es "el principio de los tiempos".
- **El desempate global rankeaba por tamaño de catálogo.** `channel_seq` cuenta hasta el número de vídeos del canal, así que compararlo **entre** canales ordena por quién tiene más. Con el **92 %** de los pares adyacentes empatados en `sort_date`, eso decidía casi toda la lista: un canal entero delante de otro solo porque 575 > 476. El desempate es ahora `channel_id` **y luego** `channel_seq`, de modo que cada bloque empatado queda contiguo por canal y el orden interno —lo que tiene que cuadrar con YouTube— no se toca. Verificado: 373 bloques de empate, **0** con un canal partido.
- **Una fila sin rank no queda desordenada, queda mal colocada.** `COALESCE(channel_seq, -1)` hace que la regla 2 herede el vídeo datado **más viejo** del canal, así que un vídeo que discovery acaba de encontrar —de los más nuevos— se muestra el último. Medido: dos subidas nuevas de Hormozi con `sort_date=20180720`, penúltima y última de 513. Pasa cuando un server de larga vida sigue con el código previo a la columna después de migrar. `_rank_unranked()` corre **siempre**, no solo al añadir la columna, y es no-op si no hay NULLs.
**Ojo al importar `yt_scraper.webapp.app`:** tiene `app = create_app()` a nivel de módulo ([app.py:115](src/yt_scraper/webapp/app.py#L115)), así que **el simple `import` abre la base real del proyecto** vía `config.yaml`, corre la migración, `auto_import_dir` y lanza el reconcile en un hilo de fondo — aunque después le pases un `Config` distinto a `create_app()`. Para tocar solo una copia, importa `yt_scraper.store` / `yt_scraper.discover` directamente y nunca `webapp.app`.
### El botón de `.md` descarga `.md`
`_run_batch` ya no cachea miniaturas. Las traen los caminos de canal (alta de canal, herramienta *Download thumbnails*) y `/api/thumbnails/{id}` redirige al CDN lo que no esté en disco, así que colgarlas del batch gastaba una petición por vídeo por una imagen que la UI ya podía mostrar. Un vídeo cuyo `.md` ya existe en disco ahora se salta entero.
### Estados terminales y frescura (la UI tiene que reflejar DB + disco)
`no_subtitles` **no** significa "este vídeo no tiene subtítulos". Se escribe siempre que `data.segments` viene vacío ([pipeline.py:146](src/yt_scraper/pipeline.py#L146)), lo que mezcla tres causas muy distintas: el vídeo no tiene pistas, la política de idiomas rechazó las que sí tiene, o la descarga vino vacía por throttling. `extract.describe_missing_subtitle()` distingue los casos y el motivo se guarda en `videos.error_msg` vía `mark_status(vid, status, reason)`.
Incidente que motivó esto: 511 vídeos de un canal quedaron en `no_subtitles` porque `config.example.yaml` ponía los idiomas en modo `manual` y el canal solo publica subtítulos automáticos. `_sources_for("manual")` no hace fallback. El default es ahora `any` (manual primero, auto después) — `manual` es opt-in explícito.
- **Ambos estados son reintentables.** `Store.RETRYABLE_STATUSES` = `("error", "no_subtitles")`. `reset_videos()` los devuelve a `pending`; `reset_errors()` conserva su significado estrecho. `done` nunca se toca. Expuesto en `POST /api/videos/reset`, `GET /api/videos-retryable` y `yt-scraper reset`.
- **Contenido bloqueado se detecta en el discovery, no al fallar.** Los entries planos de yt-dlp traen `availability`; `subscriber_only` = vídeo de membresía. Medido: 120 entradas en una petición, 4 marcadas, exactamente las mismas que la DB había aprendido a base de extracciones fallidas. Se guarda en `videos.availability` y `VideoRow.block_reason` lo traduce, con fallback al `error_msg` para las filas antiguas. `get_pending()` las excluye (los `_run_batch` por ID explícito sí las intentan, que es lo que hace recuperable el caso "compré la membresía"). Filtro `blocked` en `query_videos` / `GET /api/videos?blocked=true`, badge `.st-locked` en la UI.
- El enum completo es `private | premium_only | subscriber_only | needs_auth | unlisted | public` ([yt_dlp/extractor/common.py:414](https://github.com/yt-dlp/yt-dlp)). Solo los cuatro primeros bloquean: **`unlisted` se descarga sin problema** y marcarlo escondería vídeos que sí se pueden tener. En el SQL del filtro, `COALESCE` es imprescindible: con `availability` NULL, `NOT (NULL OR ...)` es NULL y la rama negada devolvería cero filas.
- **Salvo los permanentes.** `Store.PERMANENT_ERROR_PATTERNS` (members-only, private, removed) se excluyen del reset: reintentarlos no los va a desbloquear y gasta peticiones que necesitan los que sí pueden salir. `retryable_counts()` los devuelve aparte en la clave `permanent`. Mantén la lista estrecha: el mensaje de throttling de YouTube ("rate-limited … try again later") **sí** es reintentable y no debe caer ahí.
- **`backfill_from_markdown` no arregla estados**: puebla segmentos y metadatos, pero nunca `status` ni `markdown_path`. Para eso está `segments.reconcile_markdown()`, que corre al arrancar la webapp, en `POST /api/tools/reconcile` y en `yt-scraper reconcile`.
- `reconcile` es aditivo por defecto. `prune=True` es opt-in porque es destructivo: borra duplicados obsoletos y degrada `done` cuyo `.md` desapareció. Se niega a degradar nada si el árbol de markdown está vacío (root mal configurado).
- **La identidad de una nota es el `video_id`, nunca el nombre del fichero.** El nombre sale de `{upload_date}_{slug}` y las dos partes son inestables: YouTube sirve los títulos localizados (el mismo vídeo volvió como "La controversia de Claude Fable 5" en una pasada y "The Claude Fable controversy 5" en la siguiente) y los creadores renombran. Cada cambio de stem escribía un fichero nuevo y dejaba el anterior huérfano.
- Las dos rutas que renderizan pasan ahora por `pipeline._render_and_retire()`, que borra el fichero al que apuntaba `markdown_path` si el stem cambió. Existe **porque ya divergieron una vez**: `re_render_videos` usaba la fecha cruda (`20240519_`) y `process_video` la normalizada (`2024-05-19_`) → 94 `.md` para 61 filas. Medido tras arreglarlo: 33 duplicados reales en disco, todos con canónico existente, mezclando las dos causas (`20240519_why-did-the-2000s-look-like-this` contra `2024-05-19_por-que-los-2000-se-veian-asi`). Cero notas únicas perdidas.
- `build_filename_stem` acepta `{video_id}` en `filename_template` para quien quiera que el fichero se identifique solo, sin la DB. Es opt-in: el default no cambia, para no renombrar bibliotecas existentes.
- `mark_done` sigue siendo el **único** escritor de `markdown_path`.
- Al leer `.md`, captura `UnicodeDecodeError` además de `OSError`: es un `ValueError`, y dejarlo escapar abortaba el escaneo entero saltándose todos los ficheros posteriores.
En el frontend, `refreshLiveState()` es el único punto de invalidación: lo llaman el handler `done` del SSE, un poll de 5 s mientras hay job activo, y `visibilitychange`/`focus`. `setView` recarga **siempre**, no solo cuando la lista está vacía.
### Rate limiting: `ratelimit.py` es la política, y no es opcional
Tres piezas distintas, no intercambiables:
- **`Pacer` (`GLOBAL_PACER`)** — separación mínima entre peticiones, **global al proceso**. Existe porque el `JobManager` serializa *jobs* pero los `/api/tools/*` corren fuera de él, en el threadpool: sin un pacer compartido, dos consumidores machacan YouTube creyendo cada uno que va educado. `wait(cost=N)` reserva N ranuras — `extract_info` de un vídeo son **2** peticiones (watch + player) y cobrarlo como 1 hacía que el pacer contase la mitad.
- **`backoff_delay`** — `min(base * 2**n + jitter, cap)`, el algoritmo que Google documenta para sus propias APIs, con el jitter re-sorteado en cada intento.
- **`ThrottleGuard`** — el circuit breaker. Cuenta rate-limits **consecutivos**; al llegar a `throttle_threshold` (3) el job para. Lo que no tocó sigue en `pending`, que es el estado recuperable.
**El incidente que lo motiva:** 343 de los 350 `error` de la DB decían literalmente *"The current session has been rate-limited by YouTube for up to an hour"*. No es que los vídeos fallaran: el scraper se ganaba un ban de una hora y luego **quemaba el resto de la cola en cascada** marcándolos como fallidos. Sin breaker, un throttling se convierte en cientos de filas envenenadas.
- Un fallo **no** de throttling (vídeo privado, sin subtítulos) **resetea** el contador. Si no, tres vídeos de membresía seguidos abortarían un scrape sano.
- `is_rate_limited` e `is_quota_exhausted` son cosas distintas: Google documenta la cuota como diaria (AIP-194), así que reintentar no la arregla y el guard corta en seco sin backoff.
- Los mensajes reales de la DB están fijados verbatim en [tests/test_ratelimit.py](tests/test_ratelimit.py). Si el detector deja de reconocerlos, el breaker es decorativo.
- **No amplíes `Store.PERMANENT_ERROR_PATTERNS`** con el mensaje de throttling: tiene que seguir siendo reintentable.
#### Los nombres de las opciones de yt-dlp se validan, no se revisan
Durante toda la historia del proyecto, los cuatro puntos de red pasaron `sleep_subrequests` a yt-dlp. **Esa opción no existe.** yt-dlp ignora en silencio las claves que no conoce, así que nunca hubo ni un milisegundo de pausa entre las sub-peticiones de una extracción, mientras `config.yaml` aparentaba tenerlo configurado. Lo mismo con `extract_flat_args`.
El nombre real es **`sleep_interval_requests`**, y es la **única** palanca que afecta a la extracción: `sleep_interval` / `max_sleep_interval` se disparan en el downloader de ficheros y **nunca** actúan bajo `skip_download`, que es todo el camino de metadatos.
Todo `ydl_opts` de politeness sale ahora de `ratelimit.ydl_throttle_opts()`, y [tests/test_request_economy.py](tests/test_request_economy.py) valida cada clave contra `yt_dlp.parse_options([]).ydl_opts` (172 nombres válidos). Si inventas una opción, el test falla.
**yt-dlp no reintenta 403/429 en YouTube**: su extractor los excluye explícitamente del `RetryManager`. `retries=10` no te protege de nada; el backoff ante throttling es responsabilidad nuestra.
#### Economía de peticiones: lo medido, para no re-litigarlo
| Operación | Peticiones | Nota |
|---|---|---|
| Extracción de un vídeo | **2** | watch + `youtubei/v1/player`. Es el suelo. |
| Transcripción (`timedtext`) | 1 | vía `yt_get` |
| Miniatura | 1 | `i.ytimg.com`, CDN estático, no cuenta contra el throttle de la API |
| `discover_channel(limit=30)` | **1** | |
| Discovery completo de canal de 2564 vídeos | ~86 | ~30 entradas por petición |
| `deep_channel_avatar` | **3-4** | antes **735 y subiendo** cuando la medición lo abortó |
- **`deep_channel_avatar` extraía el canal entero para leer una URL de imagen.** yt-dlp redirige una URL de canal pelada a `/videos`, recorre además `/streams` y `/shorts`, y `download=False` solo evita bajar el media, no la extracción. `extract_flat` + `playlistend: 1` son **carga estructural, no tuning**.
- **Restringir `player_client` no ahorra nada y rompe cosas.** Medido: `web_safari` solo → 1 petición pero **0 pistas de subtítulos**. `player_skip=webpage` → pierde subtítulos *y* `duration`/`channel_id`/`tags`. El default de yt-dlp (`android_vr` + `web_safari`, 2 peticiones) ya es el óptimo.
- **`playlistend` se ignora en silencio con `process=False`.** Medido sobre 2564 vídeos: procesado + `playlistend=60` = **2** peticiones; sin procesar, el generador perezoso ignora el límite y recorrerlo cuesta **86**. Hay un test que fija `process is not False`.
- `check_formats` cuesta **una petición HTTP por formato**. Nunca lo actives.
- No desactives `cachedir`: yt-dlp cachea ahí el player JS resuelto y quitarlo añade peticiones.
- `POST /api/tools/thumbnails` con solo `channel_id` acota a `THUMBNAIL_AUTO_LIMIT` (60). Antes, añadir un canal disparaba una petición al CDN **por cada vídeo del catálogo** — 2564 de golpe en Platzi.
#### La transcripción tiene que ser el idioma que se habla, no una traducción
`languages` es una preferencia **entre idiomas que sabes leer**, no una orden de aceptar una traducción automática cuando el transcript real está ahí. `pick_subtitle` pone delante el idioma hablado si está entre los configurados; solo si no lo está manda el orden del dict.
Cómo se detecta el original: YouTube publica el ASR como **`<lang>-orig`** y luego una cola larga de traducciones con el código pelado — incluida **una traducción al propio idioma del vídeo**. En un vídeo español existen `es-orig` *y* `es`, y solo el primero es el transcript real. `_is_original_track()` mira ese sufijo, y `original_language()` lo prefiere sobre `info["language"]` porque el sufijo es evidencia de la lista de pistas mientras que `language` es metadato que YouTube localiza.
**El fallo:** `_normalize_lang("es-orig") == "es"`, así que el sufijo — lo único que distingue el ASR real de una traducción — se tiraba antes de comparar. Con `{es, es-419, en}` sobre el canal de Alex Hormozi (inglés), el picker enganchaba `es` y guardaba una traducción máquina del inglés hablado. Prueba: las URLs de `timedtext` llevaban `lang=en&kind=asr&variant=gemini&tlang=es` — **`tlang=` es la marca de traducción**. Los canales en español acertaban **por casualidad**: yt-dlp lista `es-orig` antes que `es`. 3 filas de 119 afectadas; las otras 116 son `es-orig` legítimo.
- `extractor_args: {youtube: {skip: ["translated_subs"]}}` **no arregla esto**. El gate está anidado dentro de `if is_manual_subs` (`_video.py:4284-4285`) y `is_manual_subs` es False para pistas ASR. Medido: 158 claves con el flag y sin él, idénticas.
- `subtitleslangs` **no influye** en la selección: `pick_subtitle` lee `info["automatic_captions"]` crudo, mientras yt-dlp confina su propia selección a `requested_subtitles`, clave que este repo nunca lee.
- Recuperar una fila así **no se puede por las vías normales**: `reset_videos` nunca toca `done`, y `re-render` regeneraría el idioma equivocado desde `segments_json`. Hay que limpiar `transcript_segments`, `transcript_fts`, `segments_json` **y borrar el `.md`** — si lo dejas, `reconcile_markdown()` lo re-marca `done` al arrancar.
**Pendiente, sin resolver:** los títulos también vienen localizados. El canal Platzi tiene títulos en inglés en la DB ("The Claude Fable controversy 5" por "La controversia de Claude Fable 5") mientras sus `.md` se llamaron en español. No está determinado cuál de los dos lados —el listado flat del tab o la extracción— es el localizado; hace falta una medición contra la red para saberlo.
#### El detector de throttling no puede depender de la prosa
Hay dos ortografías y **son cadenas distintas**: yt-dlp lanza `HTTP Error 429: ...` y `requests` lanza `429 Client Error: ... for url: ...`. `_HTTP_STATUS` solo cubría la primera, así que la segunda se detectaba únicamente por el substring `"too many requests"` — y un 429 servido **sin reason phrase**, que es lo normal en HTTP/2, no lleva esa prosa y atravesaba el breaker sin contarse. Igual con 408, que Google documenta como reintentable.
Ahora `_download_subtitle` normaliza el mensaje anteponiendo `HTTP Error <status>:` desde `exc.response.status_code`, y el detector reconoce ambas formas. Está cubierto por tests parametrizados con las dos ortografías y con los casos que **no** deben disparar.
#### Los eventos de error usan `message`; los de log usan `msg`
`jobs.py` emite `{"message": ...}` en los cinco sitios donde manda un evento `error`. El frontend leía `d.msg`, que no existe en esos eventos, así que **todo** fallo de job salía como `"Job failed: connection error"` — incluida la explicación detallada del breaker. Corregido en `app.js` leyendo `d.message || d.msg`, y el flag `throttled` cambia el texto: parar por rate limiting no es un crash, es una parada deliberada con todo lo no alcanzado aún en `pending`.
#### Los metadatos se persisten antes de la salida por "sin transcripción"
`process_video` guarda `view_count`/`description`/`thumbnail`/`tags`/`upload_date` **encima** del `return "no_subtitles"`. La extracción ya pagó sus dos peticiones y el `info` está en memoria; tirarlo porque falló la descarga *separada* de subtítulos hace que el reintento las vuelva a gastar para obtener datos que ya teníamos. Medido tras el incidente: cinco filas quedaron con `upload_date`, `view_count`, `description` y `thumbnail` todos NULL.
Relacionado: `mark_status` trunca el motivo a **2000** caracteres, no 500. A 500 el corte caía veinte caracteres antes del `tlang=` que probaba el diagnóstico.
#### Los handlers que hacen I/O de red van en `def`, no en `async def`
FastAPI ejecuta los `async def` **en el event loop**; los `def` van al threadpool. `add_channel`, `sync_channels`, `download_thumbnails`, `download_avatars` y `reconcile` bloquean con yt-dlp o `requests`, así que siendo `async` congelaban el servidor entero — incluido el SSE del job en curso — durante toda su duración. Verificado en producción: añadir Platzi bloquea 295 s, y con el cambio `/api/dashboard` respondió 146/146 sondas con mediana de 0,000 s.
`stream_job` **sí** debe seguir siendo `async`: devuelve el `EventSourceResponse`.
#### Calibración
El único techo publicado es el de la wiki de yt-dlp: **~300 vídeos/hora (~1000 peticiones/hora)** en sesión sin cuenta. Google no documenta límites para acceso sin API, y los umbrales del bot-check no están documentados en ninguna parte: cualquier cifra concreta es prudencia, no norma.
`config.yaml` apunta por debajo de eso. Medido en producción sobre Platzi: **13,25 s/vídeo → ~272 vídeos/hora, ~815 peticiones/hora**. Si cambias los tiempos, vuelve a medir: 3 peticiones a `youtube.com` por vídeo es la constante de la que sale todo lo demás.
### Cookies
Vault híbrido: metadatos en `cookies_meta`, archivos Netscape en `cookies/<uuid>.txt` (gitignored). **Exactamente una** cookie activa a la vez. Cuando no se pasa `--cookies`, CLI, webapp y monitor caen en `cookies.resolve_active_path(store)`. `auto_import_dir()` adopta `.txt` sueltos al arrancar CLI y webapp.
### Job runner de la webapp
`JobManager` = un único thread daemon + `deque`, deliberadamente secuencial para no martillear a YouTube. Los eventos van a una lista en memoria por job (`_events`) que el endpoint SSE **poletea** con un cursor cada 250 ms; no hay pub/sub real, y los eventos se pierden al reiniciar (el estado durable está en `scrape_jobs`).
Despacho por `opts` en `_run_job()`: `mode == "audio"` → `_run_audio`; hay `video_ids` → `_run_batch` (por vídeo: `.md` + thumbnail, saltando los que ya tienen `.md` en disco); si no → `_run_channel` (discovery + pendientes + `polite_sleep` entre vídeos). La cancelación es cooperativa: `_cancel` es un set que los loops consultan.
### Frontend
Sin build step, por decisión fija ([.opencode/agent/webapp-builder.md](.opencode/agent/webapp-builder.md)): Tailwind, Alpine 3 y Chart.js por CDN. Todo el estado vive en **un solo** componente Alpine, `window.platform()` en [static/app.js](src/yt_scraper/webapp/static/app.js), y **debe** quedar definido antes de que Alpine inicialice — el orden de los `<script defer>` en `index.html` (app.js antes de alpinejs) es carga funcional, no estilo. Las vistas se sincronizan con la URL (`hydrateURL`/`syncURL`), no con hash routes. Identidad visual: dark "command center", `#0a0a0f` + acento rose `#f43f5e`.
### Rutas de datos y convenciones
El data root se deriva siempre como `Path(cfg.output_dir_resolved).parent` → `data/{markdown,audio,thumbnails,avatars,exports,analysis}` y `data/state.db`. `videos.markdown_path` se guarda **relativo a ese root**.
Gotcha real: el job de audio de la webapp guarda `data/audio/<video_id>.mp3` (`outtmpl` con `%(id)s`) y `GET /api/videos/{id}/audio` solo encuentra ese nombre, mientras que `yt-scraper audio` (CLI) escribe por título. Los MP3 bajados por CLI no los sirve la API.
### Cualquier petición HTTP a CDNs de YouTube va por `_yt_http.yt_get()`
Headers compartidos (UA de Chrome + `Referer: https://www.youtube.com/`). Sin eso, `yt3.ggpht.com` (avatares) y `timedtext` (subtítulos) devuelven 403. `yt-dlp` es la **única** interfaz con YouTube: no añadas llamadas directas a InnerTube.
### Idiomas: dict por-idioma con compatibilidad legacy
`languages` puede ser lista (`["es","en"]`, todos usan `prefer_manual`) o dict `{lang: "manual"|"auto"|"any"}`. La normalización está duplicada a propósito: `config.parse_languages()` y `extract._coerce_languages()` (para que `extract` no dependa de `config`). Si cambias las reglas, cambia las dos.
Estados de vídeo: `pending` / `done` / `no_subtitles` / `error`. Estados terminales de job: `Store.TERMINAL_STATUSES`.
## Tests
Solo `tmp_path` + `monkeypatch`, cero red: `yt_dlp.YoutubeDL` se sustituye por un fake (ver [tests/test_webapp_jobs.py](tests/test_webapp_jobs.py)) y `pick_subtitle` se testea con dicts `info` sintéticos. Los tests del store construyen un `Store` sobre `tmp_path`, así que también cubren la migración idempotente.
## Documentos de referencia
- [AGENTS.md](AGENTS.md) — guía de entrada para cualquier agente de código (pitch, mapa de módulos, reglas de oro, punteros a docs).
- [docs/superpowers/specs/2026-07-26-platform-design.md](docs/superpowers/specs/2026-07-26-platform-design.md) — diseño de referencia (esquema, endpoints, alcance, exclusiones). Es la fuente de verdad de las decisiones; donde el código difiere, gana el código.
- [docs/backlog.md](docs/backlog.md) — backlog destilado: lo pendiente y qué ya está shipped (sustituye al histórico OPPORTUNITIES.md).
- [docs/audits/](docs/audits/) — auditorías: ingeniería inversa del mecanismo InnerTube del Obsidian Web Clipper (`YOUTUBE-TRANSCRIPT-AUDIT.md`, contexto de *por qué* `yt-dlp` es la ruta elegida) y comparativa extensión vs scraper (`CLIPPER-COMPARISON-AUDIT.md`).
- `docs/GETTING-STARTED.md`, `docs/CONFIG.md`, `docs/COOKIES.md` — docs de usuario (máquina nueva, referencia de config, guía de cookies).
- `data/`, `cookies/`, `.run/` están gitignored y contienen datos reales (sesión de YouTube). No los commitees ni pegues su contenido en respuestas. `config.yaml` ya no está versionado (config privada del usuario; la plantilla versionada es `config.example.yaml`).
## Actualización de docs
Checklist rápida al aterrizar un cambio — las docs desactualizadas mienten peor que no existir:
- **Flags/subcomandos del CLI** nuevos o cambiados → `README.md` (sección CLI) y ejemplos de `docs/GETTING-STARTED.md`.
- **Plantilla `templates/video.md.j2`** → este archivo (el contrato bidireccional con `segments.py`) y el ejemplo de nota del `README.md`.
- **Endpoints de la webapp o UI** → `README.md` (sección webapp) y las secciones *Job runner* / *Frontend* de aquí.
- **Clave de config nueva o default distinto** → `config.example.yaml`, `docs/CONFIG.md` y el resumen de bloques del `README.md`.
- **Números que caducan** (cantidad de tests, refs `archivo.py:NN`) → usa órdenes de magnitud (`~240 tests`) y re-verifica las refs de línea antes de citarlas.
- **Feature que estaba en el backlog** → márcala como shipped en `docs/backlog.md`; si añade un módulo, actualiza el mapa de `AGENTS.md`.