- 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
11 KiB
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 bundlepopup.js(offsets 380236–393083, claseYoutubeExtractor) además del doc previoYOUTUBE-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):
- DOM existente (costo 0): segmentos ya renderizados en la página.
- Ruta de red principal
fetchTranscript():fetchChapters(videoId)se dispara sin await (la promesa se resuelve en paralelo).- Track inline desde
ytInitialPlayerResponsedel DOM (costo 0), validando quevideoDetails.videoIdcoincida con el de la URL (getValidatedPlayerResponse). - Si no hay inline:
fetchPlayerData(videoId)→ POST ayoutubei/v1/player?prettyPrint=falsecon cascada:{clientName:"IOS", clientVersion:"20.10.3"}— sin UA especial.{clientName:"ANDROID", clientVersion:"20.10.38"}+User-Agent: com.google.android.youtube/20.10.38 (Linux; U; Android 14).{clientName:"WEB", clientVersion:"2.20240101.00.00"}.- Fallback final: JSON embebido del DOM.
- Cada intento: timeout 4 s,
try{}catch{}silenciado, y se acepta solo sicaptionTracks.length > 0. - Descarga del track:
GET track.baseUrlcon guard de host (new URL(baseUrl).hostname.endsWith(".youtube.com")), UAMozilla/5.0,Accept-Languagesi hay idioma preferido, timeout 4 s.
- Apertura programática del panel de transcripción (click + polling
pollForcada 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,
credentialssame-origin ⇒ youtube.com no recibe sesión). La ruta IOS/ANDROID funciona anónima. - El
Origin: https://www.youtube.com/Refererlos fuerza la regla DNR 9002 porque un browser no puede setearOrigin— en Python sería simplemente otro header. BilibiliExtractor(mismo bundle) sí cachea transcripciones: LRUMapcon tope 300 entradas.YoutubeExtractorno 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-32dice "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=falseen llamadas InnerTube: menos payload.Origin: https://www.youtube.comcomo header explícito en POSTs propios.- Lanzar la descarga dependiente como promesa paralela (la extensencia dispara
fetchChapterssin 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_subtitledel scraper (política por idioma, rechazo detlang=, detección-orig, prioridad json3) es más rica quepickCaptionTrack/findPreferredCaptionTrackde 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
- 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.
- 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. - Silenciado de errores (
try{}catch{}sin logging): la extensión degradea muda; el scraper necesita auditabilidad (skip_reasonya la da). - 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.