提示

返回博客列表

手写一个 yt-dlp extractor:从抓包定位数据源到提交 PR,我把它拆成了 9 步

事情的起因很普通:一个客户内部培训平台的课程,我要批量存档。把链接丢给 yt-dlp,返回一行冷冰冰的 Unsupported URL。我搜了一圈,这个平台太小众,yt-dlp 里确实没有对应的解析器。

那怎么办?两条路:一是用浏览器插件把视频地址抠出来再手动下(200 多节课,不可能);二是自己写个 extractor。我选了第二条,并且顺手把它提给了上游。

写完之后我的感受是:extractor 本身一点都不难,真正花时间的是三件事——搞清楚数据源藏在哪、把元数据字段填规范、以及通过上游的代码审查。这篇文章按我当时的实际顺序走一遍,包括我提交 PR 被打回两次才合并的经历。

TL;DR:yt-dlp 的 extractor 本质就是一个继承 InfoExtractor 的类,实现 _VALID_URL(正则匹配 URL)和 _real_extract(返回元数据字典)两个东西。核心工作量在 _real_extract 里:从网页或接口里把标题、时长、以及可用的媒体地址(formats)抠出来。本地调试靠 --verbose--dump-json,插件可以放在 yt-dlp 的 plugins 目录里免编译加载。想合并进上游,需要补 _TESTS、过 ruff 代码风格检查,并按模板填 PR 描述。

目录

一、先确认:yt-dlp 是真的不支持,还是你姿势不对

拿到 Unsupported URL 先别急着写代码。按这个顺序排查:

# 1) 确认版本(有些 extractor 是很新才加的)
yt-dlp --version
yt-dlp -U          # 升级

# 2) 列出所有 extractor,搜一下站点名
yt-dlp --list-extractors | grep -i "sitename"

# 3) 看看是不是被通用 extractor 接住了
yt-dlp -v "URL"

第三种情况很常见:yt-dlp 有个 generic 通用解析器,它会尝试从网页里找 <video> 标签或者 m3u8 链接。有时候能下,但拿到的往往是画质最低的那一路,而且元数据(标题、章节、字幕)全是缺失的

判断依据:如果 yt-dlp -F URL 只列出一两个格式、标题是网页 <title> 的完整内容(带着站点名后缀),那基本就是 generic 在兜底。这时候写专用 extractor 才有意义。

另外还有一种可能:站点其实是某个已知平台的白标版本(比如用 B 站或某云厂商的播放器)。这种情况直接复用现有 extractor 就行,不用自己写——我后来发现那个培训平台的播放器其实是某家云点播的 SDK,本来可以少写很多代码,可惜我是写完才发现。

二、extractor 的最小骨架

一个 extractor 就是这样一个类:

from .common import InfoExtractor


class ExampleSiteIE(InfoExtractor):
    IE_NAME = 'examplesite'
    _VALID_URL = r'https?://(?:www\.)?example\.com/watch/(?P<id>[\w-]+)'
    _TESTS = []

    def _real_extract(self, url):
        video_id = self._match_id(url)
        return {
            'id': video_id,
            'title': 'hello',
        }

三个必要元素:

元素 作用
IE_NAME 站点标识,会出现在 -F 输出和 verbose 日志里
_VALID_URL 正则,yt-dlp 用它判断"这个 URL 归我处理"。必须带命名组 (?P<id>...)
_real_extract 主逻辑,返回一个元数据字典

返回字典里最重要的字段:

字段 说明 常见写法
id 视频唯一标识 self._match_id(url)
title 标题(字符串) 网页 _og_search_title 或接口字段
formats 可用格式列表 见第四节
duration 时长(秒,整数) int_or_none(...)
thumbnail 封面 URL traverse_obj(data, ('cover', 'url', {url}))
description 简介 可选
uploader / timestamp 上传者 / 时间戳 可选

命名规范:类名以 IE 结尾(yt-dlp 靠这个后缀识别),文件名用小写下划线,放在 yt_dlp/extractor/ 目录下(通常按首字母分目录,比如 extractor/extractors/ 里的 _extractors.py 里注册)。

三、抓包:数据源到底藏在哪

