事情的起因很普通:一个客户内部培训平台的课程,我要批量存档。把链接丢给 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 是真的不支持,还是你姿势不对
- 二、extractor 的最小骨架
- 三、抓包:数据源到底藏在哪
- 四、写第一个能跑的 extractor
- 五、本地怎么加载和测试
- 六、补 _TESTS:上游合并的硬门槛
- 七、提交 PR:我被退回两次才过的点
- 八、站点改版后 extractor 坏了怎么办
- 九、合规与温馨提示
一、先确认: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.json 写 traverse_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 一共被退回两次,第三次才合并。两个问题都很有代表性:
第一次退回:代码风格
审查意见里有七八条,核心是两个东西:
- 没调用
_sort_formats。审查者原话大概是"formats 顺序不确定会让-F输出不稳定"。 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 的路径就能修好。少数情况是站上了签名/风控,那就麻烦了。
几个建议:
- 用插件方式长期挂着,不要每次都等上游合并。自己的插件目录改完立即生效,比 fork 仓库方便得多。
- 加个定期巡检。我在服务器上挂了个每周跑一次的脚本,对几个关键 URL 跑
--dump-json,失败就发通知。这样站点改版我能当天知道,而不是等到要用的时候才发现。 - 坏了的 extractor 提 issue 时,务必贴完整的
--verbose输出。这是 yt-dlp issue 模板的硬性要求,没有日志的 issue 基本会被直接关掉。
九、合规与温馨提示
写 extractor 这件事,技术门槛其实不高,但边界要想清楚。
什么适合做:
- 你自己或你所在机构拥有版权/已获授权的内容(内训课程、自有媒体、会议录像)
- 公开可访问、无需登录、无需付费的内容
- 站点本身提供播放、你只是把它转成可离线保存的形式(且不违反该站服务条款)
什么不要做:
- 绕过登录或付费墙。yt-dlp 的 PR 模板会明确问这些,答案是"是"的基本不会被收。就算你自己写插件偷偷用,性质也是规避访问控制。
- 针对明显的盗版站点写 extractor 并公开。
- 把需要内部鉴权的接口写进公开 PR。我那个培训平台的 extractor 最后就没提交上游——因为接口要带企业内部 token,公开出去等于泄露内部系统信息。这种情况老老实实用本地插件。
关于内部平台的额外提醒:
给公司/机构内部系统写下载工具前,确认两件事:一是你有没有权限批量导出这些内容(很多课程的授权仅限在线观看),二是批量下载会不会对源站造成压力。我一般会主动加 --limit-rate 和并发限制,既避免被封,也别把人家服务器拖垮——自己搭过服务就知道那种痛。
回过头看,整个流程里最有价值的不是那段 extractor 代码,而是把"抓包 → 定位数据源 → 结构化提取"这套方法论跑通了一遍。掌握了这个,碰到任何新站点你都能自己判断:能不能下、难不难、值不值得做。