ytdlp.online

返回專欄目錄

文章目錄

yt-dlp 斗魚下載失敗:修復 PhantomJS not found 與房間 ID 錯誤

發布日期

下載修正外掛 douyu_signing.py

如果使用 yt-dlp 下載斗魚影片時出現 PhantomJS not found,或錄製直播時遇到 Unable to extract room id,可以直接載入本文提供的相容性外掛。此外掛同時修復新版直播間的房間 ID 擷取、Deno 簽章執行,以及直播串流網址組合這三個問題。

如果不想在本機設定 Deno 和外掛目錄,可以把斗魚連結貼到首頁下載器,直接使用線上工具。

我們排查斗魚直播與隨選影片的故障時,先後遇到三個問題:新版網頁改用 Next.js 資料區塊,使房間 ID 的正規表示式失效;修好房間 ID 後,上游程式碼仍硬編碼呼叫已棄用的 PhantomJS 進行簽章;等簽章成功、列出各畫質格式後,組合出的 FLV 直播串流網址又少了 /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. 檢查直播格式清單

以下以公開直播間為例。實際使用時,請替換為目標房間的網址:

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 網址組合正確:

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)與檔案驗證(選用)

同一份外掛也適用於斗魚隨選影片擷取器 DouyuShowIE。測試隨選影片時,記錄中會顯示 [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 -

三處故障的技術解析

第一處:頁面改用 Next.js 資料區塊,舊正規表示式讀不到房間 ID

上游擷取器原本透過頁面中的 $ROOM.room_id 賦值語句擷取房間 ID,相關錯誤可參見上游 issue #16230。但在新版斗魚直播間中,例如自訂別名 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) 處理。

第二處:上游硬編碼呼叫 PhantomJSwrapper

取得實際 room_id 後,擷取器需要呼叫斗魚提供的 ub98484234 簽章函式,計算 v、did、tt 和 sign 四個參數。即使本機已安裝 yt-dlp 建議的 Deno,上游 douyutv.py 的 _calc_sign 仍直接建立 PhantomJSwrapper 實例,因而出現 PhantomJS not found。直播與隨選影片都受到影響。

外掛覆寫 _calc_sign,保留上游取得簽章指令碼與內建 CryptoJS MD5 的邏輯。直播傳入 room_id,隨選影片則傳入從 window.$DATA 的 ROOM.point_id 欄位讀取的影片 ID。使用 json.dumps 安全編碼參數後,透過標準輸入交給未授予存取權限的 Deno 子程序執行:

  • 明確傳入 --no-prompt、--no-config、--no-remote,不授予任何檔案、環境變數或網路存取權限。Deno 權限模型用於限制 JavaScript API 存取,不能取代作業系統層級的沙箱。
  • 設定 15 秒逾時,確認回傳的查詢字串包含非空的 sign 欄位後,再提交至 /lapi/live/getH5Play/{room_id} 或隨選影片 API。實測時,兩者皆回傳 error: 0。

第三處:urljoin 丟失 /live/ 目錄,導致串流取得失敗

房間 ID 與簽章流程修好後,-F 已能列出多種位元率格式,但第一次呼叫 FFmpeg 錄製時,仍以結束代碼 8(ffmpeg exited with code 8)失敗。

檢查 getH5Play 回傳的 JSON 後發現,API 將串流網址拆成目錄前綴 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('/') + '/'),再交由上游解析。串流檔名與簽章查詢參數保持原樣。修補後,直播串流錄製與隨選影片片段下載皆能完整接收資料,並通過解碼檢查。


完整外掛程式碼

將以下程式碼儲存為 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 說明與文件