docs: documentacion completa para humanos y agentes IA
- README reescrito: ejemplos CLI corregidos (flags van en el subcomando scrape), webapp+doctor documentados, requisitos por OS, cookies, datos generados, migracion de maquina - CLAUDE.md actualizado: conteo de tests, refs de linea reparadas, scripts nuevos, checklist de actualizacion de docs - AGENTS.md nuevo: entrada estandar para cualquier agente de codigo (mapa de modulos, comandos, reglas de oro, punteros a docs) - docs/GETTING-STARTED.md: de cero a webapp por OS con troubleshooting - docs/CONFIG.md: referencia completa de las ~25 claves de config - docs/COOKIES.md: adquisicion, import (web/navegador/CLI), rotacion y seguridad - docs/backlog.md reemplaza OPPORTUNITIES.md (distilado: shipped vs pendiente) - auditorias movidas a docs/audits/ y CLIPPER-COMPARISON-AUDIT.md versionado
This commit is contained in:
@@ -0,0 +1,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).
|
||||||
@@ -11,7 +11,8 @@ Plataforma **100% local** de minería de contenido de creadores de YouTube: `yt-
|
|||||||
```bash
|
```bash
|
||||||
pip install -e ".[dev,web,analysis]" # Python >= 3.10; ffmpeg es requisito del sistema para audio
|
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 -v # un archivo
|
||||||
python -m pytest tests/test_store_platform.py::test_dashboard_aggregates -v # un test
|
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)
|
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
|
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
|
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)
|
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
|
## 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 "<pregunta>"`, `graphify explain "<concepto>"`, `graphify path "<A>" "<B>"`. Si el grafo está desactualizado respecto a los archivos, `graphify update`. [graphify-out/GRAPH_REPORT.md](graphify-out/GRAPH_REPORT.md) resume comunidades y god nodes (`Store` es el más conectado con diferencia).
|
Existe un grafo de conocimiento en [graphify-out/](graphify-out/) (gitignored, regenerable). Recomendado —no obligatorio, no es un hook del repo— consultarlo antes de leer/grepear fuentes: `graphify query "<pregunta>"`, `graphify explain "<concepto>"`, `graphify path "<A>" "<B>"`. Si el grafo está desactualizado respecto a los archivos, `graphify update`. [graphify-out/GRAPH_REPORT.md](graphify-out/GRAPH_REPORT.md) resume comunidades y god nodes (`Store` es el más conectado con diferencia).
|
||||||
|
|
||||||
## Arquitectura
|
## 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
|
### 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.
|
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.
|
- **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.
|
- **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`
|
### 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)
|
### 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.
|
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
|
## Documentos de referencia
|
||||||
|
|
||||||
|
- [AGENTS.md](AGENTS.md) — guía de entrada para cualquier agente de código (pitch, mapa de módulos, reglas de oro, punteros a docs).
|
||||||
- [docs/superpowers/specs/2026-07-26-platform-design.md](docs/superpowers/specs/2026-07-26-platform-design.md) — diseño de referencia (esquema, endpoints, alcance, exclusiones). Es la fuente de verdad de las decisiones; donde el código difiere, gana el código.
|
- [docs/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.
|
- [docs/backlog.md](docs/backlog.md) — backlog destilado: lo pendiente y qué ya está shipped (sustituye al histórico OPPORTUNITIES.md).
|
||||||
- [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.
|
- [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`).
|
||||||
- `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/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`.
|
||||||
|
|||||||
@@ -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?"
|
|
||||||
```
|
|
||||||
@@ -1,163 +1,212 @@
|
|||||||
# yt-channel-scraper
|
# 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.
|
100% local: sin API keys, sin deploy remoto, sin telemetría.
|
||||||
|
|
||||||
## 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.) |
|
|
||||||
|
|
||||||
## Arquitectura
|
## Arquitectura
|
||||||
|
|
||||||
```
|
```
|
||||||
cli.py Orquestador (Click + Rich progress)
|
┌───────────────────────────────────────────────┐
|
||||||
├── discover.py yt-dlp --flat-playlist → lista de videoIds
|
│ webapp (FastAPI) │
|
||||||
├── store.py SQLite: estado, resume, dedup
|
│ canales · jobs SSE · lector · cookies · UI │
|
||||||
├── extract.py yt-dlp.extract_info → metadata + subtítulos
|
└───────────────────────┬───────────────────────┘
|
||||||
├── parse.py JSON3/VTT → segmentos {start, end, text}
|
│
|
||||||
├── chapters.py align_chapters: capítulos ↔ segmentos
|
CLI (Click) ▼
|
||||||
├── render.py Jinja2 → Markdown con frontmatter YAML
|
yt-scraper ──► pipeline: discover ─► extract ─► parse ─► store ─► render
|
||||||
└── ratelimit.py delays aleatorios + backoff exponencial
|
(yt-dlp (player + (JSON3/ (SQLite (Jinja2
|
||||||
|
flat) subtítulos) VTT) + FTS5) → .md)
|
||||||
|
│
|
||||||
|
data/state.db ◄───────────────────────┘
|
||||||
|
data/markdown/<Canal>/<fecha>_<slug>.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 [email protected]` | `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/<Canal>/ # una nota .md por vídeo: <fecha>_<slug>.md con frontmatter YAML
|
||||||
|
├── thumbnails/<id>.jpg # miniaturas
|
||||||
|
├── avatars/ # avatares de canal
|
||||||
|
├── audio/ # MP3 descargados (webapp: <video_id>.mp3; CLI: por título)
|
||||||
|
├── videos/<id>/ # 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
|
## Tests
|
||||||
|
|
||||||
```bash
|
```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
|
- Las rutas se resuelven **relativas al CWD**: sigue ejecutando todo desde la raíz del repo.
|
||||||
yt-dlp -U # actualizar yt-dlp
|
- 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.
|
||||||
pip install -U yt-dlp
|
- `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.
|
||||||
|
|||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
# Referencia de configuración (`config.yaml`)
|
||||||
|
|
||||||
|
`config.yaml` es tu configuración privada (gitignored). La plantilla versionada `config.example.yaml` documenta todas las claves y es el punto de partida (`cp config.example.yaml config.yaml`). Todas las rutas del archivo se resuelven **relativas al CWD** — ejecuta siempre desde la raíz del repo.
|
||||||
|
|
||||||
|
Los defaults que siguen son los de `config.example.yaml` (entre paréntesis, cuando difiere, el default del código en `src/yt_scraper/config.py`).
|
||||||
|
|
||||||
|
## Bloque raíz — canal, idiomas y filtros
|
||||||
|
|
||||||
|
| Clave | Tipo | Default | Qué afecta |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `channel_url` | str | `"https://www.youtube.com/@Nostal-Vlad/videos"` | Canal por defecto de `yt-scraper scrape` / webapp. Se ignora si pasas `--channel`/`-c`. Acepta URL completa, `/channel/UC...` o handle |
|
||||||
|
| `languages` | dict \| list | `{es: any, es-419: any, en: any}` | Idiomas de subtítulos aceptables y su modo (ver abajo). Forma legacy: lista `["es","en"]` — todos con el modo de `prefer_manual`; evítala, un canal solo-auto quedaría en `no_subtitles` |
|
||||||
|
| `prefer_manual` | bool | `true` | En modo `any`: prueba subtítulos manuales antes que los automáticos |
|
||||||
|
| `include_shorts` | bool | `false` | Descubrir/procesar Shorts. En CLI: `--include-shorts` / `--no-shorts` |
|
||||||
|
| `include_live` | bool | `true` | Procesar directos. En CLI: `--no-live` |
|
||||||
|
| `min_duration_sec` | int | `30` (código: `0`) | Duración mínima del vídeo en segundos; filtra en discovery |
|
||||||
|
|
||||||
|
Modos por idioma de `languages`:
|
||||||
|
|
||||||
|
| Valor | Significado |
|
||||||
|
|---|---|
|
||||||
|
| `manual` | **Solo** subtítulos subidos por el creador; si solo hay automáticos → `no_subtitles` |
|
||||||
|
| `auto` | **Solo** subtítulos automáticos (ASR) |
|
||||||
|
| `any` | Manuales primero (según `prefer_manual`), automáticos como fallback. **Recomendado** |
|
||||||
|
|
||||||
|
Nota: `languages` es una preferencia entre idiomas que sabes leer, no una orden de traducir. El idioma hablado del vídeo siempre gana si está en la lista; nunca se guarda una traducción automática si existe el transcript original.
|
||||||
|
|
||||||
|
## Bloque `delay` — pacing y rate-limit
|
||||||
|
|
||||||
|
YouTube no publica límites; el techo práctico conocido (wiki de yt-dlp) es ~300 vídeos/hora en sesión sin cuenta. Estos valores apuntan por debajo. Con los defaults conservadores, una tirada real cuesta **del orden de 12–13 s por vídeo** (~270 vídeos/hora).
|
||||||
|
|
||||||
|
| Clave | Tipo | Default | Qué afecta |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `min_seconds` | float | `1.5` | Pausa aleatoria mínima entre vídeos (el gap real se sortea entre `min` y `max`) |
|
||||||
|
| `max_seconds` | float | `3.5` | Pausa aleatoria máxima entre vídeos |
|
||||||
|
| `backoff_base` | float | `2.0` | Backoff tras un rate-limit: `min(base * 2**n + jitter, cap)`, con n = fallos consecutivos |
|
||||||
|
| `backoff_cap` | float | `60.0` | Techo del backoff en segundos |
|
||||||
|
| `throttle_threshold` | int | `3` | Rate-limits **consecutivos** que hacen parar la tirada (circuit breaker). Lo no procesado queda `pending` y es recuperable |
|
||||||
|
| `min_request_interval` | float | `0.0` (example) | Separación mínima entre **cualquier** dos peticiones a YouTube del proceso (Pacer global, incluye los `/api/tools/*` fuera del job runner). `0` = desactivado. Es la única palanca que actúa en todas partes a la vez |
|
||||||
|
| `audio_rate_limit` | int | `0` | Techo de bytes/segundo para descargas de audio (ratelimit de yt-dlp). `0` = ilimitado |
|
||||||
|
|
||||||
|
Referencia práctica: la config real del autor usa `min_request_interval: 2.5` para ser aún más conservador cuando la webapp y el CLI conviven.
|
||||||
|
|
||||||
|
## Bloque `yt_dlp` — reintentos y timeouts
|
||||||
|
|
||||||
|
| Clave | Tipo | Default | Qué afecta |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `retries` | int | `10` | Reintentos de descarga; solo muerde en el camino de audio (el extractor de YouTube de yt-dlp no reintenta 403/429) |
|
||||||
|
| `sleep_subrequests` | float | `2` | Segundos entre las peticiones HTTP **dentro** de una extracción (watch page, llamada player, continuations). Se reenvía a yt-dlp como `sleep_interval_requests` (nombre interno del proyecto; el yt-dlp no tiene ninguna opción llamada `sleep_subrequests`) |
|
||||||
|
| `extractor_retries` | int | `3` | Reintentos durante la extracción (solo 5xx y red) |
|
||||||
|
| `socket_timeout` | float | `30.0` | Timeout de socket; sin él una conexión colgada bloquea el worker para siempre |
|
||||||
|
|
||||||
|
## Bloque `sync` — discovery incremental
|
||||||
|
|
||||||
|
Re-escanear un canal trackeado lee la pestaña `/videos` (cronológica inversa) y corta al ver `overlap` vídeos ya conocidos: un sync rutinario cuesta 1 página, no el canal entero.
|
||||||
|
|
||||||
|
| Clave | Tipo | Default | Qué afecta |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `incremental` | bool | `true` | Activar el modo ventana. `false` (o `--full` / "Full rescan" en la webapp) recorre todo el canal |
|
||||||
|
| `window` | int | `30` | Entradas leídas en la primera pasada |
|
||||||
|
| `max_window` | int | `300` | Techo: si TODA la ventana resulta nueva, se duplica hasta aquí antes de declarar el scan truncado (aviso "usa `--full`") |
|
||||||
|
| `overlap` | int | `3` | Vídeos conocidos consecutivos que dan la sincronización por alcanzada |
|
||||||
|
|
||||||
|
## Rutas y plantillas
|
||||||
|
|
||||||
|
| Clave | Tipo | Default | Qué afecta |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `database_path` | str | `"data/state.db"` | Ruta de la BD SQLite (WAL). Relativa al CWD |
|
||||||
|
| `output_dir` | str | `"data/markdown"` | Directorio de notas; el resto de datos (`audio/`, `thumbnails/`, ...) se deriva de su padre |
|
||||||
|
| `template_path` | str | `"templates/video.md.j2"` | Plantilla Jinja2 de la nota. OJO: el formato del `.md` es un contrato bidireccional (los regex de `segments.py` lo re-lean) |
|
||||||
|
| `filename_template` | str | `"{upload_date}_{slug}"` | Patrón del nombre de las notas. Placeholders: `{upload_date}` (fecha de subida; `unknown-date` si se desconoce), `{slug}` (slug del título, máx 60), `{title}` (título crudo), `{video_id}` (id estable de YouTube — útil porque título y fecha son inestables) |
|
||||||
|
|
||||||
|
## Ejemplos
|
||||||
|
|
||||||
|
**Mínima** (defaults razonables, primer contacto):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
channel_url: "https://www.youtube.com/@MiCanal/videos"
|
||||||
|
languages:
|
||||||
|
es: any
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conservadora** (muchos vídeos, webapp y CLI a la vez, o historial de rate-limits):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
channel_url: "https://www.youtube.com/@MiCanal/videos"
|
||||||
|
languages:
|
||||||
|
es: any
|
||||||
|
es-419: any
|
||||||
|
en: any
|
||||||
|
prefer_manual: true
|
||||||
|
include_shorts: false
|
||||||
|
min_duration_sec: 30
|
||||||
|
|
||||||
|
delay:
|
||||||
|
min_seconds: 1.5
|
||||||
|
max_seconds: 3.5
|
||||||
|
backoff_base: 2.0
|
||||||
|
backoff_cap: 60.0
|
||||||
|
throttle_threshold: 3
|
||||||
|
min_request_interval: 2.5 # pacer global: la palanca más eficaz contra el bot-wall
|
||||||
|
|
||||||
|
sync:
|
||||||
|
incremental: true
|
||||||
|
window: 30
|
||||||
|
max_window: 300
|
||||||
|
overlap: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Si cambias los tiempos, vuelve a medir sobre tu canal: la constante es ~3 peticiones a `youtube.com` por vídeo y de ahí sale todo lo demás.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Guía de cookies
|
||||||
|
|
||||||
|
Las cookies son **tu sesión de YouTube**. Este scraper las usa (vía yt-dlp) para presentarse con esa sesión en cada petición.
|
||||||
|
|
||||||
|
## Por qué hacen falta
|
||||||
|
|
||||||
|
- **Vídeos de membresía** (`subscriber_only`): sin sesión es imposible descargarlos; con ella, si estás suscrito al canal, entran como cualquier otro.
|
||||||
|
- **Menos bot-checks / rate-limit**: una sesión autenticada aguanta bastante más tráfico que una anónima antes de toparse con el muro (~300 vídeos/hora en sesión de invitado según la wiki de yt-dlp).
|
||||||
|
- **Challenges y PO tokens**: parte de los retos anti-bot que YouTube sirve se suavizan cuando la petición viaja con cookies de una sesión real.
|
||||||
|
|
||||||
|
Sin cookies el scraper funciona igual; simplemente verás más `error` por throttling y los vídeos de membresía quedan bloqueados.
|
||||||
|
|
||||||
|
## Qué es un `cookies.txt` (formato Netscape)
|
||||||
|
|
||||||
|
Un archivo de texto plano con una cookie por línea, tabulador-separado:
|
||||||
|
|
||||||
|
```
|
||||||
|
.youtube.com\tTRUE\t/\tTRUE\t1798761600\tSID\t"value"
|
||||||
|
.youtube.com\tTRUE\t/\tTRUE\t0\t__Secure-3PSID\t"value"
|
||||||
|
```
|
||||||
|
|
||||||
|
Detalles que importan aquí:
|
||||||
|
|
||||||
|
- Las líneas `#HttpOnly_...` **son datos**, no comentarios: las cookies de login (SID, HSID, ...) son HttpOnly y un export que las omita no sirve.
|
||||||
|
- **Sesión válida** (criterio que aplica el vault al importar): el archivo contiene las tres `SID` + `HSID` + `SSID`, **o** al menos `LOGIN_INFO`. Las que debe traer un export correcto: `SID`, `HSID`, `SSID`, `SAPISID`, `LOGIN_INFO` (y normalmente también `APISID`, `__Secure-3PSID`, ...).
|
||||||
|
- Un archivo con solo `__Secure-3PSID` (export parcial de algunas extensiones) **no es una sesión**: YouTube lo trata como anónimo y la membresía sigue bloqueada.
|
||||||
|
|
||||||
|
## Cómo exportarlas (paso a paso)
|
||||||
|
|
||||||
|
1. Inicia sesión en [youtube.com](https://www.youtube.com) en tu navegador (Chrome, Brave o Firefox).
|
||||||
|
2. Instala la extensión **"Get cookies.txt LOCALLY"** (Chrome Web Store / Firefox Add-ons). La palabra LOCALLY importa: exporta en tu máquina sin mandar nada a un servidor.
|
||||||
|
3. Con youtube.com abierto, abre la extensión y exporta las cookies de **youtube.com** (formato Netscape por defecto).
|
||||||
|
4. Guarda el archivo (`cookies.txt`). Verifica que aparecen `SID`, `HSID`, `SSID`, `SAPISID` y `LOGIN_INFO`.
|
||||||
|
|
||||||
|
## Cómo importarlas
|
||||||
|
|
||||||
|
### Webapp (recomendado)
|
||||||
|
|
||||||
|
Sección **Cookies** de la UI:
|
||||||
|
|
||||||
|
- **Arrastrar y soltar** el `cookies.txt` (o selección manual). El vault lo copia a `cookies/<uuid>.txt`, analiza sesión/caducidad y lo registra con etiqueta.
|
||||||
|
- **Importar desde navegador**: lee las cookies directamente del navegador local (Brave por defecto). **El navegador debe estar cerrado por completo** — Chromium bloquea el archivo de cookies si el proceso vive, y el error que verás es "cookie store locked". Al importar así, la activación es automática.
|
||||||
|
- Si no hay ninguna activa, la primera que subas se activa sola.
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yt-scraper --cookies ruta/a/cookies.txt scrape # usar un archivo concreto
|
||||||
|
yt-scraper --cookies-from-browser brave scrape # chrome|firefox|edge|brave
|
||||||
|
```
|
||||||
|
|
||||||
|
Además, al arrancar el CLI o la webapp, cualquier `.txt` suelto en `cookies/` se adopta automáticamente al vault (`auto_import_dir`, idempotente).
|
||||||
|
|
||||||
|
## Activación
|
||||||
|
|
||||||
|
Hay **exactamente una cookie activa** a la vez: CLI, webapp y watch usan esa si no se pasa `--cookies`. En la webapp puedes cambiar la activa (botón *activate*), probarla (*test* comprueba que la sesión sigue viva) y borrar las demás.
|
||||||
|
|
||||||
|
## Rotación y caducidad
|
||||||
|
|
||||||
|
- Las cookies de login **caducan** (meses) o se invalidan si cierras sesión / cambias contraseña en ese navegador. Cuando el scrape vuelva a ver bloqueos de membresía o un chorreo de rate-limits, re-exporta y sube un archivo nuevo.
|
||||||
|
- El vault marca el estado `expired` según la fecha de expiración del propio archivo; el criterio `has_session` (SID+HSID+SSID o LOGIN_INFO) se comprueba en la importación y en el listado.
|
||||||
|
- No pasa nada por tener varias en el vault: solo la activa se usa.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
- **Son equivalentes a tu contraseña de Google para YouTube.** Quien tenga el archivo puede usar tu sesión.
|
||||||
|
- `cookies/` está en `.gitignore` — nunca las commitees, ni las pegues en un chat, ni las subas a ningún sitio.
|
||||||
|
- Si sospechas una fuga: cierra la sesión de YouTube en ese navegador (invalida las cookies) y re-exporta.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Getting Started — máquina nueva desde cero
|
||||||
|
|
||||||
|
De un equipo vacío a la webapp corriendo y el primer scrape hecho. Una sección por OS; salta a la tuya. Todo lo demás (arquitectura, CLI completo, config) está en el [README](../README.md).
|
||||||
|
|
||||||
|
Regla transversal: **todos los comandos se ejecutan desde la raíz del repo** (`yt-channel-scraper/`), porque las rutas de `config.yaml` se resuelven relativas al directorio de trabajo.
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
### 1. Python
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
winget install Python.Python.3.12
|
||||||
|
# cierra y reabre la terminal para que entre en PATH; verifica:
|
||||||
|
python --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Obtener el código
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone <URL-del-gitea> yt-channel-scraper
|
||||||
|
cd yt-channel-scraper
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Entorno + dependencias
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Opción A: uv (recomendado; respeta uv.lock)
|
||||||
|
winget install astral-sh.uv
|
||||||
|
uv sync --all-extras
|
||||||
|
|
||||||
|
# Opción B: venv + pip
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -e ".[dev,web,analysis]"
|
||||||
|
```
|
||||||
|
|
||||||
|
Extras: `dev` (tests), `web` (webapp), `analysis` (gráficos). Puedes instalar solo los que uses.
|
||||||
|
|
||||||
|
### 4. ffmpeg y node (opcionales)
|
||||||
|
|
||||||
|
- **ffmpeg**: solo necesario para `yt-scraper audio` / job de audio → `winget install Gyan.FFmpeg`.
|
||||||
|
- **node**: yt-dlp lo usa para resolver challenges de YouTube → `winget install OpenJS.NodeJS.LTS`.
|
||||||
|
|
||||||
|
Reabre la terminal tras instalar y verifica con `ffmpeg -version` / `node -v`.
|
||||||
|
|
||||||
|
### 5. Configuración
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
copy config.example.yaml config.yaml
|
||||||
|
notepad config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Cambia al menos `channel_url` (URL `/videos` de tu canal). Referencia de todas las claves: [CONFIG.md](CONFIG.md).
|
||||||
|
|
||||||
|
### 6. Cookies (recomendado)
|
||||||
|
|
||||||
|
Sin cookies funciona, pero con sesión de YouTube reduce los bot-checks y desbloquea vídeos de membresía. Resumen: exporta `cookies.txt` desde tu navegador con la extensión "Get cookies.txt LOCALLY" (logueado a youtube.com) y arrástralo a la webapp, o usa `--cookies`. Guía completa: [COOKIES.md](COOKIES.md).
|
||||||
|
|
||||||
|
### 7. Primer scrape (sin descargar nada)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.venv\Scripts\activate # si usaste venv; con uv: uv run yt-scraper ...
|
||||||
|
yt-scraper scrape --dry-run --limit 10
|
||||||
|
yt-scraper scrape --limit 3 # primeras 3 notas en data/markdown/<Canal>/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Arrancar la webapp
|
||||||
|
|
||||||
|
Doble clic en `start-server.bat`. La primera vez pasa por `scripts/doctor.ps1`, que instala lo que falte (Python 3.12 vía winget + `pip install -e ".[web]"`) y delega en `scripts/start-server.ps1`: busca puerto libre (8000–8100), arranca uvicorn, registra el PID en `.run/server.info`, comprueba `/healthz` y abre el navegador. Para parar: `stop-server.bat`.
|
||||||
|
|
||||||
|
Manual (debug): `python -m uvicorn yt_scraper.webapp.app:app --host 127.0.0.1 --port 8000`.
|
||||||
|
|
||||||
|
## macOS (Apple Silicon)
|
||||||
|
|
||||||
|
### 1. Homebrew + Python
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
brew install [email protected] ffmpeg node
|
||||||
|
```
|
||||||
|
|
||||||
|
Todas las dependencias Python publican wheels arm64; no hay compilación.
|
||||||
|
|
||||||
|
### 2-3. Código y entorno
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <URL-del-gitea> yt-channel-scraper
|
||||||
|
cd yt-channel-scraper
|
||||||
|
|
||||||
|
# Opción A: uv
|
||||||
|
brew install astral-sh.uv && uv sync --all-extras
|
||||||
|
|
||||||
|
# Opción B: venv + pip
|
||||||
|
python3.12 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -e ".[dev,web,analysis]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. ffmpeg / node
|
||||||
|
|
||||||
|
Ya instalados en el paso 1 (si los saltaste: `brew install ffmpeg node`).
|
||||||
|
|
||||||
|
### 5-6. Config y cookies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
|
$EDITOR config.yaml # cambia channel_url
|
||||||
|
```
|
||||||
|
|
||||||
|
Cookies: [COOKIES.md](COOKIES.md).
|
||||||
|
|
||||||
|
### 7. Primer scrape
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yt-scraper scrape --dry-run --limit 10
|
||||||
|
yt-scraper scrape --limit 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Arrancar la webapp
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make setup # primera vez: scripts/bootstrap.sh prepara todo lo que falte
|
||||||
|
make serve # arranca uvicorn y abre el navegador
|
||||||
|
make stop # para el servidor
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalente sin Makefile: `scripts/bootstrap.sh && scripts/start-server.sh` / `scripts/stop-server.sh`.
|
||||||
|
|
||||||
|
## Linux (Debian/Ubuntu)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y python3 python3-venv python3-pip git ffmpeg nodejs # nodejs si el repo lo empaqueta; si no, usa nodesource
|
||||||
|
```
|
||||||
|
|
||||||
|
El resto es idéntico a macOS: `git clone` → `uv sync --all-extras` (o venv + `pip install -e ".[dev,web,analysis]"`) → `cp config.example.yaml config.yaml` → `yt-scraper scrape --dry-run` → `make serve`. En distros sin `make`: `scripts/bootstrap.sh` + `scripts/start-server.sh`.
|
||||||
|
|
||||||
|
Si tu distro trae Python < 3.10, instala uno moderno (pyenv, deadsnakes PPA en Ubuntu, o `brew` en Linux) — el paquete exige `>=3.10`.
|
||||||
|
|
||||||
|
## Verificación final
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m pytest -q # debe pasar la suite completa (~240 tests, sin red)
|
||||||
|
yt-scraper channels list # tras el primer scrape: tu canal con sus vídeos
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Síntoma | Causa | Arreglo |
|
||||||
|
|---|---|---|
|
||||||
|
| `ModuleNotFoundError: yt_scraper` tras mover/renombrar el repo | La instalación editable apunta a la ruta vieja | `pip install -e ".[dev,web,analysis]"` otra vez (o recrea el venv: `uv sync --all-extras`) |
|
||||||
|
| `yt-scraper: command not found` | Venv sin activar, o instalaste sin `-e` | Activa el venv, o usa `uv run yt-scraper ...`, o `python -m yt_scraper.cli` |
|
||||||
|
| Los paths apuntan a sitios raros (`data/` vacío en otro lado) | Comandos ejecutados fuera de la raíz del repo | `cd` a la raíz; las rutas son relativas al CWD |
|
||||||
|
| `file cannot be loaded because running scripts is disabled` al lanzar un `.ps1` | PowerShell ExecutionPolicy restringido | `powershell -ExecutionPolicy Bypass -File scripts\start-server.ps1`, o `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` |
|
||||||
|
| "puerto ocupado" / el navegador abre otra app | Resto de un servidor anterior en el puerto | Borra `.run/server.info` (estado obsoleto) o usa `stop-server.bat` / `make stop`; el arranque busca puerto libre 8000–8100 |
|
||||||
|
| `uv: command not found` o trampolines rotos tras actualizar uv/Python | Los shims del venv quedaron inconsistentes | Ejecuta vía módulo: `uv run python -m pytest -q`, `python -m yt_scraper.cli` |
|
||||||
|
| Tests de red fallan / scrape con muchos `error` seguidos | Rate-limit de YouTube (sin cookies es más frecuente) | Espera; revisa `delay` en [CONFIG.md](CONFIG.md); añade cookies ([COOKIES.md](COOKIES.md)) |
|
||||||
|
| `ffmpeg no encontrado` en `yt-scraper audio` | ffmpeg no está en PATH | `winget install Gyan.FFmpeg` / `brew install ffmpeg` / `apt install ffmpeg`, y reabre la terminal |
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Auditoría comparativa · Obsidian Web Clipper 1.7.1 vs `yt-channel-scraper`
|
||||||
|
|
||||||
|
> **Producto auditado:** *Obsidian Web Clipper* 1.7.1 (extensión MV3) en `D:\Obsidian Web Clipper - Chrome Web Store 1.7.1.0`.
|
||||||
|
> **Sistema actual:** `yt-channel-scraper` (yt-dlp como única interfaz con YouTube; techo práctico ~300 videos/h).
|
||||||
|
> **Alcance:** solo auditoría, comparación y análisis de mejoras. Sin cambios de código.
|
||||||
|
> **Base:** verificación directa del bundle `popup.js` (offsets 380236–393083, clase `YoutubeExtractor`) además del doc previo `YOUTUBE-TRANSCRIPT-AUDIT.md`.
|
||||||
|
> **Fecha:** 2026-09-01.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0 · TL;DR
|
||||||
|
|
||||||
|
La extensión resuelve el mismo problema (metadatos + transcripción de YouTube sin API oficial) con una filosofía de red **opuesta y complementaria** a la del scraper:
|
||||||
|
|
||||||
|
| | Scraper (hoy) | Extensión |
|
||||||
|
|---|---|---|
|
||||||
|
| Filosofía ante el fallo | **Backoff temporal**: esperar y reintentar más tarde (Pacer + ThrottleGuard, abort limpio) | **Rotación de identidad**: cambiar de cliente/recurso y degradar, casi nunca esperar |
|
||||||
|
| Requests por video | 3 (watch + player + timedtext), medidos irreducibles vía yt-dlp (`config.yaml:21-33`) | 1–2 (player InnerTube + timedtext); el 3º (`next`) solo si faltan capítulos |
|
||||||
|
| Identidad | 1 cookie activa, cliente yt-dlp default, sin rotación (`cookies.py:151-163`) | 3 clientes InnerTube en cascada (IOS → ANDROID+UA → WEB), **sin cookies** |
|
||||||
|
| Retry del mismo recurso | Nunca (deliberado, `ratelimit.py:18-22`) | Nunca tampoco: 1 intento por cliente, error silenciado, siguiente |
|
||||||
|
| Timeout | 15–30 s (`_yt_http.py:40`, `config.yaml:55`) | **4 s** por intento (`AbortSignal.timeout(4e3)`) |
|
||||||
|
| Criterio de éxito | HTTP status (429/403 → clasificar y abortar) | **Contenido**: `r.ok && captionTracks.length > 0` — un 200 vacío se trata como fallo y se rota |
|
||||||
|
| Degradación del resultado | `skip_reason` con taxonomía de causas | Siempre entrega nota con metadatos; transcripción es opcional |
|
||||||
|
|
||||||
|
Los insights accionables para el scraper están en §2, priorizados en §3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · Qué hace la extensión (verificado en el bundle)
|
||||||
|
|
||||||
|
Cadena de extracción de `YoutubeExtractor.extractAsync()` (offset ~380236 de `popup.js`):
|
||||||
|
|
||||||
|
1. **DOM existente** (costo 0): segmentos ya renderizados en la página.
|
||||||
|
2. **Ruta de red principal** `fetchTranscript()`:
|
||||||
|
- `fetchChapters(videoId)` se **dispara sin await** (la promesa se resuelve en paralelo).
|
||||||
|
- Track inline desde `ytInitialPlayerResponse` del DOM (costo 0), validando que `videoDetails.videoId` coincida con el de la URL (`getValidatedPlayerResponse`).
|
||||||
|
- Si no hay inline: `fetchPlayerData(videoId)` → **POST a `youtubei/v1/player?prettyPrint=false`** con cascada:
|
||||||
|
1. `{clientName:"IOS", clientVersion:"20.10.3"}` — sin UA especial.
|
||||||
|
2. `{clientName:"ANDROID", clientVersion:"20.10.38"}` + `User-Agent: com.google.android.youtube/20.10.38 (Linux; U; Android 14)`.
|
||||||
|
3. `{clientName:"WEB", clientVersion:"2.20240101.00.00"}`.
|
||||||
|
4. Fallback final: JSON embebido del DOM.
|
||||||
|
- Cada intento: timeout 4 s, `try{}catch{}` silenciado, y **se acepta solo si `captionTracks.length > 0`**.
|
||||||
|
- Descarga del track: `GET track.baseUrl` con guard de host (`new URL(baseUrl).hostname.endsWith(".youtube.com")`), UA `Mozilla/5.0`, `Accept-Language` si hay idioma preferido, timeout 4 s.
|
||||||
|
3. **Apertura programática del panel** de transcripción (click + polling `pollFor` cada 250 ms, máx 20 intentos) como último recurso.
|
||||||
|
|
||||||
|
Capítulos (`fetchChapters`): primero inline desde `ytInitialData` (`playerOverlays…multiMarkersPlayerBarRenderer.markersMap`); si vacío, POST a `youtubei/v1/next` con cliente WEB; segundo fallback `engagementPanels[*].macroMarkersListItemRenderer`.
|
||||||
|
|
||||||
|
Puntos de red relevantes:
|
||||||
|
|
||||||
|
- Los POST a InnerTube **no llevan cookies** (el fetch de la popup corre en contexto de extensión, `credentials` same-origin ⇒ youtube.com no recibe sesión). La ruta IOS/ANDROID funciona **anónima**.
|
||||||
|
- El `Origin: https://www.youtube.com` / `Referer` los fuerza la regla DNR 9002 porque un browser no puede setear `Origin` — en Python sería simplemente otro header.
|
||||||
|
- `BilibiliExtractor` (mismo bundle) sí cachea transcripciones: LRU `Map` con tope 300 entradas. `YoutubeExtractor` no cachea nada.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · Insights accionables para el scraper
|
||||||
|
|
||||||
|
### I1 · Ruta InnerTube propia como *modo degradado* (impacto alto, esfuerzo medio)
|
||||||
|
|
||||||
|
**Evidencia:** la extensión obtiene metadatos completos + `captionTracks` con **un solo POST anónimo** a `youtubei/v1/player` con cliente IOS. `videoDetails` da título, autor, channelId, lengthSeconds, viewCount, keywords; `microformat` da publishDate, description, ownerChannelName. Nada de watch page.
|
||||||
|
|
||||||
|
**Aplicación:** hoy, cuando el ThrottleGuard trip (`ratelimit.py:221-283`), el job aborta limpio y todo queda `pending` hasta el siguiente pase del monitor. Una vía de salvage — POST directo a `player` (1 petición/video en vez de 3) para lo estrictamente necesario (transcripción + metadatos básicos) — permitiría **seguir produciendo a ⅓ del costo** durante los periodos en que la ruta completa (watch page incluida) está bloqueada. `_yt_http.py` ya es el lugar natural para ese cliente.
|
||||||
|
|
||||||
|
**Advertencias:**
|
||||||
|
- `config.yaml:28-32` dice "3 requests irreducibles — no re-litigar". Esa medición fue sobre **yt-dlp restringido** (`player_skip=webpage`, single-client), que pierde pistas. La ruta de la extensión es distinta: una llamada InnerTube propia con aceptación por contenido. No la invalida, pero **habría que medirla** antes de tratarla como reemplazo; como modo degradado opcional el riesgo es acotado.
|
||||||
|
- Las versiones de cliente hardcodeadas de la extensión (20.10.3 / 20.10.38 / 2.20240101) tienen más de un año de rotación. Si se implementa, tomar las versiones vigentes de yt-dlp (que ya las mantiene) en vez de hardcodear, o aceptar el mismo mantenimiento que la extensión.
|
||||||
|
- YouTube exige PO tokens en algunos clientes para formats/streaming; para metadatos/captions la ruta IOS/ANDROID ha seguido funcionando sin ellos (es la evidencia de esta extensión), pero es el punto que puede romperse.
|
||||||
|
|
||||||
|
### I2 · Aceptación por contenido, no por status (impacto medio, esfuerzo bajo)
|
||||||
|
|
||||||
|
La extensión trata "HTTP 200 con respuesta inútil" como fallo y rota. El scraper ya clasifica causas (`skip_reason`, `describe_missing_subtitle`), pero la aceptación es binaria por status. Aplicable a: timedtext que devuelve 200 con cuerpo vacío/corrupto (hoy parsearía vacío y se marcaría "parsed empty" en vez de reintentable), y a cualquier futura llamada InnerTube propia.
|
||||||
|
|
||||||
|
### I3 · Timeout corto con fail-fast en timedtext (impacto medio, esfuerzo bajo)
|
||||||
|
|
||||||
|
`extract.py:301-321` baja subtítulos con timeout 15 s; `config.yaml:55` pone 30 s de socket. El punto donde el throttling "más aparece" es precisamente timedtext (`extract.py:302-307`). Un timeout más agresivo (configurable, p. ej. 6–8 s para json3/srv1 — payloads pequeños) convertiría cuelgues de 15–30 s en un fallo clasificable como reintentable casi inmediato, liberando el pacer antes. La extensión usa 4 s para todo.
|
||||||
|
|
||||||
|
### I4 · Rotación de cookie al hacer trip el ThrottleGuard (impacto alto, esfuerzo medio)
|
||||||
|
|
||||||
|
La extensión no rota cookies (viaja sobre la sesión real del usuario), pero su patrón estructural — *ante el bloqueo, cambiar de identidad en vez de solo esperar* — traducido al scraper es: el vault ya persiste múltiples cookies (`cookies/<uuid>.txt` + `cookies_meta`), pero `resolve_active_path` usa exactamente una (`cookies.py:151-163`). Al trip del breaker, cambiar a la siguiente cookie no expirada antes de rendirse al reloj multiplicaría el presupuesto efectivo por sesión sin nueva infraestructura. (Insight inspirado en el patrón de la extensión, no copiado de ella.)
|
||||||
|
|
||||||
|
### I5 · `youtubei/v1/next` para capítulos sin watch page (habilitador de I1)
|
||||||
|
|
||||||
|
Si algún día se activa la ruta de 1 petición (I1), los capítulos —que hoy llegan vía info de yt-dlp desde la watch page (`chapters.py:39-49`)— se recuperan con un POST a `next` (cliente WEB) parseando `playerOverlays…markersMap`, con fallback a `engagementPanels`. El bundle de la extensión contiene la implementación de referencia exacta (offset ~393083). Solo necesario para videos con capítulos; el resto no paga el request.
|
||||||
|
|
||||||
|
### I6 · Guard de host antes de descargar timedtext (hardening, esfuerzo mínimo)
|
||||||
|
|
||||||
|
`fetchCaptionXml` exige `hostname.endsWith(".youtube.com")` antes del GET al `baseUrl` del caption track. El scraper baja `pick.url` con `yt_get` sin validar host (`extract.py:301-321`). La URL viene de yt-dlp (confiable hoy), pero una línea de validación cierra la clase de riesgo "baseUrl corrupto/inyectado ⇒ GET con headers de navegador a un host arbitrario".
|
||||||
|
|
||||||
|
### I7 · Detalles menores de protocolo (gratis si se implementa I1)
|
||||||
|
|
||||||
|
- `?prettyPrint=false` en llamadas InnerTube: menos payload.
|
||||||
|
- `Origin: https://www.youtube.com` como header explícito en POSTs propios.
|
||||||
|
- Lanzar la descarga dependiente como promesa paralela (la extensencia dispara `fetchChapters` sin await): en el scraper el equivalente sería solapar la descarga de timedtext con el siguiente video del pacer **solo si** el presupuesto de requests ya lo contempla — cuidado: hoy el pacer es la política de cortesía, no paralelizar contra él.
|
||||||
|
|
||||||
|
### I8 · Paridades confirmadas (sin acción)
|
||||||
|
|
||||||
|
- Selección de pista por idioma: `pick_subtitle` del scraper (política por idioma, rechazo de `tlang=`, detección `-orig`, prioridad json3) es **más rica** que `pickCaptionTrack`/`findPreferredCaptionTrack` de la extensión.
|
||||||
|
- Degradación graciosa del resultado: taxonomía `skip_reason` ≥ "nota siempre con metadatos" de la extensión.
|
||||||
|
- Caching: SQLite del scraper > LRU 300 del BilibiliExtractor; YoutubeExtractor ni siquiera cachea.
|
||||||
|
- Validación de JSON inline contra videoId (`getValidatedPlayerResponse`): patrón correcto a recordar **si** algún día se cachean player responses o se reutilizan continuations entre sesiones (evita atribuir a un video la respuesta de otro).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 · Priorización sugerida
|
||||||
|
|
||||||
|
| # | Mejora | Contra qué límite ayuda | Esfuerzo | Riesgo |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | I3 timeout fail-fast en timedtext | Throughput bajo throttling | Bajo | Bajo (hacerlo configurable) |
|
||||||
|
| 2 | I6 guard de host | Hardening | Mínimo | Nulo |
|
||||||
|
| 3 | I4 rotación de cookies al trip del breaker | Techo de presupuesto por sesión | Medio | Medio (cuenta de la cookie expuesta al mismo ritmo) |
|
||||||
|
| 4 | I1+I2+I5+I7 ruta InnerTube propia como modo degradado | Bot wall: seguir produciendo a ⅓ de costo | Medio-Alto | Medio (requiere medición; mantenimiento de client versions) |
|
||||||
|
|
||||||
|
## 4 · Qué NO copiar de la extensión
|
||||||
|
|
||||||
|
1. **Scraping del DOM del panel de transcripción** (clicks + polling): requiere un browser real con fingerprint real; el scraper es headless. Solo cobraría sentido con Playwright + perfil real, que es otra conversación.
|
||||||
|
2. **Re-litigar los 3 requests/video vía yt-dlp** (`config.yaml:28-32`): la medición del repo sigue en pie para yt-dlp. La vía InnerTube propia es una **ruta paralela degradada**, no un reemplazo de la ruta completa.
|
||||||
|
3. **Silenciado de errores** (`try{}catch{}` sin logging): la extensión degradea muda; el scraper necesita auditabilidad (`skip_reason` ya la da).
|
||||||
|
4. **Versiones de cliente hardcodeadas**: la extensión las parchea por release; el scraper ya delega eso en yt-dlp. Cualquier ruta propia debe heredar las versiones de yt-dlp, no duplicarlas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Fin del informe.*
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Auditorías
|
||||||
|
|
||||||
|
Documentos de investigación que explican decisiones técnicas del proyecto mediante ingeniería inversa. No son specs ni manuales: son material de referencia.
|
||||||
|
|
||||||
|
- **`YOUTUBE-TRANSCRIPT-AUDIT.md`** — ingeniería inversa de la extensión *Obsidian Web Clipper* 1.7.1: cómo obtiene metadatos y transcripciones vía la API privada InnerTube (`youtubei/v1/player`) con clientes ANDROID/IOS/WEB. Es el contexto de *por qué* este scraper delega en `yt-dlp` (que implementa el mismo mecanismo) en vez de llamar a InnerTube directamente.
|
||||||
|
- **`CLIPPER-COMPARISON-AUDIT.md`** — comparativa extensión vs `yt-channel-scraper`: filosofías de red opuestas (una petición por vídeo pinchado vs batch educado de canal) y qué ideas de una aplican a la otra.
|
||||||
|
|
||||||
|
Ambos provienen de la raíz del repo y se mantienen sin edición aquí.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Backlog
|
||||||
|
|
||||||
|
Destilado del histórico `OPPORTUNITIES.md`: qué sigue siendo candidato a construir y qué ya existe. Criterio de inclusión en "pendiente": no implementado hoy en el repo.
|
||||||
|
|
||||||
|
## Ya está hecho (no re-abrir)
|
||||||
|
|
||||||
|
| Feature (ítem original) | Dónde vive |
|
||||||
|
|---|---|
|
||||||
|
| `search` — búsqueda FTS5 en transcripciones | `yt-scraper search "..."` + webapp |
|
||||||
|
| `export` json/csv/srt/html | `yt-scraper export --format ...` → `data/exports/` |
|
||||||
|
| `audio` — descarga MP3 | `yt-scraper audio` + job de audio de la webapp (ffmpeg) |
|
||||||
|
| `re-render` + `--backfill` | `yt-scraper re-render` |
|
||||||
|
| Multi-canal | `yt-scraper channels add/list/remove`, `--all-channels` |
|
||||||
|
| `watch` — monitoreo periódico | `yt-scraper watch --interval 6h` |
|
||||||
|
| Análisis (top-words, wordcloud, timeline) | `yt-scraper analyze` → `data/analysis/` |
|
||||||
|
| Miniaturas | `data/thumbnails/` + herramienta *Download thumbnails* de la webapp |
|
||||||
|
| Vista grid de vídeos | Webapp (grid de tarjetas con miniatura) |
|
||||||
|
| Cookies (vault, import, activación) | Webapp + flags `--cookies` / `--cookies-from-browser` — ver [COOKIES.md](COOKIES.md) |
|
||||||
|
| Sync incremental de canales | `sync:` en config; discovery de ventana con overlap (no estaba en el original) |
|
||||||
|
|
||||||
|
## Pendiente
|
||||||
|
|
||||||
|
| Feature | Por qué (1 línea) | Esfuerzo |
|
||||||
|
|---|---|---|
|
||||||
|
| `clip` — texto entre dos timestamps (`yt-scraper clip <id> --from 01:38 --to 03:20`) | Citar fragmentos sin abrir el `.md`; los segmentos ya están en la BD | ~30 min |
|
||||||
|
| `stats` como subcomando | La webapp tiene dashboard y el CLI imprime un resumen tras scrape, pero no existe `yt-scraper stats` con rango/wordcount/tags top | ~1 h |
|
||||||
|
| Traducción de transcripciones (es→en o inversa) | Bibliotecas bilingües y notas para no hispanohablantes; hoy el picker rechaza traducciones por diseño (guarda el transcript real) | 3-4 h vía `deep-translator`; más si se quiere calidad |
|
||||||
|
| Alertas al terminar un job (notificación de escritorio) | Jobs largos sin vigilancia; hoy solo SSE en la pestaña abierta | 2-3 h |
|
||||||
|
| Export a formatos de note-taking (Obsidian Canvas, Anki) | El contenido ya está segmentado por capítulos; solo cambia la salida | 3-4 h |
|
||||||
|
|
||||||
|
## Fuera de alcance (por decisión de plataforma)
|
||||||
|
|
||||||
|
El spec (`docs/superpowers/specs/2026-07-26-platform-design.md` §14) excluye explícitamente features de IA. Reabrirlas exige cambiar el spec primero, no solo escribir código:
|
||||||
|
|
||||||
|
| Feature (ítem original) | Motivo de la exclusión |
|
||||||
|
|---|---|
|
||||||
|
| LLM summaries (`--summarize`) | Requiere API key / modelo local; rompe el "100% local, sin auth" |
|
||||||
|
| Q&A / RAG (`yt-scraper ask`) | Ídem; FTS5 cubre la parte recuperativa sin LLM |
|
||||||
|
| NER / extracción de entidades | Dependencia pesada (spaCy) y caso de uso cubierto por search + top-words |
|
||||||
|
| Speaker diarization | Complejidad alta (audio models) para ganancia marginal en canales monohablante |
|
||||||
Reference in New Issue
Block a user