这是最花时间的一步,也是最有"侦探感"的一步。

我的标准流程:

1. 打开 DevTools → Network,勾选 Preserve log,刷新页面。

2. 先按类型过滤。
- 看 Fetch/XHR:站点的接口数据(JSON)通常在这
- 搜索框输入 m3u8:直接看有没有 HLS 列表
- 搜索 mp4 / playlist / getPlayInfo / videoUrl:常见的接口命名

3. 如果 XHR 里没有,看 HTML 源码。
很多站点会把播放配置直接内联在页面里,长得像这样:

<script>
  window.__PLAYER_CONFIG__ = {"videoId":"abc123","title":"第 3 节",
    "sources":[{"quality":"1080p","url":"https://cdn.../1080.m3u8"}]};
</script>

或者藏在 data-* 属性、<video> 标签里。

4. 注意接口参数。
我那次遇到的情况是:播放接口需要 videoId + 一个 token,token 在另一个接口返回。这种"两步走"很常见。

5. 复制成 curl,先验证能独立跑通。

# DevTools 里右键 → Copy as cURL,然后在终端跑
curl 'https://api.example.com/play?vid=abc123' \
  -H 'Referer: https://www.example.com/watch/abc123' \
  -H 'Cookie: session=xxx' | python3 -m json.tool

这一步一定要做。如果 curl 都拿不到数据,说明需要登录态或者签名,extractor 里也拿不到(或者拿到了也属于你不该访问的内容)。我一般会先去掉 Cookie 试一次,看看是不是真的需要登录。

6. 确认能拿到后,把 JSON 结构看清楚,因为下一步要写解析代码。我习惯把响应存下来:

curl ... -o sample.json

然后对着 sample.jsontraverse_obj 路径,比对着浏览器里折叠的 JSON 舒服得多。

四、写第一个能跑的 extractor

假设接口返回是这样(我简化过的真实结构):

{
  "data": {
    "videoId": "abc123",
    "title": "第 3 节:流水线设计",
    "duration": 1832,
    "cover": "https://cdn.example.com/cover/abc123.jpg",
    "playUrls": [
      {"quality": "1080p", "format": "hls", "url": "https://cdn.example.com/abc123/1080.m3u8"},
      {"quality": "720p",  "format": "hls", "url": "https://cdn.example.com/abc123/720.m3u8"},
      {"quality": "480p",  "format": "mp4", "url": "https://cdn.example.com/abc123/480.mp4"}
    ]
  }
}

对应的 extractor:

from .common import InfoExtractor
from ..utils import traverse_obj, int_or_none, url_or_none


class ExampleSiteIE(InfoExtractor):
    IE_NAME = 'examplesite'
    _VALID_URL = r'https?://(?:www\.)?example\.com/watch/(?P<id>[\w-]+)'
    _TESTS = [{
        'url': 'https://www.example.com/watch/abc123',
        'info_dict': {
            'id': 'abc123',
            'title': '第 3 节:流水线设计',
            'duration': 1832,
        },
    }]

    def _real_extract(self, url):
        video_id = self._match_id(url)

        # 1) 拿接口数据
        data = self._download_json(
            f'https://api.example.com/play?vid={video_id}',
            video_id,
            headers={'Referer': url},
        )
        info = traverse_obj(data, ('data', {dict})) or {}

        # 2) 组装 formats
        formats = []
        for src in traverse_obj(info, ('playUrls', lambda _, v: url_or_none(v['url']))) or []:
            src_url = src['url']
            fmt = src.get('format')
            if fmt == 'hls':
                formats.extend(self._extract_m3u8_formats(
                    src_url, video_id, 'mp4',
                    m3u8_id=src.get('quality'), fatal=False))
            else:
                formats.append({
                    'url': src_url,
                    'format_id': src.get('quality'),
                })

        return {
            'id': video_id,
            'title': traverse_obj(info, ('title', {str})) or video_id,
            'duration': int_or_none(info.get('duration')),
            'thumbnail': url_or_none(traverse_obj(info, ('cover', {url_or_none}))),
            'formats': formats,
        }

几个关键点,都是我踩过才记牢的:

