ytdlp.online
Inicio Tampermonkey

Todas las soluciones

En esta página

yt-dlp y Douyu: corregir PhantomJS not found y los errores de ID de sala

Fecha de publicación

Descargar complemento de corrección douyu_signing.py

Si al descargar videos de Douyu con yt-dlp aparece PhantomJS not found, o al grabar una transmisión recibes Unable to extract room id, puedes cargar el plugin de compatibilidad de este artículo. Corrige tres problemas: la extracción del ID de sala en las páginas nuevas, la ejecución de la firma con Deno y la construcción de la URL de la transmisión.

Si prefieres evitar la configuración local de Deno y del directorio de plugins, pega el enlace de Douyu en el descargador de la página principal y utiliza la herramienta online.

Al investigar los fallos de las transmisiones y los videos bajo demanda de Douyu, encontramos tres obstáculos consecutivos. La nueva página utiliza bloques de datos de Next.js, por lo que la expresión regular ya no extrae el ID de sala. Tras corregirlo, el extractor original sigue llamando directamente al obsoleto PhantomJS para calcular la firma. Y cuando la firma funciona y aparecen los formatos disponibles, la URL FLV resultante pierde el directorio /live/. Primero veremos cómo aplicar la solución y después explicaremos cada causa.

Síntomas y etapa en la que falla la extracción

Error o síntoma Dónde se produce el fallo
Unable to extract room id La nueva página de la sala utiliza el flujo de datos de Next.js y ya no coincide con el patrón antiguo de $ROOM.room_id
PhantomJS not found El archivo original douyutv.py llama directamente a PhantomJSwrapper; instalar Deno en el sistema no cambia esa llamada
Los formatos aparecen (-F funciona), pero la descarga termina con un error La API devuelve rtmp_url sin barra final y urljoin elimina el directorio /live/ al construir la URL
The channel is not currently live La sala no está emitiendo; es una comprobación normal del estado
The channel is auto-playing VODs La sala está reproduciendo videos bajo demanda en bucle (videoLoop: 1); el plugin detiene la extracción

Pasos para aplicar la solución

1. Prepara el entorno y el archivo del plugin

Asegúrate de que yt-dlp, deno y ffmpeg estén instalados y disponibles desde la terminal. Nuestras pruebas se realizaron con yt-dlp nightly 2026.10.29.232701, Deno 2.9.8 y FFmpeg 7.1.5. El plugin depende del método _search_nextjs_v13_data de yt-dlp; actualiza yt-dlp si tu versión es demasiado antigua.

Crea esta estructura de carpetas en tu directorio de trabajo y guarda el código completo del plugin como douyu_signing.py:

plugins/
└── douyu/
    └── yt_dlp_plugins/
        └── extractor/
            └── douyu_signing.py

Ejecuta los comandos siguientes desde el directorio que contiene la carpeta plugins. La opción --plugin-dirs ./plugins carga el plugin sin modificar el código fuente de la instalación de yt-dlp.

2. Comprueba la lista de formatos de la transmisión

Este ejemplo utiliza una sala pública. Sustituye la URL por la de la sala que quieras comprobar:

yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -v -F "https://www.douyu.com/pigff"

La etiqueta [DouyuTV+DenoSigning] en el registro confirma que el plugin está activo. Si la sala está emitiendo, se solicitará la API getH5Play y aparecerán los formatos de las distintas calidades. Si recibes The channel is not currently live, la sala no está emitiendo en ese momento; prueba con otra que esté en directo.

3. Graba 30 segundos para comprobar el flujo multimedia

Obtener la lista de formatos solo demuestra que la API que recibe la firma ha devuelto error: 0. Para confirmar que la URL de la CDN está bien construida, también hay que descargar datos de la transmisión:

yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir --downloader ffmpeg --downloader-args "ffmpeg_i:-rw_timeout 15000000" --downloader-args "ffmpeg_o:-t 30" -f worst -o "douyu-live.%(ext)s" "https://www.douyu.com/pigff"

La opción -f worst selecciona la tasa de bits más baja para reducir el tamaño del archivo de prueba. -t 30 limita la grabación a 30 segundos, y el tiempo de espera para las operaciones de lectura y escritura de red es de 15 segundos.

4. Comprueba los videos bajo demanda y los archivos (opcional)

El mismo plugin también se aplica al extractor de videos bajo demanda de Douyu, DouyuShowIE. Al comprobar un video bajo demanda, el registro mostrará [DouyuShow+DenoSigning]:

# List VOD formats
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -F "https://v.douyu.com/show/mPyq7oVNe5Yv1gLY"

# Download the VOD test sample
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -f worst -o "douyu-video.%(ext)s" "https://v.douyu.com/show/mPyq7oVNe5Yv1gLY"

