yt-dlp e Douyu: corrigir PhantomJS not found e erros de ID da sala
Data de publicação
Baixar plugin de correção douyu_signing.py
Se o yt-dlp mostrar PhantomJS not found ao baixar vídeos do Douyu, ou Unable to extract room id ao gravar uma transmissão, carregue o plugin de compatibilidade deste artigo. Ele corrige três problemas: a extração do ID da sala nas páginas novas, a execução da assinatura com Deno e a construção da URL da transmissão.
Se preferir evitar a configuração local do Deno e do diretório de plugins, cole o link do Douyu na ferramenta de download da página inicial e use o serviço online.
Ao investigar as falhas nas transmissões e nos vídeos sob demanda do Douyu, encontramos três obstáculos consecutivos. A nova página usa blocos de dados do Next.js, e a expressão regular antiga deixa de encontrar o ID da sala. Após essa correção, o extrator original ainda chama diretamente o obsoleto PhantomJS para calcular a assinatura. E, quando a assinatura funciona e a lista de formatos aparece, a URL FLV resultante perde o diretório /live/. Primeiro apresentamos os passos de correção e depois explicamos cada causa.
Sintomas e etapa da falha
| Erro ou sintoma | Onde ocorre a falha |
|---|---|
Unable to extract room id |
A nova página da sala usa o fluxo de dados do Next.js e não corresponde ao padrão antigo de $ROOM.room_id |
PhantomJS not found |
O arquivo original douyutv.py chama diretamente PhantomJSwrapper; instalar o Deno no sistema não altera essa chamada |
A lista de formatos aparece (-F funciona), mas o download termina com erro |
A API retorna rtmp_url sem a barra final, e urljoin remove o diretório /live/ ao montar a URL |
The channel is not currently live |
A sala não está ao vivo; trata-se de uma verificação normal do estado |
The channel is auto-playing VODs |
A sala está reproduzindo vídeos sob demanda em loop (videoLoop: 1); o plugin interrompe a extração |
Passos para aplicar a correção
1. Prepare o ambiente e o arquivo do plugin
Verifique se yt-dlp, deno e ffmpeg estão instalados e disponíveis no terminal. Nossos testes foram realizados com yt-dlp nightly 2026.10.29.232701, Deno 2.9.8 e FFmpeg 7.1.5. O plugin depende do método _search_nextjs_v13_data do yt-dlp; atualize o yt-dlp se a versão local for antiga demais.
Crie esta estrutura de pastas no diretório de trabalho e salve o código completo do plugin como douyu_signing.py:
plugins/
└── douyu/
└── yt_dlp_plugins/
└── extractor/
└── douyu_signing.py
Execute os comandos seguintes no diretório que contém a pasta plugins. A opção --plugin-dirs ./plugins carrega o plugin sem alterar o código-fonte da instalação do yt-dlp.
2. Confira a lista de formatos da transmissão
O exemplo abaixo usa uma sala pública. Substitua a URL pelo endereço da sala desejada:
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -v -F "https://www.douyu.com/pigff"
A identificação [DouyuTV+DenoSigning] no log confirma que o plugin está ativo. Se a sala estiver ao vivo, o extrator fará uma requisição à API getH5Play e exibirá os formatos das diferentes qualidades. Se aparecer The channel is not currently live, a sala não está transmitindo naquele momento; teste outra sala que esteja ao vivo.
3. Grave 30 segundos para verificar o fluxo de mídia
A lista de formatos mostra apenas que a API que recebe a assinatura retornou error: 0. Também é preciso receber dados da transmissão para confirmar que a URL da CDN foi montada corretamente:
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"
A opção -f worst seleciona a menor taxa de bits para reduzir o tamanho do arquivo de teste. -t 30 limita a gravação a 30 segundos, e o tempo limite de leitura e escrita na rede é de 15 segundos.
4. Verifique vídeos sob demanda e arquivos (opcional)
O mesmo plugin também cobre o extrator de vídeos sob demanda do Douyu, DouyuShowIE. Ao testar um vídeo sob demanda, o log 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 verificar se o arquivo .flv ou .mp4 gravado contém quadros danificados e se as faixas de áudio e vídeo estão completas, execute ffprobe e uma verificação de decodificação do arquivo inteiro:
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álise técnica das três falhas
Primeira falha: a mudança para dados do Next.js impede a leitura do ID da sala
O extrator original obtinha o ID da sala a partir de uma atribuição a $ROOM.room_id na página. O erro correspondente foi relatado na issue #16230. Nas novas páginas do Douyu, tanto as que usam um nome personalizado, como pigff, quanto as que usam um ID numérico, como 593229, os metadados da sala passaram para os blocos de dados do Next.js App Router (__next_f.push).
Uma busca por room_id com uma expressão regular ampla na página inteira pode capturar o ID de uma sala recomendada na barra lateral ou no final da página. Por isso, o plugin usa _search_nextjs_v13_data para analisar o fluxo de dados e lê apenas o dicionário roomInfo.room, que representa a sala atual:
- Resolve corretamente o nome personalizado
pigffpara o ID real24422. - Verifica o estado antes de solicitar a assinatura. Se
show_status == 2, informa que a sala não está ao vivo, como ocorreu com a sala593229. Seroom_biz_all.videoLoop == 1, informa que há vídeos em reprodução contínua. Assim, evita calcular assinaturas desnecessárias. - Se a página não contiver blocos de dados do Next.js e ainda usar a estrutura antiga, delega automaticamente o processamento ao método original
super()._real_extract(url).
Segunda falha: a chamada direta a PhantomJSwrapper
Depois de obter o room_id real, o extrator precisa executar a função de assinatura ub98484234, fornecida pelo Douyu, para calcular os quatro parâmetros v, did, tt e sign. Mesmo com o Deno recomendado pelo yt-dlp já instalado, _calc_sign no arquivo original douyutv.py continua instanciando PhantomJSwrapper diretamente. Isso causa PhantomJS not found tanto nas transmissões ao vivo quanto nos vídeos sob demanda.
O plugin substitui _calc_sign, mantendo a lógica original de obtenção do script de assinatura e da implementação MD5 do CryptoJS. Para transmissões ao vivo, passa room_id; para vídeos sob demanda, o ID de vídeo extraído do campo ROOM.point_id de window.$DATA. Após codificar os argumentos de forma segura com json.dumps, envia o script pela entrada padrão a um subprocesso do Deno sem permissões de acesso:
- Passa explicitamente
--no-prompt,--no-confige--no-remote, sem conceder acesso a arquivos, variáveis de ambiente ou rede. O modelo de permissões do Deno limita o acesso às APIs de JavaScript; não substitui um isolamento no nível do sistema operacional. - Define um tempo limite de 15 segundos e verifica se a string de consulta retornada contém um campo
signnão vazio antes de enviá-la a/lapi/live/getH5Play/{room_id}ou à API de vídeos sob demanda. Nos testes, ambas as APIs retornaramerror: 0.
Terceira falha: urljoin remove o diretório /live/ e impede o download
Depois de corrigir o ID da sala e a assinatura, -F já listava formatos com diferentes taxas de bits. Mesmo assim, a primeira tentativa de gravação com FFmpeg falhou com o código de saída 8 (ffmpeg exited with code 8).
A análise do JSON retornado por getH5Play mostrou que a API divide o endereço em duas partes: o prefixo de diretório rtmp_url e o nome do arquivo rtmp_live. O valor de rtmp_url terminava em /live, sem a barra final. Ao unir as partes diretamente com urllib.parse.urljoin, o extrator original tratava live como o último nome de arquivo e o substituía. A issue #11998 também registra respostas 403 da CDN causadas pela ausência 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
Em _extract_stream_formats, o plugin garante que rtmp_url termine com uma barra (data['rtmp_url'].rstrip('/') + '/') antes de passar os dados ao extrator original. O nome do arquivo e os parâmetros de assinatura da consulta permanecem intactos. Após a correção, tanto a gravação da transmissão quanto o download dos fragmentos sob demanda foram concluídos e passaram na verificação de decodificação.
Código completo do plugin
Salve o código abaixo 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}),
}),
}