traverse_obj 而不是一串 .get() yt-dlp 的 traverse_obj 能安全处理字段缺失、类型不对、嵌套路径不存在的情况,比手写 data.get('data', {}).get('title') 健壮得多,而且上游 review 时会要求用它。

_extract_m3u8_formats 直接帮你展开 HLS。 如果地址是 m3u8,别自己拼格式,交给这个方法,它会把 m3u8 里所有码率的子流都列出来。fatal=False 表示某个 m3u8 挂了不要整个失败。

formats 最后要排序。 我这个例子里应该加一句 self._sort_formats(formats),否则 -F 列出来的顺序是乱的。忘了这行是我 PR 被打回的原因之一。

不要硬编码 token 或密钥。 需要签名参数时,从页面或接口里动态取,写死了过几天就失效。

五、本地怎么加载和测试

不需要重新编译 yt-dlp。用插件目录机制:

# Linux / macOS
~/.config/yt-dlp/plugins/myextractor/yt_dlp_plugins/extractor/examplesite.py

# Windows
%APPDATA%\yt-dlp\plugins\myextractor\yt_dlp_plugins\extractor\examplesite.py

把你的 .py 文件放进去,yt-dlp 启动时会自动扫描加载。验证有没有被加载:

yt-dlp --list-extractors | grep -i examplesite

调试三件套:

# 1) 看详细过程(哪个请求发了、返回了什么、哪一步失败)
yt-dlp -v "https://www.example.com/watch/abc123"

# 2) 只看解析出的元数据,不下载
yt-dlp --dump-json "URL" | python3 -m json.tool

# 3) 看格式列表
yt-dlp -F "URL"

--dump-json 是我用得最多的。它会把 _real_extract 返回的字典完整打出来,字段对不对、formats 有没有解析出来、标题有没有抓成网页 title,一眼就能看到。

改代码后不用重启任何东西,直接重跑命令即可(插件每次启动重新加载)。

六、补 _TESTS:上游合并的硬门槛

本地能跑和能被合并是两件事。yt-dlp 对每个 extractor 都要求至少一个测试用例,而且 CI 会真的去请求那个 URL。

_TESTS 的基本结构:

_TESTS = [{
    'url': 'https://www.example.com/watch/abc123',
    'info_dict': {
        'id': 'abc123',
        'ext': 'mp4',
        'title': '第 3 节:流水线设计',
        'duration': 1832,
        'thumbnail': 'https://cdn.example.com/cover/abc123.jpg',
    },
    'params': {'skip_download': True},
}, {
    # 第二个用例:覆盖另一种情形(比如多格式、或者播放列表)
    'url': 'https://www.example.com/watch/def456',
    'info_dict': {'id': 'def456', 'title': 'str'},   # 'str' 表示只校验类型不校验值
    'params': {'skip_download': True},
}]

几个规则:

  • info_dict 里的值会被严格比对。标题多一个空格、少一个标点都会挂。不确定就写类型名('str''int''count')只校验类型。
  • 'params': {'skip_download': True} 几乎必加,否则 CI 会真下载整个视频。
  • 测试 URL 要能长期访问。如果内容是有时效的,加 'skip': '视频已下架' 并说明原因。
  • 不要提交需要登录的测试用例。如果站点强制登录,整个 extractor 大概率不会被接受(见第九节)。

跑测试:

# 只跑你这个 extractor 的用例
python test/test_download.py TestDownload.test_Examplesite

# 跑全部下载测试(很慢,一般只在提 PR 前跑一次)
python test/test_download.py

我第一次提的时候只写了一个用例而且 info_dict 里填了错的时长,CI 直接红。后来本地跑通再提,就顺了。

七、提交 PR:我被退回两次才过的点

我的 PR 一共被退回两次,第三次才合并。两个问题都很有代表性:

第一次退回:代码风格

审查意见里有七八条,核心是两个东西:

  1. 没调用 _sort_formats。审查者原话大概是"formats 顺序不确定会让 -F 输出不稳定"。
  2. traverse_obj 的用法不规范。我一开始写的是 traverse_obj(data, 'data', 'title'),被要求写成带类型校验的形式:traverse_obj(data, ('data', 'title', {str}))

