- 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
37 KiB
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
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).
Orientación antes de leer código
Existe un grafo de conocimiento en 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 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) y con re-render --backfill.
Eso convierte el formato del .md en un contrato bidireccional: templates/video.md.j2 escribe **MM:SS** · texto y ### Título (MM:SS); los regex _SEG_LINE / _CHAPTER / _FRONTMATTER_KEY de segments.py:17 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 adiscover_incrementaldebe 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_videosusaCOALESCEenupload_date/duration: el discovery flat mandaNoney sin eso cada sync borraría las fechas aprendidas en la extracción — justo la marca de agua.video_countse recalcula constore.mark_channel_synced(), nunca conlen(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:
--fullen el CLI,opts["full"]en los jobs, botones Full rescan / Full y el checkbox Full channel rescan en la webapp. Config ensync:deconfig.yaml. /tools/sync-channelsy/tools/avatarssolo quieren metadatos+avatar del objeto canal, así que llaman adiscover_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_videosasignachannel_seqpor 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_SQLcorren 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 elWHEREparcial, 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. ElWHEREde la query tiene que estar escrito igual que el del índice o SQLite lo descarta en silencio; hay un test sobreEXPLAIN QUERY PLANque lo fija. - El orden lleva desempate total (
channel_seq,video_id). Sin él,LIMIT/OFFSETrepite o se salta filas entre páginas. _order_clauseacepta las dos ortografías de cada clave. La webapp mandabaview_count/like_count/durationy el store solo conocíaviews_desc/duration_desc: todo sort que no fuera fecha caía en unupload_date DESCcrudo que ignoraba la inferencia.- Bases anteriores a la columna se siembran una sola vez en
_seed_channel_seq(), rankeando pordiscovered_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_videosre-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 DESCsolo) 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_videoscontradiscover_channely 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=oldestabrí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_FIRSTlleva 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_seqcuenta 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 ensort_date, eso decidía casi toda la lista: un canal entero delante de otro solo porque 575 > 476. El desempate es ahorachannel_idy luegochannel_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 consort_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), 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), 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 apending;reset_errors()conserva su significado estrecho.donenunca se toca. Expuesto enPOST /api/videos/reset,GET /api/videos-retryableyyt-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 envideos.availabilityyVideoRow.block_reasonlo traduce, con fallback alerror_msgpara las filas antiguas.get_pending()las excluye (los_run_batchpor ID explícito sí las intentan, que es lo que hace recuperable el caso "compré la membresía"). Filtroblockedenquery_videos/GET /api/videos?blocked=true, badge.st-lockeden la UI. - El enum completo es
private | premium_only | subscriber_only | needs_auth | unlisted | public(yt_dlp/extractor/common.py:414). Solo los cuatro primeros bloquean:unlistedse descarga sin problema y marcarlo escondería vídeos que sí se pueden tener. En el SQL del filtro,COALESCEes imprescindible: conavailabilityNULL,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 clavepermanent. 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_markdownno arregla estados: puebla segmentos y metadatos, pero nuncastatusnimarkdown_path. Para eso estásegments.reconcile_markdown(), que corre al arrancar la webapp, enPOST /api/tools/reconciley enyt-scraper reconcile.reconcilees aditivo por defecto.prune=Truees opt-in porque es destructivo: borra duplicados obsoletos y degradadonecuyo.mddesapareció. 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 apuntabamarkdown_pathsi el stem cambió. Existe porque ya divergieron una vez:re_render_videosusaba la fecha cruda (20240519_) yprocess_videola normalizada (2024-05-19_) → 94.mdpara 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-thiscontra2024-05-19_por-que-los-2000-se-veian-asi). Cero notas únicas perdidas. build_filename_stemacepta{video_id}enfilename_templatepara quien quiera que el fichero se identifique solo, sin la DB. Es opt-in: el default no cambia, para no renombrar bibliotecas existentes.mark_donesigue siendo el único escritor demarkdown_path.
- Las dos rutas que renderizan pasan ahora por
- Al leer
.md, capturaUnicodeDecodeErrorademás deOSError: es unValueError, 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 elJobManagerserializa 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_infode 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 athrottle_threshold(3) el job para. Lo que no tocó sigue enpending, 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_limitedeis_quota_exhaustedson 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. Si el detector deja de reconocerlos, el breaker es decorativo.
- No amplíes
Store.PERMANENT_ERROR_PATTERNScon 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 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_avatarextraía el canal entero para leer una URL de imagen. yt-dlp redirige una URL de canal pelada a/videos, recorre además/streamsy/shorts, ydownload=Falsesolo evita bajar el media, no la extracción.extract_flat+playlistend: 1son carga estructural, no tuning.- Restringir
player_clientno ahorra nada y rompe cosas. Medido:web_safarisolo → 1 petición pero 0 pistas de subtítulos.player_skip=webpage→ pierde subtítulos yduration/channel_id/tags. El default de yt-dlp (android_vr+web_safari, 2 peticiones) ya es el óptimo. playlistendse ignora en silencio conprocess=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 fijaprocess is not False.check_formatscuesta 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/thumbnailscon solochannel_idacota aTHUMBNAIL_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 deif is_manual_subs(_video.py:4284-4285) yis_manual_subses False para pistas ASR. Medido: 158 claves con el flag y sin él, idénticas.subtitleslangsno influye en la selección:pick_subtitleleeinfo["automatic_captions"]crudo, mientras yt-dlp confina su propia selección arequested_subtitles, clave que este repo nunca lee.- Recuperar una fila así no se puede por las vías normales:
reset_videosnunca tocadone, yre-renderregeneraría el idioma equivocado desdesegments_json. Hay que limpiartranscript_segments,transcript_fts,segments_jsony borrar el.md— si lo dejas,reconcile_markdown()lo re-marcadoneal 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): Tailwind, Alpine 3 y Chart.js por CDN. Todo el estado vive en un solo componente Alpine, window.platform() en 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) 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 — 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 — 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 — backlog destilado: lo pendiente y qué ya está shipped (sustituye al histórico OPPORTUNITIES.md).
- docs/audits/ — auditorías: ingeniería inversa del mecanismo InnerTube del Obsidian Web Clipper (
YOUTUBE-TRANSCRIPT-AUDIT.md, contexto de por quéyt-dlpes 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.yamlya no está versionado (config privada del usuario; la plantilla versionada esconfig.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 dedocs/GETTING-STARTED.md. - Plantilla
templates/video.md.j2→ este archivo (el contrato bidireccional consegments.py) y el ejemplo de nota delREADME.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.mdy el resumen de bloques delREADME.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 deAGENTS.md.