La casa de moneda: acuña el PO Token que YouTube empezó a exigir para entregar audio y video. Un binario, sin instalar nada, sin tocar las cuentas de nadie.
Desde 2026 YouTube exige un PO Token (proof-of-origin) en todos sus clientes de
reproducción. Sin él, la extracción funciona —título, duración y calidades salen bien— pero
bajar el medio devuelve HTTP Error 403: Forbidden. Medido acá contra yt-dlp 2026.7.4:
falla con todos los clientes (android_vr, web, web_safari, ios, mweb,
tv_simply), con y sin runtime JS, y con y sin el solver de challenges. No es un problema
de versión: 2026.7.4 era la última publicada.
La alternativa habitual es pasarle a yt-dlp las cookies del navegador. Esto existe para no hacer eso: en un equipo donde la app la usan varias personas, leerles el navegador no es una opción.
sesión anónima nueva -> visitor_data
desafío de BotGuard -> intérprete JS -> integrity token -> PO Token
Sale una línea de JSON por stdout:
{ "visitorData": "CgtLNVMz…", "poToken": "MtYEKUO4B0oQ…" }Los dos viajan juntos y eso no es comodidad: el token que destraba las URLs del servidor
de video (gvs) se ata al visitor_data de la sesión. Usarlo con otro da un 403 idéntico al
de no tener token.
eval "$(ceca | python -c 'import sys,json; d=json.load(sys.stdin); print("VD=%s; PT=%s" % (d["visitorData"], d["poToken"]))')"
yt-dlp --extractor-args "youtube:player_client=tv_simply;po_token=tv_simply.gvs+$PT;visitor_data=$VD" \
-f bestaudio "https://www.youtube.com/watch?v=…"tv_simply no es decorativo. Con el mismo token, web devuelve solo miniaturas, y mweb
e ios siguen dando 403. Es el único cliente que se verificó bajando de punta a punta.
Cuatro cosas que costaron tiempo y cuyo error no nombra su causa:
| Síntoma | Causa |
|---|---|
400 … JSPB Fava message don't accept top-level braces |
getHeaders() de bgutils-js manda content-type en minúscula. Pisarlo con Content-Type no reemplaza: viajan las dos y YouTube lee application/json+protobuf, application/json |
| Token válido que igual da 403 | Se ató al id del video (que es lo intuitivo) en vez de al visitor_data. El de id sirve para otro contexto |
Only images are available for download |
El cliente web necesita más que el token gvs. Usar tv_simply |
Not implemented: HTMLCanvasElement's getContext() |
Es un aviso, no un error: canvas no hace falta. Se verificó bajando video sin él, y evita un módulo nativo que hay que compilar por plataforma |
deno task test # sin red
deno task mint # acuña uno de verdad
deno task compile # dist/ceca, un ejecutable autocontenidoEl binario compilado pesa ~110 MB (Deno mete su runtime adentro) y no necesita Node, npm ni Deno instalados en la máquina donde corre.
Funciona sin tocar nada: Deno respeta HTTP_PROXY y HTTPS_PROXY del entorno, y un
proceso lanzado por otra aplicación hereda esas variables.
Verificado con un control, que es la única forma de saberlo: apuntando HTTPS_PROXY a un
puerto muerto la corrida falla con fetch failed, y sin esa variable la misma corrida
acuña normalmente. Si diera lo mismo en los dos casos, el proxy se estaría ignorando.
Cada pedido se corta a los 20 s, y ninguno sigue después de los 60 s de empezada la corrida. Los dos quedan por debajo de los 120 s que suele esperar quien lo llama. Así el motivo llega antes de que el otro se canse. El error dice en qué paso se colgó y cuál límite lo cortó:
att/get (desafio de BotGuard): sin respuesta en 20000 ms (CECA_REQUEST_TIMEOUT_MS)
GenerateIT (integrity token): se agoto el techo total de 60000 ms (CECA_TIMEOUT_MS)
| Variable | Qué limita | Por defecto |
|---|---|---|
CECA_REQUEST_TIMEOUT_MS |
cada pedido, incluida la lectura de la respuesta | 20000 |
CECA_TIMEOUT_MS |
la corrida entera: cada pedido recibe como mucho lo que queda | 60000 |
Tienen que ser enteros positivos. Cualquier otro valor corta la corrida de entrada, con un error que nombra la variable. Las dos se leen antes de soltar el entorno (ver Seguridad).
Este programa ejecuta JavaScript ofuscado que baja de YouTube. Es inevitable: el desafío de BotGuard es ese programa, y resolverlo es todo el punto. Por eso vive en un proceso aparte en vez de adentro de la app que lo usa, y por eso corre con los permisos de Deno acotados:
- Red: solo
www.youtube.com,www.google.com(de ahí baja el intérprete) yjnn-pa.googleapis.com(donde se canjea el integrity token). Cualquier otro host daNotCapable, también para unimport()dinámico:deno runpermite importar deesm.sh,jsr.io,deno.landy otros sin flag, así que ese permiso también se revoca antes de acuñar (ver el punto siguiente). - Disco y entorno: se sueltan antes de acuñar.
dropLocalAccess()revoca lectura, escritura, entorno, sistema, procesos, FFI e importaciones remotas, y revocar no tiene vuelta atrás: el JS de YouTube recibeNotCapablesi intenta leer un archivo o una variable. Los flags no alcanzan solos porque la carga de las dependencias los necesita:debugenumera todas las variables de entorno al cargarse (de ahí--allow-envsin lista), y desde el código fuente jsdom lee su hoja de estilos del caché de npm, en una ruta que depende deDENO_DIR(de ahí--allow-readendeno task mint). El binario compilado no lleva--allow-read: jsdom la lee de los archivos que van adentro del ejecutable. --no-prompt: sin él, un permiso revocado que ese código pidiera se convertiría en una pregunta en la terminal.
Quien lance src/main.ts con sus propios flags igual pierde disco y entorno antes de acuñar,
pero la lista de hosts depende de los flags: hay que copiar los de deno task mint.
No usa cuentas, cookies ni credenciales de nadie. La sesión es anónima y nueva en cada corrida.
El trabajo pesado —la reimplementación del cliente de BotGuard— es de
bgutils-js (MIT), y el visitor_data sale de
youtubei.js (MIT). Acá solo está el pegamento.
Existe bgutil-ytdlp-pot-provider, que hace esto y bastante más (sesiones múltiples, proxies, plugin de yt-dlp). Este repo no existe porque aquel esté mal: existe porque es GPL-3.0, y para poder distribuirlo dentro de una aplicación propia hacía falta algo MIT.