yt-dlp 斗魚下載失敗:修復 PhantomJS not found 與房間 ID 錯誤
發布日期
如果使用 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正確解析為實際房間 ID24422。 - 在請求簽章前先判斷房間狀態:
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}),
}),
}