- 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
123 lines
11 KiB
Markdown
123 lines
11 KiB
Markdown
# 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.*
|