网络上有一个非常强大的工具叫做 res-downloader,它能够把很多现存网站上的视频下载下来。但是,我在下载某破站的视频时,发现出现了一个小错误:一个完整的视频被拆分成了好几个 m4s 的包。于是,我就让我的 Agent 帮我处理这个问题。以下是我跟 Agent 对话的节选,我们可以从中看到是如何解决这个问题的,以及我们在这个过程中学到了什么。
1. 先理解你当时看到的"37KB"到底是什么
最开始我们看到类似:
text
bilivideo.com
37.27 KB
mountaintoys.cn
1.15 KB
1.30 KB
1.41 KB
直觉很容易认为:
"软件只抓到了几个垃圾文件,没有抓到真正的视频。"
实际上不是。
浏览器确实已经在下载真正的视频,只不过不是一次把完整视频下载下来。
2. 破站现在播放视频,大量使用 DASH
传统的视频可能是:
text
video.mp4
里面同时包含:
text
视频轨
+
音频轨
浏览器请求一次:
text
GET video.mp4
服务器返回:
text
200 OK
Content-Length: 50000000
这种情况非常容易理解。
但 破站常见的是 DASH:
text
破站一个视频
│
├── video.m4s ← 只有画面
│
└── audio.m4s ← 只有声音
而且还不只是两条。
可能同时有:
text
video 360P
video 480P
video 720P
video 1080P
video HEVC
video AV1
audio 64K
audio 128K
audio 高码率
所以你打开一个 破站页面之后,抓包程序可能一下看到很多:
text
xxx-30032.m4s
xxx-30033.m4s
xxx-30280.m4s
...
这不是重复垃圾。
它们可能是:
不同清晰度、不同编码、音频和视频轨。
3. 更麻烦的是:浏览器连一个 .m4s 都不会一次下载完
假设完整视频轨大小:
text
14,383,614 bytes
浏览器不一定发送:
http
GET /video.m4s
要求全部文件。
它可能发送:
http
GET /video.m4s
Range: bytes=2794479-3535414
意思是:
"我现在只要这个文件从第 2,794,479 个字节到第 3,535,414 个字节。"
服务器于是返回:
http
HTTP/1.1 206 Partial Content
Content-Range:
bytes 2794479-3535414/14383614
这一行是整个 Bug 的关键。
4. Content-Range 到底是什么意思
把它拆开:
text
bytes 2794479-3535414/14383614
└──────┬──────┘ └───┬───┘
本次返回范围 完整文件总大小
所以:
text
2794479
是本次起始字节。
text
3535414
是本次结束字节。
而:
text
14383614
才是:
这个
.m4s完整媒体文件的逻辑总大小。
5. 原程序错在哪里
原来的思路大致相当于:
go
size := response.ContentLength
对于普通:
http
200 OK
这通常没什么问题。
比如:
http
Content-Length: 14383614
那就说明:
text
资源大小 = 14383614
但到了:
http
206 Partial Content
Content-Length 表示的是:
本次返回的这一块有多大。
不是:
完整
.m4s有多大。
于是原来的程序逻辑变成:
text
浏览器请求了一小块
服务器:
这次我给你几十 KB
res-downloader:
好的,这个视频只有几十 KB
所以你才看到了:
text
1.15 KB
37.27 KB
6. 我们怎么修
新的逻辑变成:
go
if statusCode == 206 {
parse Content-Range
}
类似:
go
Content-Range:
bytes 2794479-3535414/14383614
解析:
go
start = 2794479
end = 3535414
total = 14383614
然后:
go
MediaInfo.Size = total
也就是:
text
MediaInfo.Size = 14383614
而不是:
text
MediaInfo.Size = 本次几十 KB
所以 Hotfix 后你重新真实测试时,列表就变成了:
text
39.84 MB
157.53 MB
12.99 MB
...
这说明现在显示的是:
完整媒体轨的逻辑大小。
而不再是:
浏览器这一次碰巧请求了多少字节。
7. 这里特别重要:我们没有假装 206 是 200
代码不是简单:
go
if 206 {
Size = 随便一个大值
}
而是明确区分两个概念:
text
Response Size
和:
text
Logical Resource Size
可以把它理解成操作系统里的:
text
一页内存大小
和:
text
整个虚拟地址空间大小
不是同一个层级。
8. 为什么同一个视频会出现很多 Range 请求
比如浏览器播放:
text
00:00 → 00:10
可能请求:
text
Range A
然后你拖到:
text
05:30
浏览器可能马上请求:
text
Range B
再继续播放:
text
Range C
Range D
...
所以网络层可能看到:
text
同一个 URL
+
Range 0-xxxxx
同一个 URL
+
Range yyyyy-zzzzz
同一个 URL
+
Range aaaaa-bbbbb
这些实际上都是:
text
同一个媒体对象
9. 所以第二处修复是"资源合并"
不能:
text
Range A → Resource 1
Range B → Resource 2
Range C → Resource 3
否则列表会越来越长。
我们之前已经有:
text
UrlSign
和中央:
text
Resource Registry
于是新的处理逻辑更像:
text
收到网络响应
↓
计算稳定 Asset identity
↓
有没有这个媒体?
│
┌──┴──┐
│ │
没有 已有
│ │
新建 更新 metadata
│
└── Size 取可靠 total
于是:
text
第一次:
Content-Range:
bytes .../.../14383614
Size = 14383614
第二次可能只有一小块:
text
Content-Length = 2048
也不能再把:
text
14383614
覆盖成:
text
2048
10. 为什么我们还修了下载器的 Range
这也是一个比较典型的网络编程坑。
抓包时,我们保存了一些原始 Request Headers。
里面可能包括:
http
Range: bytes=2794479-3535414
If-Range: ...
If-Modified-Since: ...
If-None-Match: ...
这些 Header 是:
浏览器当时播放这个视频时使用的条件。
但是之后用户点:
text
下载
res-downloader 本身又有自己的下载器。
如果我们把浏览器的:
http
Range: bytes=2794479-3535414
原封不动带过去,就会发生:
text
用户:下载整个视频
Downloader:
好的。
但是 HTTP Header:
我只要 2794479-3535414
服务器:
206,给你这一段。
Downloader:
下载完成!
于是最后真的只保存了:
text
一个残缺 m4s
11. 所以下载时现在会过滤这些 Header
类似:
text
Range
If-Range
If-Match
If-None-Match
If-Modified-Since
If-Unmodified-Since
不会把浏览器当时的 Range 请求条件拿来当作完整下载请求。
下载器需要分片时:
由下载器自己产生 Range。
例如:
text
Downloader
├─ bytes 0-4MB
├─ bytes 4MB-8MB
├─ bytes 8MB-12MB
└─ bytes 12MB-end
这才是受控的并发分段下载。
12. 但为什么我们后来还是没有让 res-downloader 自己拼 破站视频?
因为即使解决 Range,还有另一个问题:
text
一个 m4s ≠ 一个最终视频
例如:
text
video.m4s
可能:
text
有画面
没声音
而:
text
audio.m4s
可能:
text
有声音
没画面
你最终需要的是:
text
video.m4s
+
audio.m4s
↓
FFmpeg
↓
video.mp4
13. 所以架构最后变成了两层
res-downloader
负责:
text
网络观察层
即:
text
这个页面出现了什么资源?
这个资源有多大?
它来自什么页面?
这个页面是哪一个 破站 BV/P?
BiliMind
负责:
text
平台理解层
即:
text
BV 是什么?
P 是多少?
CID 是多少?
有哪些画质?
哪个是 video-only?
哪个是 audio-only?
当前账号允许下载什么?
然后:
text
yt-dlp
↓
选择 video
+
选择 audio
↓
完整下载
↓
FFmpeg -c copy
↓
video.mp4
14. 这就是为什么我们后来增加了"破站完整下载"
以前:
text
res-downloader
抓到 m4s
↓
点下载
现在:
text
res-downloader
发现:
BV1xxxx P2
↓
生成 ContentRef:
bilibili:BV1xxxx:p2
↓
点击:
破站完整下载
↓
HTTP API
↓
BiliMind
↓
yt-dlp
↓
DASH视频轨
+
DASH音频轨
↓
FFmpeg mux
↓
video.mp4
这里网络抓包只负责:
发现。
平台 Downloader 负责:
正确下载。
这是软件架构上很关键的一次拆分。
15. 还有一个你当时同时遇到的问题:为什么 BiliMind 一直是 -
这个和 37KB 是两个不同 Bug。
当时我们的 Source 是:
text
https://www.bilibili.com/
而不是:
text
https://www.bilibili.com/video/BVxxxx
所以 Resolver 看见:
text
platform=bilibili
但 URL:
text
https://www.bilibili.com/
于是:
text
Reject
这是正确的。
因为否则首页同时推荐:
text
视频 A
视频 B
视频 C
视频 D
我们根本不知道你抓到的 m4s 属于哪个视频。
16. 为什么 破站媒体请求的 Referer 只有首页
我们真实 Chrome DevTools 已经验证了:
text
Referer:
https://www.bilibili.com/
而不是具体 BV。
所以不能:
go
source := request.Header.Get("Referer")
然后期待得到:
text
/video/BVxxx
浏览器/播放器不会总给你这个信息。
17. 所以后来又加了 Bilibili Page Context Capture
我们在真正打开:
text
www.bilibili.com/video/BVxxxx
这个 HTML 页面时,记录:
text
ContentKey
CanonicalURL
BVID
P
例如:
text
ContentKey:
bilibili:BV123:p2
同时页面内通过:
text
PerformanceObserver
观察它实际加载了哪些:
text
bilivideo.com
mountaintoys.cn
资源 URL。
于是建立:
text
当前页面 BV123 P2
│
├── 实际观察到 m4s A
├── 实际观察到 m4s B
└── 实际观察到 m4s C
比:
text
Referer == 破站首页
可靠得多。
18. 为什么不能简单使用"最近打开的 BV"
假设:
text
Tab A
BVAAA
Tab B
BVBBB
如果代码只有:
go
lastBilibiliVideo = BVBBB
那么 Tab A 后续加载一个 m4s:
text
A 的视频
可能被错误绑定:
text
BVBBB
这种 Bug 比"不识别"危险得多。
所以我们采用原则:
无法证明的时候宁可不绑定。
这叫:
text
fail-closed
而不是:
text
尽量猜一个
19. 把整个 Bug 修复画成一张图
原来:
text
Chrome
│
│ Range request
▼
m4s 局部分片
│
│ Content-Length = 几 KB
▼
res-downloader
│
├── Size = 几 KB ❌
└── Referer = 首页 ❌
│
▼
无 ContentRef
│
▼
BiliMind = -
现在:
text
Chrome
│
├──── /video/BVxxx 页面
│ │
│ ▼
│ Page Context Capture
│ │
│ ▼
│ ContentRef
│ BVxxx / P
│
└──── m4s Range Request
│
▼
206 Partial Content
│
▼
Content-Range
│
▼
total = 完整媒体大小
│
▼
MediaInfo.Size
│
▼
正常显示 MB
完整下载则另走:
text
ContentRef
│
▼
BiliMind API
│
▼
yt-dlp
│
┌──┴──┐
▼ ▼
video audio
m4s m4s
└──┬──┘
▼
FFmpeg
-c copy
▼
video.mp4
20. 从计算机专业角度,这次你可以记住 5 个知识点
第一,HTTP Response 大小不一定等于资源大小
尤其:
text
206 Partial Content
必须看:
text
Content-Range
第二,网络请求和逻辑资源是两个层级
text
HTTP Request
是网络事件。
text
Media Asset
是逻辑对象。
它们可以是:
text
N : 1
也就是:
多个 HTTP Range Request 对应一个媒体文件。
第三,资源文件和内容作品又是两个层级
text
video.m4s
audio.m4s
cover.jpg
都是 Asset。
但它们可能属于:
text
BV123 P2
这个 Content。
所以:
text
Asset ≠ Content
第四,不要把浏览器行为直接重放成下载行为
浏览器 Playback Request:
text
Range
If-Range
...
是为了:
流媒体播放。
Downloader Request:
是为了:
获取完整文件。
二者不能直接复用全部 Header。
第五,成熟平台最好用平台解析器,不要单靠抓包
普通网页:
text
抓 URL
→ 下载
可能够用。
但 破站:
text
多P
CID
DASH
video-only
audio-only
会员权限
Cookie
多编码
已经属于:
平台协议。
这时更合理的架构就是:
text
res-downloader
负责发现
BiliMind / yt-dlp
负责理解和下载
这也是为什么我们最终没有继续把 res-downloader 做成一个巨大的"万能 破站解析器"。