Para comprobar si el archivo .flv o .mp4 contiene fotogramas dañados y si sus pistas de audio y video están completas, puedes ejecutar ffprobe y decodificar todo el archivo:

ffprobe -v error -show_entries stream=codec_name,codec_type,width,height:format=duration,size -of json "douyu-live.flv"
ffmpeg -nostdin -v error -xerror -i "douyu-live.flv" -map 0:v:0 -map "0:a:0?" -f null -

Análisis técnico de los tres fallos

Primer fallo: los datos de Next.js dejan el ID de sala fuera del alcance de la expresión regular

El extractor original obtenía el ID de sala a partir de una asignación a $ROOM.room_id dentro de la página. El issue #16230 recoge el error correspondiente. En las nuevas páginas de Douyu, tanto las que usan un alias como pigff como las que usan un ID numérico como 593229, los metadatos de la sala se han trasladado a los bloques de datos de Next.js App Router (__next_f.push).

Buscar room_id con una expresión regular demasiado amplia en toda la página puede devolver el ID de una sala recomendada en la barra lateral o al final de la página. Por eso el plugin analiza el flujo con _search_nextjs_v13_data y lee únicamente el diccionario roomInfo.room, que corresponde a la sala actual:

  • Resuelve correctamente el alias pigff al ID real 24422.
  • Comprueba el estado antes de solicitar la firma. Si show_status == 2, indica que la sala no está emitiendo, como ocurrió con la sala 593229. Si room_biz_all.videoLoop == 1, indica que se están reproduciendo videos en bucle. Así evita calcular firmas innecesarias.
  • Si la página no contiene bloques de datos de Next.js y conserva la estructura antigua, delega automáticamente en super()._real_extract(url) del extractor original.

Segundo fallo: la llamada directa a PhantomJSwrapper

Una vez obtenido el room_id real, el extractor debe ejecutar la función de firma ub98484234 que proporciona Douyu para calcular los cuatro parámetros v, did, tt y sign. Aunque Deno, recomendado por yt-dlp, ya esté instalado, _calc_sign en el archivo original douyutv.py sigue instanciando PhantomJSwrapper directamente. Esto provoca PhantomJS not found tanto en las transmisiones como en los videos bajo demanda.

El plugin sustituye _calc_sign y conserva la lógica original para obtener el script de firma y la implementación de MD5 de CryptoJS. Para las transmisiones pasa room_id; para los videos bajo demanda, el ID de video extraído del campo ROOM.point_id de window.$DATA. Codifica los argumentos de forma segura con json.dumps y pasa el script por la entrada estándar a un subproceso de Deno sin permisos de acceso:

  • Pasa explícitamente --no-prompt, --no-config y --no-remote, sin conceder permisos para acceder a archivos, variables de entorno o la red. El modelo de permisos de Deno limita el acceso a las API de JavaScript; no sustituye un aislamiento a nivel del sistema operativo.
  • Establece un tiempo de espera de 15 segundos y comprueba que la cadena de consulta devuelta contenga un campo sign no vacío antes de enviarla a /lapi/live/getH5Play/{room_id} o a la API de videos bajo demanda. En las pruebas, ambas API devolvieron error: 0.

Tercer fallo: urljoin elimina el directorio /live/ y la descarga falla

Una vez corregidos el ID de sala y la firma, -F ya mostraba formatos con distintas tasas de bits. Aun así, el primer intento de grabación con FFmpeg terminó con el código de salida 8 (ffmpeg exited with code 8).

Al revisar el JSON de getH5Play, comprobamos que la API separa la dirección en dos partes: el prefijo de directorio rtmp_url y el nombre de archivo rtmp_live. El valor de rtmp_url terminaba en /live, sin barra final. Al unir ambas partes directamente con urllib.parse.urljoin, el extractor original trataba live como el último nombre de archivo y lo reemplazaba. El issue #11998 también documenta respuestas 403 de la CDN por la ausencia de /live/:

urljoin('https://cdn.example/live', '24422.flv?sign=example')
# Incorrect result: https://cdn.example/24422.flv?sign=example

urljoin('https://cdn.example/live/', '24422.flv?sign=example')
# Correct result: https://cdn.example/live/24422.flv?sign=example

En _extract_stream_formats, el plugin asegura que rtmp_url termine en una barra (data['rtmp_url'].rstrip('/') + '/') antes de pasar los datos al extractor original. El nombre del archivo y los parámetros de firma de la consulta se conservan. Tras la corrección, tanto la grabación de la transmisión como la descarga de los fragmentos bajo demanda pudieron completarse y superar la comprobación de decodificación.


Código completo del plugin

Guarda este código como plugins/douyu/yt_dlp_plugins/extractor/douyu_signing.py:

"""Run Douyu signing scripts in Deno without file, environment, or network access."""
import json
import shutil
import subprocess
import time
import urllib.parse
import uuid

from yt_dlp.extractor.douyutv import DouyuShowIE as UpstreamDouyuShowIE, DouyuTVIE as UpstreamDouyuTVIE
from yt_dlp.utils import (
    ExtractorError,
    UserNotLive,
    int_or_none,
    str_or_none,
    traverse_obj,
    unescapeHTML,
    url_or_none,
    urlencode_postdata,
)


class _DenoSigning:
    def _calc_sign(self, sign_func, video_id, room_id):
        deno = shutil.which('deno')
        if not deno:
            raise ExtractorError('Deno is required for Douyu signing', expected=True)
        script = ('globalThis.window=globalThis;' + self._get_cryptojs_md5(video_id) + ';' + sign_func
                  + ';console.log(ub98484234(' + ','.join(json.dumps(value) for value in
                    (str(room_id), uuid.uuid4().hex, str(round(time.time())))) + '))')
        try:
            result = subprocess.run([deno, 'run', '--no-prompt', '--no-config', '--no-remote', '-'],
                                    input=script, text=True, capture_output=True, timeout=15)
        except (OSError, subprocess.TimeoutExpired) as exc:
            raise ExtractorError('Douyu signing runtime failed', cause=exc) from exc
        lines = result.stdout.strip().splitlines()
        fields = urllib.parse.parse_qs(lines[-1]) if lines and result.returncode == 0 else {}
        if not fields.get('sign'):
            raise ExtractorError('Unable to sign Douyu request with Deno')
        return {key: values[0] for key, values in fields.items()}


class DouyuShowIE(UpstreamDouyuShowIE, plugin_name='DenoSigning'):
    _calc_sign = _DenoSigning._calc_sign


class DouyuTVIE(UpstreamDouyuTVIE, plugin_name='DenoSigning'):
    _calc_sign = _DenoSigning._calc_sign

    def _extract_stream_formats(self, stream_formats):
        normalized = []
        for response in stream_formats:
            data = response.get('data')
            if isinstance(data, dict) and isinstance(data.get('rtmp_url'), str):
                # Treat /live as a directory so urljoin preserves it.
                response = {**response, 'data': {
                    **data, 'rtmp_url': data['rtmp_url'].rstrip('/') + '/',
                }}
            normalized.append(response)
        return super()._extract_stream_formats(normalized)

    def _real_extract(self, url):
        video_id = self._match_id(url)
        webpage = self._download_webpage(url, video_id)
        room = traverse_obj(
            self._search_nextjs_v13_data(webpage, video_id, fatal=False),
            (..., 'roomInfo', 'room', {dict}), get_all=False)
        if not room:
            # Let upstream handle older pages without guessing IDs from recommendations or URLs.
            return super()._real_extract(url)

        room_id = str_or_none(room.get('room_id'))
        if not room_id or not room_id.isascii() or not room_id.isdecimal() or int(room_id) <= 0:
            raise ExtractorError('Unable to extract room id')
        if traverse_obj(room, ('room_biz_all', 'videoLoop', {int_or_none})) == 1:
            raise UserNotLive('The channel is auto-playing VODs', video_id=video_id)
        if int_or_none(room.get('show_status')) == 2:
            raise UserNotLive(video_id=video_id)

        # New pages omit the signing function; reuse upstream script fetching and format parsing.
        sign_func = self._get_sign_func(room_id, video_id)
        form_data = {'rate': 0, **self._calc_sign(sign_func, video_id, room_id)}
        stream_formats = [self._download_json(
            f'https://www.douyu.com/lapi/live/getH5Play/{room_id}', video_id,
            note='Downloading livestream format', data=urlencode_postdata(form_data))]
        for rate_id in traverse_obj(stream_formats[0], ('data', 'multirates', ..., 'rate')):
            if rate_id != traverse_obj(stream_formats[0], ('data', 'rate')):
                form_data['rate'] = rate_id
                stream_formats.append(self._download_json(
                    f'https://www.douyu.com/lapi/live/getH5Play/{room_id}', video_id,
                    note=f'Downloading livestream format {rate_id}', data=urlencode_postdata(form_data)))

        return {
            'id': room_id,
            'display_id': video_id,
            'formats': self._extract_stream_formats(stream_formats),
            'is_live': True,
            **traverse_obj(room, {
                'title': ('room_name', {str}, {unescapeHTML}),
                'description': ('show_details', {str}),
                'uploader': ('nickname', {str}),
                'thumbnail': ('coverSrc', {url_or_none}),
            }),
        }
Herramientas yt-dlp
Ver todas las herramientas yt-dlp
Ayuda y documentación de yt-dlp