diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2d7cff5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# AGENTS.md + +Guía de entrada para cualquier agente de código (Claude Code, Cursor, opencode, ZCode, ...) que vaya a trabajar en este repo. Léela antes de tocar nada. + +## Qué es (en 5 líneas) + +Plataforma 100% local de minería de contenido de creadores de YouTube. `yt-dlp` extrae metadatos + transcripciones de canales completos; todo se guarda en SQLite (con FTS5) y se renderiza a notas Markdown estilo Obsidian. Dos superficies: CLI `yt-scraper` (Click) y webapp FastAPI con SPA vanilla (Alpine). 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). + +Arquitectura profunda, decisiones y trampas: [CLAUDE.md](CLAUDE.md) — es la referencia principal; no la dupliques aquí. + +## Mapa de módulos (`src/yt_scraper/`) + +| Módulo | Responsabilidad | +|---|---| +| `cli.py` | CLI Click (`yt-scraper`); fachada del pipeline. Sin subcomando = scrape completo | +| `config.py` | Dataclasses `Config`/`DelayConfig`/`YtDlpConfig`/`SyncConfig` + `load_config` | +| `discover.py` | yt-dlp `--flat-playlist` → `VideoRef[]`; `discover_incremental` (ventana + overlap) | +| `extract.py` | `extract_info` → metadatos + subtítulos; `pick_subtitle` (json3 > srv1 > vtt) | +| `parse.py` | JSON3/VTT → `Segment{start, end, text}` | +| `chapters.py` | `align_chapters`: capítulos ↔ segmentos → secciones | +| `pipeline.py` | `process_video()`: la unidad de trabajo por vídeo (extract→parse→store→render) | +| `store.py` | Única capa de datos: SQLite a mano (WAL, FTS5, migraciones idempotentes) | +| `segments.py` | Camino inverso `.md` → DB (`backfill_from_markdown`, `reconcile_markdown`) | +| `render.py` | Jinja2 → Markdown con frontmatter YAML (`templates/video.md.j2`) | +| `ratelimit.py` | Políticas de red: `Pacer` global, backoff exponencial, `ThrottleGuard` | +| `cookies.py` | Vault de cookies Netscape (import, activación, caducidad) | +| `monitor.py` | Watch loop (discovery periódico) | +| `export.py` | Export json/csv/srt/html | +| `analysis.py` | Top words, wordcloud, timeline | +| `_yt_http.py` | `yt_get()`: toda petición HTTP directa a CDNs de YouTube (UA + Referer) | +| `webapp/` | FastAPI: `app.py` (`create_app`), `api.py` (routers), `jobs.py` (`JobManager`), `static/` (SPA Alpine) | + +## Comandos esenciales + +```bash +uv sync --all-extras # o: pip install -e ".[dev,web,analysis]" +uv run python -m pytest -q # suite hermética (~240 tests, 24 archivos, sin red) +python -m uvicorn yt_scraper.webapp.app:app --port 8000 # webapp manual +yt-scraper --help # CLI; ej: yt-scraper scrape --dry-run --limit 10 +make serve # macOS/Linux; Windows: start-server.bat / stop-server.bat +``` + +Todo se ejecuta **desde la raíz del repo**: las rutas de `config.yaml` se resuelven relativas al CWD. + +## Reglas de oro + +1. **No toques `data/` ni `cookies/`**: contienen la biblioteca real y sesiones de YouTube (equivalente a contraseñas). Están gitignored; nunca las commitees ni pegues su contenido. +2. **Tests herméticos**: solo `tmp_path` + `monkeypatch`, cero red. `yt_dlp.YoutubeDL` se fakea (ver `tests/test_webapp_jobs.py`). +3. **Handlers con I/O de red en la webapp: `def`, no `async def`** (FastAPI manda los `async def` al event loop y congelan el servidor; los `def` van al threadpool). Excepción: `stream_job`, que devuelve el SSE. +4. **Mantén CLAUDE.md al día** cuando cambies decisiones de arquitectura o contratos (plantilla ↔ regex, endpoints, flags). Hay checklist al final de ese archivo. +5. **LF, no CRLF**: `.gitattributes` fuerza LF para código y docs (`*.bat`/`*.ps1` salen en CRLF). No lo deshagas; no añades `^M`. +6. Una feature se implementa en `pipeline`/`store` y se expone dos veces (subcomando CLI + endpoint webapp). No dupliques lógica en la capa web. +7. `yt-dlp` es la única interfaz con YouTube; el resto de peticiones HTTP van por `_yt_http.yt_get()`. + +## Punteros + +- [CLAUDE.md](CLAUDE.md) — arquitectura profunda, invariantes, incidentes y porqués. +- [docs/GETTING-STARTED.md](docs/GETTING-STARTED.md) — máquina nueva desde cero por OS. +- [docs/CONFIG.md](docs/CONFIG.md) — referencia completa de `config.yaml`. +- [docs/COOKIES.md](docs/COOKIES.md) — guía de cookies (export, import, rotación, seguridad). +- [docs/superpowers/specs/](docs/superpowers/specs/) — specs de diseño históricos (fuente de verdad de decisiones; donde el código difiere, gana el código). diff --git a/CLAUDE.md b/CLAUDE.md index bfe42f3..c4b0fb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,8 @@ Plataforma **100% local** de minería de contenido de creadores de YouTube: `yt- ```bash pip install -e ".[dev,web,analysis]" # Python >= 3.10; ffmpeg es requisito del sistema para audio -python -m pytest tests/ -q # suite completa (61 tests, sin red) +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) @@ -23,16 +24,20 @@ yt-scraper re-render --backfill # reimportar .md a la DB y regenerar Ma 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/start-server.ps1` / `scripts/stop-server.ps1`. +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:39](src/yt_scraper/cli.py#L39)). +**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/) y un hook que exige usarlo antes de leer/grepear fuentes: `graphify query ""`, `graphify explain ""`, `graphify path "" ""`. 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). +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 ""`, `graphify explain ""`, `graphify path "" ""`. 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 @@ -64,7 +69,7 @@ SQLite sin ORM, `sqlite3.Row`, WAL, `foreign_keys=ON`. Cada método abre y cierr ### 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 ([webapp/app.py:31](src/yt_scraper/webapp/app.py#L31)) y con `re-render --backfill`. +`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. @@ -123,7 +128,7 @@ Los tres se encontraron auditando la base viva, no razonando: - **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:102](src/yt_scraper/webapp/app.py#L102)), así que **el simple `import` abre la base real del proyecto** vía `config.yaml`, corre la migración, `auto_import_dir` y `reconcile_markdown` — 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`. +**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` @@ -131,7 +136,7 @@ Los tres se encontraron auditando la base viva, no razonando: ### 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:52](src/yt_scraper/pipeline.py#L52)), 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)`. +`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. @@ -270,7 +275,20 @@ Solo `tmp_path` + `monkeypatch`, cero red: `yt_dlp.YoutubeDL` se sustituye por u ## 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. -- [OPPORTUNITIES.md](OPPORTUNITIES.md) — backlog de features con esfuerzo estimado. -- [YOUTUBE-TRANSCRIPT-AUDIT.md](YOUTUBE-TRANSCRIPT-AUDIT.md) — ingeniería inversa de Obsidian Web Clipper (mecanismo InnerTube). Contexto de *por qué* `yt-dlp` es la ruta elegida. -- `data/`, `cookies/`, `.run/` están gitignored y contienen datos reales (883 vídeos, sesión de YouTube). No los commitees ni pegues su contenido en respuestas. +- [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`. diff --git a/OPPORTUNITIES.md b/OPPORTUNITIES.md deleted file mode 100644 index 6fc5533..0000000 --- a/OPPORTUNITIES.md +++ /dev/null @@ -1,298 +0,0 @@ -# AUDIT · Oportunidades de extensión - -> Análisis de features, comandos y opciones que se pueden construir sobre la infraestructura actual. -> Cada item incluye: valor, esfuerzo estimado, dependencias y comando propuesto. - ---- - -## Infraestructura disponible (lo que ya tenemos) - -| Recurso | Estado | Reutilizable para | -|---|---|---| -| `yt-dlp` instalado | ✅ v2026.7.4 | Audio/video download, thumbnails, metadata enriquecida | -| SQLite 3.49 con FTS5 | ✅ verificado | Búsqueda full-text sobre transcripciones | -| SQLite JSON1 | ✅ verificado | Queries estructuradas sobre metadatos | -| 33 transcripciones parseadas | ✅ en `data/state.db` | Análisis, estadísticas, búsqueda | -| Markdown con frontmatter YAML | ✅ 842 KB en `data/markdown/` | Obsidian, Pandoc, static site generators | -| Pipeline modular | ✅ 8 módulos | Insertar nuevos pasos sin romper nada | - ---- - -## TIER 1 · Alto valor, bajo esfuerzo (1-3 h c/u) - -### 1. `search` — Búsqueda full-text en transcripciones - -```bash -yt-scraper search "vaporwave" -yt-scraper search "dragon ball" --channel UCmhcYyPg7fsxMzQsY0RJBjw -``` - -**Qué hace:** busca texto dentro de las transcripciones ya scrapeadas y devuelve vídeo + timestamp exacto. - -**Implementación:** -- Nueva tabla `transcript_segments(video_id, start, text)` poblada durante el scrape -- Índice `USING fts5(text)` sobre esa tabla -- Comando `search` que hace `SELECT ... WHERE transcript_segments MATCH ?` -- **Esfuerzo:** 1-2 h. Sin dependencias nuevas. - -### 2. `stats` — Estadísticas del canal - -```bash -yt-scraper stats -yt-scraper stats --channel UCmhcYyPg7fsxMzQsY0RJBjw -``` - -**Qué muestra:** -``` -Nostal Vlad (@Nostal-Vlad) - Videos: 33 scrapeados / 37 totales - Duración total: 11.2 horas - Palabras totales: 89,432 - Fecha rango: 2019-04-28 → 2026-07-26 - Views promedio: 45,231 - Likes promedio: 3,201 - Top tags: videojuegos (12), 2000s (10), nostalgia (8) -``` - -**Implementación:** queries SQL agregadas + presentación con Rich tables. -- **Esfuerzo:** 1 h. - -### 3. `export` — Export multi-formato - -```bash -yt-scraper export --format json # un JSON con todo -yt-scraper export --format csv # CSV de metadatos -yt-scraper export --format srt # subtítulos SRT estándar -yt-scraper export --format html # galería navegable -``` - -**Implementación:** -- JSON: serializar `Store.get_all()` + leer transcripciones de los `.md` -- CSV: `csv.writer` sobre metadatos -- SRT: ya tenemos `{start, end, text}` en `Segment` — conversión trivial -- HTML: plantilla Jinja2 con tarjetas por vídeo (thumbnail + título + link) -- **Esfuerzo:** 2-3 h los 4 formatos. - -### 4. `clip` — Extracción de segmento por timestamp - -```bash -yt-scraper clip gOUyxFwWQqA --from 01:38 --to 03:20 -``` - -**Qué hace:** devuelve el texto de la transcripción entre dos timestamps, listo para citar. - -**Implementación:** cargar el `.md`, parsear segmentos, filtrar por rango. -- **Esfuerzo:** 30 min. - -### 5. `audio` — Descarga de audio (podcast) - -```bash -yt-scraper --channel "https://www.youtube.com/@Nostal-Vlad/videos" --download-audio -``` - -**Qué hace:** descarga el audio MP3 de cada vídeo junto a la transcripción. - -**Implementación:** añadir a `extract.py`: -```python -ydl_opts["format"] = "bestaudio/best" -ydl_opts["postprocessors"] = [{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "128"}] -ydl_opts["outtmpl"] = str(output_dir / "%(title)s.%(ext)s") -``` -Requiere `ffmpeg` instalado. -- **Esfuerzo:** 1 h. - -### 6. `re-render` — Regenerar Markdown tras cambiar plantilla - -```bash -yt-scraper re-render -``` - -**Qué hace:** re-procesa todos los `.md` usando la plantilla actual sin re-descargar nada de YouTube. Útil cuando cambias `video.md.j2`. - -**Implementación:** guardar `segments` y `chapters` en SQLite (columnas JSON), o re-parsear los `.md` existentes. -- **Esfuerzo:** 1-2 h. - ---- - -## TIER 2 · Alto valor, esfuerzo medio (3-8 h c/u) - -### 7. Multi-canal — Gestión de varios canales - -```bash -yt-scraper channels add "https://www.youtube.com/@otro-canal" -yt-scraper channels list -yt-scraper --channel @nostal-vlad search "vaporwave" -yt-scraper --all-channels stats -``` - -**Implementación:** tabla `channels` ya existe; ampliar CLI con subcomandos `channels add/list/remove`. El `Store` ya soporta multi-canal (PK por `channel_id`). -- **Esfuerzo:** 3-4 h. - -### 8. `watch` — Monitoreo de nuevos vídeos - -```bash -yt-scraper watch --interval 6h -``` - -**Qué hace:** ejecuta discovery periódicamente, y si hay vídeos nuevos los procesa automáticamente. - -**Implementación:** loop con `time.sleep(interval)` + `discover_channel()`. En Windows se puede dejar corriendo o usar Task Scheduler. -- **Esfuerzo:** 2-3 h. - -### 9. Análisis de contenido - -```bash -yt-scraper analyze --wordcloud -yt-scraper analyze --top-words 50 -yt-scraper analyze --timeline "vaporwave" -``` - -**Qué hace:** -- Nube de palabras (matplotlib + wordcloud) -- Frecuencia de términos con gráfico -- Timeline: cuándo se mencionó un tema a lo largo del tiempo - -**Dependencias nuevas:** `matplotlib`, `wordcloud`, `nltk` (o regex simple para español). -- **Esfuerzo:** 4-5 h. - -### 10. `thumbnails` — Descarga masiva de miniaturas - -```bash -yt-scraper thumbnails --size maxres -``` - -**Implementación:** ya tenemos `thumbnail` URL en el frontmatter. Un `requests.get` por vídeo + guardar en `data/thumbnails/`. -- **Esfuerzo:** 30 min. - -### 11. Traducción de transcripciones - -```bash -yt-scraper --channel @nostal-vlad --translate-to en -``` - -**Qué hace:** traduce cada transcripción al idioma especificado antes de renderizar el Markdown. - -**Opciones:** -- Google Translate (gratis, vía `deep-translator`): rápido, baja calidad -- LLM (OpenAI/Anthropic): calidad alta, requiere API key -- **Esfuerzo:** 3-4 h (cualquier ruta). - ---- - -## TIER 3 · Features avanzadas (8+ h) - -### 12. LLM Summaries — Resúmenes por vídeo - -```bash -yt-scraper --channel @nostal-vlad --summarize -``` - -**Qué hace:** genera un resumen de 3-5 párrafos por vídeo usando un LLM. Se añade al Markdown como campo `summary` en el frontmatter. - -**Implementación:** -- Nuevo módulo `summarize.py` -- Usa la transcripción completa como contexto -- Modelos: OpenAI `gpt-4o-mini` ($0.015 por vídeo de 20 min), Anthropic Claude Haiku, o modelo local con `ollama` -- Guardar resumen en SQLite para no re-ejecutar -- **Esfuerzo:** 4-6 h. - -**Dependencias:** `openai` o `anthropic` o `ollama` (local, gratis). - -### 13. Q&A / RAG — Preguntas sobre el canal - -```bash -yt-scraper ask "¿Qué dijo Vlad sobre los juegos flash?" -``` - -**Qué hace:** busca en todas las transcripciones y responde con citas + timestamps exactos. - -**Implementación:** -- **RAG simple:** FTS5 search → mandar top-K segmentos al LLM como contexto -- **RAG con embeddings:** `sentence-transformers` → ChromaDB/FAISS → recuperación semántica -- **Esfuerzo:** RAG simple 4-5 h, con embeddings 8-10 h. - -### 14. Extracción de entidades (NER) - -```bash -yt-scraper analyze --entities -``` - -**Qué hace:** detecta personas, marcas, lugares, videojuegos mencionados a lo largo de todos los vídeos. - -**Output:** tabla `entities(video_id, type, name, count)` → "Pokémon mencionado en 8 vídeos", "Sonic en 5 vídeos". - -**Implementación:** `spaCy` (modelo `es_core_news_sm`) o LLM con prompt estructurado. -- **Esfuerzo:** 4-6 h. - -### 15. Speaker diarization — Separación de hablantes - -**Qué hace:** distingue "narrador" de "entrevistado" o "invitado" en la transcripción. - -**Implementación:** el audit del Web Clipper ya documenta esto (`groupBySpeaker` en `YoutubeExtractor`). Se puede portar a Python o usar `pyannote-audio`. -- **Esfuerzo:** port del algoritmo 2-3 h; con modelo de audio 8+ h. - ---- - -## Matriz de prioridades - -| # | Feature | Valor | Esfuerzo | ROI | -|---|---|---|---|---| -| 1 | `search` (FTS5) | 🔥🔥🔥 | 1-2 h | ⭐⭐⭐ | -| 2 | `stats` | 🔥🔥 | 1 h | ⭐⭐⭐ | -| 3 | `export json/srt/html` | 🔥🔥 | 2-3 h | ⭐⭐⭐ | -| 4 | `clip` | 🔥 | 30 min | ⭐⭐ | -| 5 | `audio` download | 🔥🔥 | 1 h | ⭐⭐⭐ | -| 6 | `re-render` | 🔥 | 1-2 h | ⭐⭐ | -| 7 | Multi-canal | 🔥🔥 | 3-4 h | ⭐⭐ | -| 8 | `watch` mode | 🔥🔥 | 2-3 h | ⭐⭐ | -| 9 | Análisis (wordcloud) | 🔥 | 4-5 h | ⭐ | -| 10 | `thumbnails` | 🔥 | 30 min | ⭐⭐⭐ | -| 11 | Traducción | 🔥🔥 | 3-4 h | ⭐⭐ | -| 12 | LLM summaries | 🔥🔥🔥 | 4-6 h | ⭐⭐⭐ | -| 13 | Q&A / RAG | 🔥🔥🔥 | 4-10 h | ⭐⭐ | -| 14 | NER (entidades) | 🔥 | 4-6 h | ⭐ | -| 15 | Speaker diarization | 🔥 | 2-8 h | ⭐ | - ---- - -## Dependencias Python adicionales por feature - -```bash -# Tier 1 — sin dependencias nuevas (solo stdlib) -# search, stats, export, clip, re-render usan SQLite + Jinja2 ya instalados - -# Tier 2 -pip install matplotlib wordcloud # análisis / wordcloud -pip install deep-translator # traducción gratuita - -# Tier 3 -pip install openai # LLM summaries / Q&A -pip install sentence-transformers # embeddings para RAG semántico -pip install spacy && python -m spacy download es_core_news_sm # NER -pip install pyannote.audio # speaker diarization (requiere GPU idealmente) - -# Audio download -# Requiere ffmpeg instalado en el sistema: -# winget install ffmpeg -# o: choco install ffmpeg -``` - ---- - -## Comandos compuestos propuestos (pipelines) - -```bash -# Pipeline completo: scrapear + buscar + extraer clip -yt-scraper --channel @nostal-vlad scrape -yt-scraper search "frutiger aero" -yt-scraper clip 8tMQMtIBfTE --from 02:15 --to 04:30 > clip.md - -# Pipeline de análisis -yt-scraper stats > stats.txt -yt-scraper export --format html > index.html -yt-scraper analyze --top-words 30 > palabras.csv - -# Pipeline LLM -yt-scraper --channel @nostal-vlad --summarize -yt-scraper ask "¿Qué opina Vlad sobre la cultura otaku?" -``` diff --git a/README.md b/README.md index 53788eb..2f2070e 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,212 @@ # yt-channel-scraper -Scraper de canales de YouTube que extrae transcripciones, capítulos y metadatos completos, generando notas Markdown listas para Obsidian. +Scraper local de canales de YouTube: descubre los vídeos de cada canal con `yt-dlp`, extrae metadatos y transcripciones, guarda todo en SQLite (con búsqueda full-text) y genera una nota Markdown por vídeo, lista para un vault de Obsidian. Se maneja desde el CLI `yt-scraper` o desde una webapp local (FastAPI + Alpine) con gestión de canales, jobs en vivo, lector de transcripciones y vault de cookies. -Basado en la ingeniería inversa de [Obsidian Web Clipper](https://obsidian.md/) — usa `yt-dlp` internamente, que implementa el mismo mecanismo InnerTube (`youtubei/v1/player` con clientes ANDROID/IOS/WEB) que la extensión audita. - -## Instalación - -```bash -cd yt-channel-scraper -pip install -e ".[dev]" -``` - -Requiere Python ≥ 3.10. - -## Uso - -### Scrape completo de un canal - -```bash -yt-scraper --channel "https://www.youtube.com/@Nostal-Vlad/videos" -``` - -### Dry run (ver qué descubriría sin descargar) - -```bash -yt-scraper --channel "https://www.youtube.com/@Nostal-Vlad/videos" --dry-run --limit 10 -``` - -### Solo vídeos recientes - -```bash -yt-scraper --channel "https://www.youtube.com/@Nostal-Vlad/videos" --since 2025-01-01 -``` - -### Reanudar tras interrupción - -```bash -yt-scraper --channel "https://www.youtube.com/@Nostal-Vlad/videos" -``` - -El estado se guarda en `data/state.db` (SQLite). Los vídeos ya procesados se saltan automáticamente. - -### Reintentar vídeos con error - -```bash -yt-scraper --channel "https://www.youtube.com/@Nostal-Vlad/videos" --reset-errors -``` - -## Opciones - -| Flag | Descripción | Default | -|---|---|---| -| `--channel, -c` | URL del canal | de config.yaml | -| `--config` | Ruta al YAML de configuración | `config.yaml` | -| `--limit N` | Procesar solo N vídeos | sin límite | -| `--since DATE` | Solo vídeos desde YYYY-MM-DD | sin filtro | -| `--languages, -l` | Idiomas preferidos (coma-sep) | `es,en` | -| `--no-auto` | Ignorar subtítulos auto-generados | false | -| `--no-shorts` | Excluir Shorts | de config | -| `--include-shorts` | Incluir Shorts | de config | -| `--resume/--no-resume` | Saltar procesados | `--resume` | -| `--dry-run` | Solo discovery, no descargar | false | -| `--reset-errors` | Reintentar vídeos con error | false | -| `--verbose, -v` | Logging DEBUG | false | - -## Configuración - -Copia `config.example.yaml` a `config.yaml` y edita: - -```yaml -channel_url: "https://www.youtube.com/@Nostal-Vlad/videos" -languages: ["es", "es-419", "en"] -prefer_manual: true -include_shorts: false -min_duration_sec: 30 - -delay: - min_seconds: 1.5 - max_seconds: 3.5 -``` - -## Output - -Cada vídeo genera un archivo Markdown en `data/markdown/@canal/`: - -``` -data/markdown/Nostal Vlad/ -├── 2026-07-26_asi-era-ser-una-adolescente-edgy-en-los-2000.md -├── 2026-07-12_la-estetica-que-romantiza-ser-un-perdedor-losercore.md -└── ... -``` - -Formato de cada nota: - -```markdown ---- -video_id: gOUyxFwWQqA -title: "La Estética Que ROMANTIZA ser un \"PERDEDOR\" | Losercore" -channel: Nostal Vlad -upload_date: 2026-07-12 -duration: 1069 -url: https://www.youtube.com/watch?v=gOUyxFwWQqA -transcript_lang: es-orig -transcript_src: auto -views: 100523 -likes: 8196 ---- - -# La Estética Que ROMANTIZA ser un "PERDEDOR" | Losercore - -> [Ver en YouTube](https://www.youtube.com/watch?v=gOUyxFwWQqA) - -## Transcripcion - -### Intro (00:15) - -**00:15** · Una de las estéticas que ha cobrado más relevancia últimamente... -**00:28** · que hacer esto. Y es que el loser core como tal es muy difuso... - -### Losercore (01:38) - -**01:38** · ... -``` - -## Estados en SQLite - -| Status | Significado | -|---|---| -| `pending` | Descubierto, sin procesar | -| `done` | Transcripción extraída y Markdown generado | -| `no_subtitles` | El vídeo no tiene subtítulos (ni manuales ni auto) | -| `error` | Error al procesar (miembros-only, privado, bloqueo, etc.) | +100% local: sin API keys, sin deploy remoto, sin telemetría. ## Arquitectura ``` -cli.py Orquestador (Click + Rich progress) -├── discover.py yt-dlp --flat-playlist → lista de videoIds -├── store.py SQLite: estado, resume, dedup -├── extract.py yt-dlp.extract_info → metadata + subtítulos -├── parse.py JSON3/VTT → segmentos {start, end, text} -├── chapters.py align_chapters: capítulos ↔ segmentos -├── render.py Jinja2 → Markdown con frontmatter YAML -└── ratelimit.py delays aleatorios + backoff exponencial + ┌───────────────────────────────────────────────┐ + │ webapp (FastAPI) │ + │ canales · jobs SSE · lector · cookies · UI │ + └───────────────────────┬───────────────────────┘ + │ + CLI (Click) ▼ + yt-scraper ──► pipeline: discover ─► extract ─► parse ─► store ─► render + (yt-dlp (player + (JSON3/ (SQLite (Jinja2 + flat) subtítulos) VTT) + FTS5) → .md) + │ + data/state.db ◄───────────────────────┘ + data/markdown//_.md ``` +- `discover` lista el canal (1 petición por página); `extract` baja metadatos + subtítulos (~2 peticiones por vídeo); `parse` convierte JSON3/VTT en segmentos; `store` persiste en SQLite con FTS5; `render` escribe el `.md` con frontmatter YAML. +- CLI, webapp y `watch` son fachadas sobre el mismo pipeline: una feature se implementa una vez y se expone en ambas. + +## Requisitos + +- **Python >= 3.10** (3.12 recomendado). +- **ffmpeg** (opcional): solo para `yt-scraper audio` y el job de audio de la webapp. +- **Un runtime JS** (node, deno o bun; opcional pero recomendado): yt-dlp lo usa para resolver los challenges de YouTube. +- Plataforma: Windows, macOS (incl. Apple Silicon; todas las dependencias publican wheels arm64) o Linux. + +| Herramienta | Windows (winget) | macOS (brew) | Debian/Ubuntu (apt) | +|---|---|---|---| +| Python 3.12 | `winget install Python.Python.3.12` | `brew install python@3.12` | `sudo apt install python3 python3-venv` | +| ffmpeg | `winget install Gyan.FFmpeg` | `brew install ffmpeg` | `sudo apt install ffmpeg` | +| node | `winget install OpenJS.NodeJS.LTS` | `brew install node` | `sudo apt install nodejs` | + +## Instalación rápida + +Ejecuta siempre los comandos **desde la raíz del repo**: las rutas de `config.yaml` (`data/`, `templates/`, la BD) se resuelven relativas al directorio de trabajo. + +```bash +# Opción A: uv (recomendado, respeta uv.lock) +uv sync --all-extras + +# Opción B: pip +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev,web,analysis]" +``` + +Extras: `dev` (pytest, pytest-cov, httpx), `web` (fastapi, uvicorn, sse-starlette, python-multipart), `analysis` (matplotlib, wordcloud). + +Máquina nueva desde cero: [docs/GETTING-STARTED.md](docs/GETTING-STARTED.md) (paso a paso por OS, con troubleshooting). + +## Arranque + +### Webapp + +**Windows** — doble clic en `start-server.bat`: + +- La primera vez pasa por `scripts/doctor.ps1`, que prepara el entorno si falta (Python 3.12 vía winget + `pip install -e ".[web]"`). +- `scripts/start-server.ps1` busca un puerto libre (8000–8100), arranca uvicorn, registra el proceso en `.run/server.info` (reutilizable), comprueba `/healthz` y abre el navegador. +- Para parar: `stop-server.bat` (mata el proceso registrado y libera el puerto). + +**macOS / Linux**: + +```bash +make setup # primera vez: scripts/bootstrap.sh (python/ffmpeg/node vía brew si faltan + deps) +make serve # scripts/start-server.sh +make stop # scripts/stop-server.sh +# O directamente, sin Makefile: +scripts/bootstrap.sh && scripts/start-server.sh +``` + +**Manual (debug)**: + +```bash +python -m uvicorn yt_scraper.webapp.app:app --host 127.0.0.1 --port 8000 +``` + +### CLI + +El entry point es `yt-scraper`. OJO: sin subcomando ejecuta un scrape completo del canal de `config.yaml`. Los flags de scrape van en el subcomando: + +```bash +yt-scraper scrape # scrape del canal de config.yaml (incremental) +yt-scraper -c "https://www.youtube.com/@Nostal-Vlad/videos" scrape # otro canal sin tocar config + +# Discovery sin descargar nada (ver qué encontraría) +yt-scraper scrape --dry-run --limit 10 + +# Solo vídeos desde una fecha / limitar la tirada +yt-scraper scrape --since 2025-01-01 --limit 5 + +# Reintentar vídeos en error/no_subtitles y continuar +yt-scraper scrape --reset-errors + +# Recorrer el canal entero (ignora la ventana incremental) +yt-scraper scrape --full +``` + +Flags del grupo raíz (aplican a todo): `--config`, `--channel/-c`, `--all-channels`, `--cookies`, `--cookies-from-browser`, `--verbose/-v`. + +**Canales** (multi-canal): + +```bash +yt-scraper channels add "https://www.youtube.com/@Fazt" +yt-scraper channels list +yt-scraper channels remove "@Fazt" +yt-scraper --all-channels scrape # aplica el subcomando a todos los canales +``` + +**Búsqueda y export** (sobre lo ya scrapeado, sin tocar la red): + +```bash +yt-scraper search "vaporwave" # FTS5 en transcripciones, con timestamp +yt-scraper -c "@Fazt" search "typescript" -l 50 +yt-scraper export --format json # json | csv | srt | html → data/exports/ +yt-scraper --all-channels export --format srt +``` + +**Recuperación** (DB ↔ disco): + +```bash +yt-scraper reset # error/no_subtitles → pending (pregunta antes) +yt-scraper reset --status error --yes +yt-scraper reconcile # re-escanea data/markdown y cuadra la DB con el disco +yt-scraper reconcile --prune # además devuelve a pending los done sin .md +yt-scraper re-render # regenera .md desde segmentos guardados (0 descargas) +yt-scraper re-render --backfill # antes importa segmentos desde los .md existentes +``` + +**Audio, monitoreo y análisis**: + +```bash +yt-scraper audio --limit 5 # MP3 de los done (requiere ffmpeg) → data/audio/ +yt-scraper watch --interval 6h # discovery periódico y procesa lo nuevo +yt-scraper watch --once # una sola pasada +yt-scraper analyze --top-words 50 # frecuencia → data/analysis/top_words.{csv,png} +yt-scraper analyze --wordcloud # + data/analysis/wordcloud.png +yt-scraper analyze --timeline "ia" # menciones de un término por mes +``` + +## Configuración + +Copia la plantilla y edita: + +```bash +cp config.example.yaml config.yaml # Windows: copy config.example.yaml config.yaml +``` + +`config.yaml` es privado y está gitignored; la plantilla versionada (`config.example.yaml`) documenta todas las claves. Bloques principales: + +| Bloque | Qué controla | +|---|---| +| raíz (`channel_url`, `languages`, `include_shorts`, ...) | canal por defecto, idiomas de subtítulos (modo `manual`/`auto`/`any` por idioma), filtros de shorts/live/duración | +| `delay` | pacing: pausas entre vídeos, backoff ante rate-limit, circuit breaker, intervalo mínimo global entre peticiones | +| `yt_dlp` | reintentos, pausa entre sub-peticiones, timeouts de yt-dlp | +| `sync` | discovery incremental: ventana inicial, máximo y solapamiento que da la sincronización por alcanzada | +| `database_path`, `output_dir`, `template_path`, `filename_template` | rutas (relativas al CWD) y patrón del nombre de las notas | + +Referencia completa de cada clave con tipos y defaults: [docs/CONFIG.md](docs/CONFIG.md). + +## Cookies + +Las cookies de sesión de YouTube (formato Netscape) sirven para acceder a vídeos de membresía y reducir los bot-checks. El vault (`cookies/`) admite varias, con **exactamente una activa**; se pueden importar: + +- **Webapp**: arrastrar y soltar un `cookies.txt`, o importar directamente desde el navegador local (Brave). +- **CLI**: `yt-scraper --cookies ruta/cookies.txt scrape` o `--cookies-from-browser brave` (chrome|firefox|edge|brave). + +Cómo exportarlas desde tu navegador y cómo rotarlas: [docs/COOKIES.md](docs/COOKIES.md). **Son equivalentes a tu contraseña: nunca las commitees** (están en `.gitignore`). + +## Datos generados + +``` +data/ +├── state.db # SQLite (WAL): vídeos, canales, segmentos, FTS5, jobs, cookies_meta +├── markdown// # una nota .md por vídeo: _.md con frontmatter YAML +├── thumbnails/.jpg # miniaturas +├── avatars/ # avatares de canal +├── audio/ # MP3 descargados (webapp: .mp3; CLI: por título) +├── videos// # descargas de vídeo completas (.webm) +├── exports/ # export json/csv/srt/html +└── analysis/ # wordcloud, top_words, timeline +cookies/ # vault de cookies Netscape (gitignored, SENSIBLE) +.run/ # estado del servidor (puerto, PID) +``` + +Estados de un vídeo en la BD: `pending` (descubierto, sin procesar), `done` (transcripción + `.md`), `no_subtitles` (sin pista válida según la política de idiomas), `error` (fallo: privado, bloqueo, ...). + +`data/`, `cookies/`, `config.yaml` y `.run/` están gitignored: contienen tu biblioteca y tu sesión. No los commitees. + ## Tests ```bash -python -m pytest tests/ -v +uv run python -m pytest -q # o: python -m pytest -q (dentro del venv) ``` -## Mantenimiento +Suite hermética (~240 tests en 24 archivos, sin red): todo con `tmp_path` + `monkeypatch`; `yt_dlp.YoutubeDL` se sustituye por un fake. -Si la extracción falla tras una actualización de YouTube: +## Migrar el repo a otra máquina -```bash -yt-dlp -U # actualizar yt-dlp -pip install -U yt-dlp -``` +- Las rutas se resuelven **relativas al CWD**: sigue ejecutando todo desde la raíz del repo. +- Si mueves o clonas el repo, la instalación editable apunta a la ruta vieja: re-ejecuta `uv sync --all-extras` (o `pip install -e ".[dev,web,analysis]"`) y recrea el venv si hace falta. +- `config.yaml` y `cookies/` no viajan en el clon: recréalos desde `config.example.yaml` y re-exporta las cookies. -Las versiones de cliente InnerTube (ANDROID `20.10.x`, IOS `20.10.x`, WEB `2.2024xxxx`) rotan mensualmente. `yt-dlp` las mantiene actualizadas. +## Licencia + +TBD. diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 0000000..ccc9efd --- /dev/null +++ b/docs/CONFIG.md @@ -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. diff --git a/docs/COOKIES.md b/docs/COOKIES.md new file mode 100644 index 0000000..d0a99eb --- /dev/null +++ b/docs/COOKIES.md @@ -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/.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. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md new file mode 100644 index 0000000..fdc0d96 --- /dev/null +++ b/docs/GETTING-STARTED.md @@ -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 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// +``` + +### 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 python@3.12 ffmpeg node +``` + +Todas las dependencias Python publican wheels arm64; no hay compilación. + +### 2-3. Código y entorno + +```bash +git clone 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 | diff --git a/docs/audits/CLIPPER-COMPARISON-AUDIT.md b/docs/audits/CLIPPER-COMPARISON-AUDIT.md new file mode 100644 index 0000000..aaad61f --- /dev/null +++ b/docs/audits/CLIPPER-COMPARISON-AUDIT.md @@ -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/.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.* diff --git a/docs/audits/README.md b/docs/audits/README.md new file mode 100644 index 0000000..c63e8af --- /dev/null +++ b/docs/audits/README.md @@ -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í. diff --git a/YOUTUBE-TRANSCRIPT-AUDIT.md b/docs/audits/YOUTUBE-TRANSCRIPT-AUDIT.md similarity index 100% rename from YOUTUBE-TRANSCRIPT-AUDIT.md rename to docs/audits/YOUTUBE-TRANSCRIPT-AUDIT.md diff --git a/docs/backlog.md b/docs/backlog.md new file mode 100644 index 0000000..e26dbd1 --- /dev/null +++ b/docs/backlog.md @@ -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 --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 |