修法很机械,yt-dlp 仓库自带配置:

ruff check --fix .
ruff format .

提交前跑一遍这两条,能省掉一轮 review。

第二次退回:PR 模板没填完整

yt-dlp 的 PR 模板会问一堆问题,我一开始偷懒只填了"站点名 + 测试通过"。被要求补全的关键几项:

  • 站点是否需要登录/付费
  • 是否只支持单视频(还是支持播放列表)
  • 是否有地域限制
  • 是否提供多种格式
  • 提取的媒体地址是直接的还是 m3u8

这些问题不是形式主义——审查者靠它判断这个 extractor 该不该收、以及归类到哪个测试分组。

其他需要注意的

  • 一个 PR 只放一个 extractor。我第二次顺手塞了个修复,被要求拆出去。
  • 文件放对位置并注册。新 extractor 要在 yt_dlp/extractor/_extractors.py 的列表里加上(按字母序),否则 CI 找不到。
  • 审查周期不短。yt-dlp 的 PR 排队比较久,我那个等了差不多三周。期间保持关注,审查意见来了尽快改。

八、站点改版后 extractor 坏了怎么办

写 extractor 不是一次性工作。站点改版、接口换域名、加签名参数,都会让它失效。我维护的那个 extractor 半年里坏了两次。

坏掉的典型症状:

# 1) URL 匹配不上(站点改了路径结构)
Unsupported URL

# 2) 接口 404 或返回结构变了
ERROR: unable to download JSON metadata: HTTP Error 404

# 3) 能解析但 formats 为空
ERROR: No video formats found

排查顺序(和写的时候反过来):

# 1) 看详细日志,定位失败在哪一步
yt-dlp -v "URL"

# 2) 用 curl 复现接口,确认是接口变了还是代码问题
curl -v 'https://api.example.com/play?vid=abc123' -H 'Referer: ...'

# 3) 重新抓一次包,看新的数据源在哪

大部分情况是接口路径或字段名变了,改 traverse_obj 的路径就能修好。少数情况是站上了签名/风控,那就麻烦了。

几个建议:

  1. 用插件方式长期挂着,不要每次都等上游合并。自己的插件目录改完立即生效,比 fork 仓库方便得多。
  2. 加个定期巡检。我在服务器上挂了个每周跑一次的脚本,对几个关键 URL 跑 --dump-json,失败就发通知。这样站点改版我能当天知道,而不是等到要用的时候才发现。
  3. 坏了的 extractor 提 issue 时,务必贴完整的 --verbose 输出。这是 yt-dlp issue 模板的硬性要求,没有日志的 issue 基本会被直接关掉。

九、合规与温馨提示

写 extractor 这件事,技术门槛其实不高,但边界要想清楚。

什么适合做:

  • 你自己或你所在机构拥有版权/已获授权的内容(内训课程、自有媒体、会议录像)
  • 公开可访问、无需登录、无需付费的内容
  • 站点本身提供播放、你只是把它转成可离线保存的形式(且不违反该站服务条款)

什么不要做:

  • 绕过登录或付费墙。yt-dlp 的 PR 模板会明确问这些,答案是"是"的基本不会被收。就算你自己写插件偷偷用,性质也是规避访问控制。
  • 针对明显的盗版站点写 extractor 并公开。
  • 把需要内部鉴权的接口写进公开 PR。我那个培训平台的 extractor 最后就没提交上游——因为接口要带企业内部 token,公开出去等于泄露内部系统信息。这种情况老老实实用本地插件。

关于内部平台的额外提醒:

给公司/机构内部系统写下载工具前,确认两件事:一是你有没有权限批量导出这些内容(很多课程的授权仅限在线观看),二是批量下载会不会对源站造成压力。我一般会主动加 --limit-rate 和并发限制,既避免被封,也别把人家服务器拖垮——自己搭过服务就知道那种痛。


回过头看,整个流程里最有价值的不是那段 extractor 代码,而是把"抓包 → 定位数据源 → 结构化提取"这套方法论跑通了一遍。掌握了这个,碰到任何新站点你都能自己判断:能不能下、难不难、值不值得做。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部