yt-dlp et Douyu : corriger PhantomJS not found et les erreurs d’ID de salon
Date de publication
Télécharger le plugin correctif douyu_signing.py
Si yt-dlp affiche PhantomJS not found pendant le téléchargement d’une vidéo Douyu ou Unable to extract room id pendant l’enregistrement d’un direct, chargez le plugin de compatibilité fourni dans cet article. Il corrige trois problèmes : l’extraction de l’identifiant du salon depuis les nouvelles pages, le calcul de signature avec Deno et l’assemblage de l’URL du flux en direct.
Si vous préférez éviter la configuration locale de Deno et du répertoire des plugins, collez le lien Douyu dans l’outil de téléchargement de notre page d’accueil pour l’utiliser en ligne.
Notre analyse des échecs sur les directs et les vidéos à la demande de Douyu a révélé trois blocages successifs. Le passage des pages aux blocs de données Next.js empêchait l’ancienne expression régulière de trouver l’identifiant du salon. Une fois ce problème corrigé, l’extracteur continuait d’appeler directement PhantomJS, désormais obsolète, pour calculer la signature. Puis, alors que la signature fonctionnait et que les résolutions s’affichaient, l’URL du flux FLV assemblée avait perdu son répertoire /live/. Vous trouverez d’abord la procédure, puis l’analyse de chaque cause.
Identifier le problème à partir du message affiché
| Message ou symptôme | Étape en cause |
|---|---|
Unable to extract room id |
Les nouvelles pages de direct utilisent un flux de données Next.js ; l’ancienne recherche de $ROOM.room_id ne trouve plus de correspondance |
PhantomJS not found |
Le fichier amont douyutv.py appelle directement PhantomJSwrapper ; installer Deno ne remplace pas automatiquement cet appel |
Les formats s’affichent (-F fonctionne), mais le téléchargement se termine par une erreur |
L’API renvoie un rtmp_url sans barre oblique finale ; urljoin supprime alors le répertoire /live/ lors de l’assemblage |
The channel is not currently live |
Le salon est hors ligne ; l’arrêt correspond au contrôle normal de son état |
The channel is auto-playing VODs |
Le salon diffuse des vidéos à la demande en boucle (videoLoop: 1) ; le plugin arrête l’extraction |
Étapes de correction rapide
1. Préparer l’environnement et le fichier du plugin
Vérifiez que yt-dlp, deno et ffmpeg sont disponibles dans votre terminal. Les tests de cet article ont utilisé yt-dlp nightly 2026.10.29.232701, Deno 2.9.8 et FFmpeg 7.1.5. Le plugin dépend de la méthode _search_nextjs_v13_data intégrée à yt-dlp. Mettez d’abord yt-dlp à jour si votre version est trop ancienne.
Dans votre répertoire de travail, créez cette arborescence et enregistrez le code complet du plugin en fin d’article dans douyu_signing.py :
plugins/
└── douyu/
└── yt_dlp_plugins/
└── extractor/
└── douyu_signing.py
Exécutez toutes les commandes suivantes depuis le répertoire contenant le dossier plugins. L’option --plugin-dirs ./plugins charge le plugin sans modifier le code source installé avec yt-dlp.
2. Vérifier la liste des formats du direct
Cet exemple utilise un salon public. Remplacez son URL par celle du salon souhaité :
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -v -F "https://www.douyu.com/pigff"
La présence de [DouyuTV+DenoSigning] dans le journal confirme que le plugin est actif. Si le salon diffuse en direct, yt-dlp interroge l’API getH5Play et affiche les formats pour chaque résolution. Le message The channel is not currently live indique que le salon est hors ligne ; choisissez alors un salon en cours de diffusion pour le test.
3. Enregistrer 30 secondes pour vérifier le flux
L’affichage des formats confirme seulement que l’API a renvoyé error: 0 pour la requête signée. Il faut encore récupérer le flux pour vérifier que l’adresse du CDN est correctement assemblée :
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"
L’option -f worst sélectionne le débit le plus faible pour limiter la taille du fichier de test. -t 30 limite l’enregistrement à 30 secondes, et le délai maximal de lecture et d’écriture réseau est fixé à 15 secondes.
4. Tester une vidéo à la demande et vérifier les fichiers (facultatif)
Le même plugin couvre également DouyuShowIE, l’extracteur de vidéos à la demande (VOD) de Douyu. Lors d’un test VOD, le journal affiche [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 sample
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -f worst -o "douyu-video.%(ext)s" "https://v.douyu.com/show/mPyq7oVNe5Yv1gLY"
Pour rechercher des images corrompues et vérifier l’intégrité des pistes audio et vidéo du fichier .flv ou .mp4 enregistré, utilisez ffprobe puis un décodage intégral :
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 -
Analyse technique des trois problèmes
Premier problème : les blocs Next.js rendent l’ancienne recherche d’ID inopérante
L’extracteur amont récupérait l’identifiant du salon dans une affectation à $ROOM.room_id présente dans la page. Un échec correspondant est signalé dans l’issue #16230. Sur les nouvelles pages de direct Douyu, par exemple celles de l’alias pigff ou du salon numérique 593229, les métadonnées du salon se trouvent désormais dans les blocs de script de l’App Router de Next.js (__next_f.push).
Une expression régulière trop large recherchant room_id dans toute la page risque de récupérer l’identifiant d’un salon recommandé dans la barre latérale ou en bas de page. Le plugin analyse donc le flux avec _search_nextjs_v13_data et ne lit que le dictionnaire roomInfo.room correspondant au salon courant :
- L’alias
pigffest correctement associé à l’identifiant réel24422. - L’état du salon est contrôlé avant la demande de signature. Avec
show_status == 2, le plugin renvoie immédiatement le message indiquant que le salon est hors ligne, comme pour le salon593229lors du test. Avecroom_biz_all.videoLoop == 1, il signale une lecture de vidéos en boucle. Ces contrôles évitent un calcul de signature inutile. - Si la page ne contient pas de blocs Next.js et utilise l’ancienne structure, le traitement revient automatiquement à la méthode amont
super()._real_extract(url).
Deuxième problème : l’appel à PhantomJSwrapper est codé en dur
Après avoir obtenu la véritable room_id, l’extracteur doit exécuter la fonction de signature ub98484234 fournie par Douyu pour calculer les quatre paramètres v, did, tt et sign. Même si Deno, recommandé par yt-dlp, est installé, la méthode _calc_sign du fichier amont douyutv.py instancie directement PhantomJSwrapper. Cela provoque l’erreur PhantomJS not found, aussi bien pour les directs que pour les VOD.
Le plugin redéfinit _calc_sign tout en conservant la logique amont de récupération du script de signature et de la dépendance CryptoJS MD5 intégrée. Il transmet room_id pour les directs et l’identifiant vidéo extrait du champ ROOM.point_id de window.$DATA pour les VOD. Les arguments sont encodés avec json.dumps, puis envoyés par l’entrée standard à un sous-processus Deno sans autorisations accordées :
- Les options
--no-prompt,--no-configet--no-remotesont explicitement activées. Aucune autorisation d’accès aux fichiers, aux variables d’environnement ou au réseau n’est accordée. Le modèle d’autorisations de Deno limite l’accès aux API JavaScript ; il ne remplace pas une isolation au niveau du système d’exploitation. - Le délai maximal est fixé à 15 secondes. Le plugin vérifie que la chaîne de paramètres renvoyée contient un champ
signnon vide avant d’envoyer la requête à/lapi/live/getH5Play/{room_id}ou à l’API VOD. Lors des tests, les deux interfaces ont renvoyéerror: 0.
Troisième problème : urljoin supprime le répertoire /live/
Une fois l’identifiant du salon et la signature corrigés, -F affichait les formats à plusieurs débits. Pourtant, le premier enregistrement avec FFmpeg a encore échoué avec le code de sortie 8 (ffmpeg exited with code 8).
La réponse JSON de getH5Play séparait l’adresse du flux en deux champs : le préfixe de répertoire rtmp_url et le nom de fichier rtmp_live. Or rtmp_url se terminait par /live, sans barre oblique finale. Lors de l’assemblage direct avec urllib.parse.urljoin, live était donc traité comme le dernier nom de fichier et remplacé. L’issue #11998 rapporte également des erreurs 403 du CDN dues à l’absence 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
Dans _extract_stream_formats, le plugin ajoute d’abord la barre oblique finale à rtmp_url avec data['rtmp_url'].rstrip('/') + '/', puis transmet les données à l’analyseur de formats amont. Le nom du fichier et les paramètres de signature restent inchangés. Après cette correction, les enregistrements de directs et les téléchargements de fragments VOD ont pu récupérer toutes les données et passer le contrôle de décodage.
Code complet du plugin
Enregistrez le code suivant dans 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}),
}),
}