yt-dlp の Douyu ダウンロードエラー:PhantomJS not found とルーム ID の修正
公開日
修正プラグインをダウンロード douyu_signing.py
yt-dlp で Douyu の動画をダウンロードするときに PhantomJS not found が出る場合や、ライブ配信の録画で Unable to extract room id が出る場合は、この記事の互換プラグインを読み込むことで対処できます。新しい配信ページからのルーム ID 抽出、Deno による署名処理、ライブストリーム URL の結合という 3 つの問題を修正します。
ローカル環境で Deno やプラグインディレクトリを設定せずに使いたい場合は、Douyu のリンクをトップページのダウンローダーに貼り付けて、オンラインツールを利用できます。
Douyu のライブ配信とオンデマンド動画の不具合を調べる中で、3 つの問題が順に見つかりました。新しいページが Next.js のデータブロックを使うようになり、ルーム ID の正規表現が一致しなくなっていました。ルーム ID を取得できるようにすると、今度は上流のコードが非推奨の PhantomJS を署名処理に使うようハードコードされていたため停止しました。署名が成功して画質ごとの形式を一覧表示できても、生成された FLV ストリーム URL から /live/ ディレクトリが抜けていました。まず対処の手順を示し、その後で各原因を説明します。
エラーの症状と発生箇所
| エラーや症状 | 問題のある処理 |
|---|---|
Unable to extract room id |
新しい配信ページは Next.js のデータストリームを使うため、従来の $ROOM.room_id のパターンに一致しません |
PhantomJS not found |
上流の douyutv.py が PhantomJSwrapper を呼び出すようハードコードされています。システムに Deno をインストールするだけでは切り替わりません |
形式一覧は表示できる(-F は正常)が、ダウンロードがエラーで終了する |
API が返す rtmp_url に末尾のスラッシュがなく、urljoin で結合すると /live/ ディレクトリが失われます |
The channel is not currently live |
現在配信中ではありません。状態の判定によって正常に処理を停止しています |
The channel is auto-playing VODs |
オンデマンド動画をループ再生中です(videoLoop: 1)。プラグインは抽出処理を停止します |
修正の手順
1. 実行環境とプラグインファイルを準備する
ターミナルで yt-dlp、deno、ffmpeg を実行できるようにします。この記事のテスト環境は yt-dlp nightly 2026.10.29.232701、Deno 2.9.8、FFmpeg 7.1.5 です。プラグインは yt-dlp 内蔵の _search_nextjs_v13_data メソッドを使うため、古いバージョンを使用している場合は先に yt-dlp を更新してください。
作業ディレクトリ内に次の構成でフォルダーを作成し、記事末尾のプラグインの全コードを douyu_signing.py として保存します。
plugins/
└── douyu/
└── yt_dlp_plugins/
└── extractor/
└── douyu_signing.py
以降のコマンドは、すべて plugins フォルダーがあるディレクトリで実行します。--plugin-dirs ./plugins でプラグインを読み込むため、yt-dlp のインストール先にあるソースコードを変更する必要はありません。
2. ライブ配信の形式一覧を確認する
次の例では公開の配信ルームを使用しています。実行時には対象ルームの URL に置き換えてください。
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -v -F "https://www.douyu.com/pigff"
ログに [DouyuTV+DenoSigning] と表示されれば、プラグインが有効になっています。配信中であれば、getH5Play API へのリクエストが行われ、画質ごとの形式が表示されます。The channel is not currently live と表示された場合は配信されていないため、配信中の別のルームで確認してください。
3. 30 秒間録画してメディアストリームを検証する
形式一覧の表示で確認できるのは、署名を使った API リクエストが error: 0 を返したことまでです。CDN の URL が正しく結合されているか確認するには、実際にストリームを取得する必要があります。
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"
-f worst は最低ビットレートを選び、テストファイルのサイズを抑えます。-t 30 で録画を 30 秒間に制限し、ネットワークの読み書きには 15 秒のタイムアウトを設定しています。
4. オンデマンド動画(VOD)とファイルを検証する(任意)
同じプラグインは、Douyu のオンデマンド動画用エクストラクター DouyuShowIE にも対応しています。VOD のテストでは、ログに [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"
録画した .flv や .mp4 に破損したフレームがないか、映像・音声トラックが揃っているかをさらに確認するには、ffprobe と全体のデコード検査を実行します。
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 -
3 つの不具合を技術的に解説
1. Next.js データへの移行で、従来の正規表現がルーム ID を取得できない
上流のエクストラクターは、ページ内の $ROOM.room_id への代入文からルーム ID を取得していました。関連するエラーは上流の issue #16230 に報告されています。しかし、新しい Douyu の配信ページでは、カスタムエイリアス pigff や通常のルーム番号 593229 を含め、ルームのメタデータが Next.js App Router のスクリプトデータブロック(__next_f.push)に移されています。
ページ全体から大まかな正規表現で room_id を探すと、サイドバーやページ下部のおすすめ配信の ID を誤って取得しやすくなります。そこでプラグインは _search_nextjs_v13_data でデータストリームを解析し、現在のルームを表す roomInfo.room 辞書だけを読み取ります。
- カスタムエイリアス
pigffを実際のルーム ID24422に解決します。 - 署名を要求する前にルームの状態を確認します。
show_status == 2の場合は未配信のメッセージを返します(例:テスト時に未配信だったルーム593229)。room_biz_all.videoLoop == 1の場合は動画をループ再生中と判定し、不要な署名計算を避けます。 - ページに Next.js のデータブロックがない場合は、旧形式のページとして上流の
super()._real_extract(url)に処理を戻します。
2. 上流のコードが PhantomJSwrapper を直接呼び出す
実際の room_id を取得した後、エクストラクターは Douyu から受け取った署名関数 ub98484234 を呼び出し、v、did、tt、sign の 4 つのパラメーターを計算します。yt-dlp が推奨する Deno をインストール済みでも、上流の douyutv.py の _calc_sign は PhantomJSwrapper を直接インスタンス化するため、PhantomJS not found が発生します。ライブ配信と VOD の両方が影響を受けます。
プラグインは _calc_sign をオーバーライドします。署名スクリプトと内蔵の CryptoJS MD5 を取得する上流の処理を保ち、ライブ配信では room_id、VOD では window.$DATA の ROOM.point_id フィールドから取得した動画 ID を渡します。json.dumps で引数を安全にエンコードした後、標準入力を通じて権限を付与していない Deno の子プロセスに渡し、実行します。
--no-prompt、--no-config、--no-remoteを明示的に指定し、ファイル、環境変数、ネットワークへのアクセス権限を付与しません。Deno の権限モデルは JavaScript API のアクセスを制限するもので、OS レベルのサンドボックスの代わりにはなりません。- タイムアウトを 15 秒に設定します。返されたクエリ文字列に空でない
signフィールドがあることを確認してから、/lapi/live/getH5Play/{room_id}または VOD の API に送信します。実際のテストでは、いずれの API もerror: 0を返しました。
3. urljoin が /live/ ディレクトリを落とし、ストリーム取得に失敗する
ルーム ID と署名の問題を解消すると、-F で複数のビットレートの形式を一覧表示できるようになりました。それでも最初の FFmpeg による録画は、終了コード 8(ffmpeg exited with code 8)で失敗しました。
getH5Play が返す JSON を調べると、ストリーム URL はディレクトリのプレフィックス rtmp_url とファイル名 rtmp_live に分かれていました。rtmp_url は /live で終わり、末尾のスラッシュがありません。上流のコードがそのまま urllib.parse.urljoin で結合すると、live がパス末尾のファイル名として扱われ、置き換えられてしまいます。上流の issue #11998 にも、/live/ が失われたことで CDN が 403 を返す現象が記録されています。
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
プラグインは _extract_stream_formats で、まず rtmp_url の末尾にスラッシュを揃えます(data['rtmp_url'].rstrip('/') + '/')。その後、上流の形式解析に渡します。ストリームのファイル名と署名クエリパラメーターは変更しません。修正後は、ライブ配信の録画と VOD のセグメントダウンロードが完了し、デコード検査にも合格しました。
プラグインの全コード
次のコードを 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}),
}),
}