ytdlp.online
ホーム Tampermonkey

対処法一覧に戻る

目次

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 を実際のルーム ID 24422 に解決します。
  • 署名を要求する前にルームの状態を確認します。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}),
            }),
        }
yt-dlp ツール群
すべてのyt-dlpツールを表示
yt-dlp ヘルプ&ドキュメント