ytdlp.online

返回专栏目录

文章目录

yt-dlp 斗鱼下载失败:修复 PhantomJS not found 与房间号错误

发布日期

下载修复插件 douyu_signing.py

如果使用 yt-dlp 下载斗鱼视频时报 PhantomJS not found,或录制直播时遇到 Unable to extract room id,可以直接加载本文提供的兼容插件。该插件同时解决新版直播间的房间号提取、Deno 签名执行以及直播流地址拼接三个问题。

如果不想在本地配置 Deno 和插件目录,可以把斗鱼链接粘贴到首页下载器,直接使用在线工具。

我们在排查斗鱼直播与点播故障时,遇到了三处连续卡点:新版网页改用 Next.js 数据块导致房间号正则失效;修好房间号后,上游仍硬编码调用已废弃的 PhantomJS 签名;等签名跑通、成功列出清晰度格式后,拼接出来的 FLV 直播流地址又少了 /live/ 目录。下面先给操作步骤,再逐一拆解原因。

故障现象与对应位置

报错或现象 故障环节
Unable to extract room id 新版直播间页面改用 Next.js 数据流,旧版 $ROOM.room_id 匹配失败
PhantomJS not found 上游 douyutv.py 硬编码调用 PhantomJSwrapper;仅在系统安装 Deno 不会自动生效
能列出格式(-F 正常),但下载报错退出 接口返回的 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 接口并打印各清晰度格式;若提示 The channel is not currently live,说明当前未开播,换一个开播中的房间测试即可。

3. 试录 30 秒验证媒体流

仅列出格式只能说明签名接口返回了 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]:

# 查看点播格式
yt-dlp --ignore-config --plugin-dirs ./plugins --no-cache-dir -F "https://v.douyu.com/show/mPyq7oVNe5Yv1gLY"

# 下载点播样本
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 数据块,旧正则读不到房间号

上游提取器原本通过页面里的 $ROOM.room_id 赋值语句提取房间号(相关报错见上游 issue #16230)。但在新版斗鱼直播间(如靓号别名 pigff 或普通房间号 593229)中,房间元数据已经迁移到 Next.js App Router 的脚本数据块(__next_f.push)里。

如果直接用宽泛正则在整页搜索 room_id,很容易误抓到侧边栏或底部推荐直播间的房间号。因此插件使用 _search_nextjs_v13_data 解析数据流,只读取代表当前房间的 roomInfo.room 字典:

  • 将靓号别名 pigff 准确解析为真实房间号 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 权限模型用于限制 JS API 访问,不替代操作系统级沙箱)。
  • 设置 15 秒超时,校验返回的查询字符串中包含非空 sign 字段后,再提交给 /lapi/live/getH5Play/{room_id} 或点播接口,实测接口均返回 error: 0。

第三处:urljoin 吞掉 /live/ 目录导致拉流失败

房间号和签名都打通后,-F 已经能列出多码率格式,但首次调用 FFmpeg 录制时依然以退出码 8(ffmpeg exited with code 8)失败。

排查 getH5Play 返回的 JSON 发现,接口把流地址拆成了目录前缀 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')
# 错误结果:https://cdn.example/24422.flv?sign=example

urljoin('https://cdn.example/live/', '24422.flv?sign=example')
# 正确结果: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:

"""用现有 Deno 在无文件、环境和网络权限的进程中执行斗鱼签名脚本。"""
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):
                # API 给出的 /live 是目录;补斜杠,避免 urljoin 把它当文件名替换。
                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:
            # 旧版页面仍交给上游处理,避免从推荐列表或网址猜测房间号。
            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)

        # 新页面不再内嵌签名函数;沿用上游获取脚本和解析格式的方法。
        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 帮助与文档