我做了一个得到视频下载器:从播放器黑盒到可复盘的工程实现
DRM / CENC / WebAssembly / Selenium — 把一整条加密播放链路拆开、看懂、跑通的完整技术复盘
关键词:DRM / CENC / WebAssembly / Selenium / Chrome / Playwright / ffmpeg / mp4decrypt / Vue / 工程逆向
最近我做了一个 得到视频下载器。先说清楚:这不是盈利项目,也不是准备卖的产品,而是一个个人学习研究项目。它的目标很明确——把我已经购买的得到课程视频下载到本地,用于个人离线学习;更重要的是,借这个项目把一整条原本隐藏在播放器内部的技术链路真正拆开、看懂、跑通。
如果只看结果,这像是一个”下载器项目”;但如果看实现过程,它其实更像一次完整的工程复盘:
- 浏览器端播放器信息是怎么拿到的
- DRM 授权接口是怎么参与密钥获取的
- WebAssembly 为什么会成为真正的核心突破口
- CENC 媒体应该如何稳定解密
- 一个研究原型又是怎么一步步收敛成可长期使用的工具
这篇文章会尽量做到两件事:
- 讲清楚这个项目是怎么做成的
- 让技术读者读完后,能按文章线索复盘并重现核心链路
先给结论:这篇文章能让你带走什么
如果你是带着”我读完后到底能拿到什么”这个问题点进来的,先给结论。
读完这篇文章,你至少能明确以下几件事:
- 得到视频并不是”抓到 MP4 就完事”,而是标准化的 CENC 加密媒体
- 播放器真正的关键不在普通 JS,而在一个 只有 9KB 的 WASM 模块
- 这条链路的核心顺序是:
页面提取 -> DRM API -> PlayAuthContent -> WASM openBox -> AES Key -> mp4decrypt -> ffmpeg - 为什么这个项目的技术路线会从 Playwright 原型 演化成 Selenium + Chrome 工程版
- 如果你要自己复盘,应该先验证哪几步、每一步看什么结果、哪里最容易踩坑
如果把整篇文章压缩成一张图,就是下面这样:
flowchart TD
A[得到课程页面] --> B[Vue 组件树中的 player 实例]
B --> C[提取关键参数\nkid / keyToken / playDomain / media urls]
C --> D[调用火山引擎 DRM API]
D --> E[返回 PlayAuthContent]
E --> F[浏览器环境加载 9KB WASM]
F --> G[openBox 解出 AES Key]
C --> H[下载加密视频流]
C --> I[下载加密音频流]
G --> J[mp4decrypt 按 CENC 解密]
H --> J
I --> J
J --> K[ffmpeg 合并音视频]
K --> L[最终可播放 MP4]
一句话总结:
这个工具不是单纯”下载视频”,而是在本地复刻了一条浏览器播放器的关键解密路径。
一、项目目标与边界
项目目标只有一个:下载自己已购买的得到课程视频,用于个人离线学习。
这里要强调两个边界:
- 这是一个个人学习研究项目,不是商业产品。
- 这篇文章关注的是技术链路复盘与工程实现,不是泛泛而谈”怎么下载视频”。
我最初并不是想”做一个下载器创业项目”,而是单纯被这个问题吸引:
一个在浏览器里能正常播放的视频,背后到底经历了怎样的加密、授权和解密过程?
而这个问题越往下挖,就越像一堂把前端、浏览器自动化、WASM、流媒体和工程化串起来的综合实践课。
二、起点:我抓到了 MP4,但它根本不能播
最开始的直觉很朴素:既然浏览器能播放,那就一定有媒体文件;抓到 URL,下载下来,不就结束了?
现实并不是这样。
我先用浏览器工具抓到了 4 个文件:
- 两个视频流
- 两个音频流
- 容器格式都是正常 MP4
- 但视频打开后花屏,音频也无法正常播放
这说明一个关键事实:
拿到媒体文件地址,不等于拿到可播放内容。
继续分析 MP4 结构后,可以看到典型的加密 box:
schm→cenctenc→ 包含KIDsenc→ 包含 sample IV
这基本可以确认:得到这条链路使用的是 CENC(Common Encryption)。也就是说,下载下来的不是明文视频,而是加密媒体。
从这一刻开始,项目性质就变了:
从"下载文件"
变成了
"复现播放器的密钥获取与解密链路"
三、整个系统真正的核心,竟然是一个只有 9KB 的 WASM 文件
这个项目里最抓人的细节之一,是我最后发现:
整条密钥链路的真正核心,最后浓缩在一个只有 9,294 bytes(约 9KB)的 WebAssembly 文件里。
文件名是:
aw.3be8a024.wasm
CDN 地址类似:
https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer/dash/aw.3be8a024.wasm
一个体积只有 9KB 的 WASM 模块,却承担了整个加密播放体系里最关键的一步:
- 输入:
PlayAuthContent - 处理:调用
openBox() - 输出:真正可用的 AES key
这件事很有冲击力。因为表面上看,整个系统非常复杂:
- 前端 SPA
- Vue 组件树
- 播放器内核
- DRM 授权接口
- CDN 加密媒体
- 音视频分离流
- 最终解密与合并
但真正负责”把密钥材料打开”的核心逻辑,可能就藏在一个 9KB 的文件里。
这也是整个项目最关键的突破口。
四、技术路线为什么从 Playwright 走到了 Selenium + Chrome
如果只看最终版本,会以为这个项目从一开始就是 Selenium 路线。其实不是。
4.1 第一阶段:Playwright 更适合做”研究原型”
项目最早的版本,是命令行原型,核心工具是 Playwright。
原因很简单:
- 启动快
- 注入脚本方便
- 对页面状态观测友好
- 非常适合快速验证一个想法能不能跑通
在”我要先证明这条链路能走通”的阶段,Playwright 很合适。它让我更快完成这些实验:
- 打开得到页面
- 抓取页面内播放器信息
- 观察播放行为和请求过程
- 验证是否有机会还原真实解密链
所以可以把 Playwright 阶段理解为:
快速验证问题是否可解。
4.2 第二阶段:项目目标变了,技术选择也要跟着变
后来我发现,项目不再只是”写个能跑的验证脚本”,而是逐渐变成一个自己长期想用的桌面工具。
目标一变,选择标准就变了。
这时候要考虑的已经不是”哪个写着爽”,而是:
- 和本地 Chrome 的协同是否自然
- 人工登录 + Cookie 复用是否稳定
- 做成 GUI 工具后整体是否顺滑
- PyInstaller 打包后是否更容易收敛
- 用户侧依赖是否尽量少
在这个阶段,Selenium + Chrome 反而成了更合适的方案。
4.3 工程决策对比:为什么最后是 Selenium + Chrome
| 维度 | Playwright(原型期) | Selenium + Chrome(落地期) |
|---|---|---|
| 核心任务 | 快速验证链路可行性 | 稳定桌面工具化 |
| 优势 | 启动快、脚本注入方便、实验效率高 | 更贴近本地 Chrome、人工登录配合自然 |
| 登录配合 | 适合实验性自动化 | 更适合”人工登录 + Cookie 复用” |
| GUI 整合 | 需要更多额外收敛 | 与 tkinter + PyInstaller 路径更顺 |
| 用户侧体验 | 适合开发者原型 | 更适合自己长期使用 |
| 本项目中的角色 | 证明问题可解 | 把原型收敛成真正可用工具 |
所以这个项目的技术演变,其实可以概括成一句话:
Playwright 用来快速验证
Selenium + Chrome 用来稳定落地
这不是”推翻重来”,而是一次很典型的工程决策演化。
五、关键前提:先找到 Vue 组件树里的 player 实例
得到是一个 Vue SPA,播放器实例并没有老老实实挂在全局变量上。所以真正的第一步,不是”抓请求”,而是先在页面运行时里找到播放器对象。
核心思路是:
- 从
document.body开始递归遍历 DOM - 检查节点上的
__vue__属性 - 找到持有
$refs.IgetMediaPlayer.player的组件
核心逻辑示意:
function findPlayer(el) {
if (!el) return null;
if (el.__vue__) {
const vm = el.__vue__;
if (vm.$refs?.IgetMediaPlayer?.player) {
return vm.$refs.IgetMediaPlayer.player;
}
}
for (const child of (el.children || [])) {
const r = findPlayer(child);
if (r) return r;
}
return null;
}
拿到 player 后,真正重要的不是播放器对象本身,而是它携带的配置:
kid:密钥 IDkeyToken:调用 DRM 接口所需签名参数playDomain:DRM 接口域名videoUrl/audioUrl:媒体流地址title:页面标题,可作为文件名参考
这几个字段是整条解密链的入口。
关键名词解释
为了后面不混乱,先把几个术语说清楚:
- KID:Key ID,表示这段加密内容对应哪把密钥。
- PlayAuthContent:DRM 接口返回的密钥材料,不是最终明文 key。
- AES Key:真正用于解密音视频流的密钥。
- CENC:Common Encryption,标准化的加密媒体方案。
它们之间的关系是:
flowchart LR
A[KID] --> B[DRM API 查询授权]
B --> C[PlayAuthContent]
C --> D[WASM openBox]
D --> E[AES Key]
E --> F[CENC 媒体解密]
六、关键实现抓手:如果你要复盘,先盯住这 5 个点
如果你的目标不是”看个故事”,而是真想自己复盘实现,那么我建议你优先盯住下面 5 个抓手。
抓手 1:player 的关键字段从哪里来
核心字段并不是凭空出现,而是来自播放器配置对象:
player.config.keyTokenplayer.config.playDomainplayer.config.dashOpts.dynamic_video.dynamic_video_list[0].kidplayer.config.dashOpts.dynamic_video.dynamic_video_list[0].main_urlplayer.config.dashOpts.dynamic_video.dynamic_audio_list[0].main_url
如果这几个字段拿不到,后面的链路都走不通。
抓手 2:DRM API 请求长什么样
请求形态大致如下:
GET https://{playDomain}?{keyToken}&DrmType=22&PlayAuthIds={kid}&ssl=true
它返回的关键字段是:
Result.PlayAuthInfoList[0].PlayAuthContent
这一步的检查点是:
- 你是否成功拿到了与
kid对应的PlayAuthContent - 它是否是一个可继续送入
openBox()的 Base64 字符串
抓手 3:WASM 的调用约束是什么
aw.wasm 体积只有 9KB,但调用并不是”直接扔进去就行”。至少有几个关键点:
- 需要提供
memory和table - 需要提供 Emscripten 相关导入函数
WebAssembly.Memory必须是:
new WebAssembly.Memory({ initial: 1, maximum: 1 })
这个约束非常关键。配置大了会直接报 LinkError。
抓手 4:最终可用的不是 PlayAuthContent,而是 openBox 的返回值
流程不要搞反:
PlayAuthContent -> openBox() -> AES Key
最终的 AES key 一般是一个 32 字符 hex 字符串,对应 16 字节的 AES-128 key。
抓手 5:解密工具为什么是 mp4decrypt 而不是 ffmpeg
在这个项目里,我最后使用的是:
mp4decrypt --key {KID}:{AES_KEY} encrypted.mp4 decrypted.mp4
ffmpeg -i video_dec.mp4 -i audio_dec.mp4 -c copy output.mp4
选择 mp4decrypt 的原因很直接:
- 对 CENC 支持更稳
- 处理 subsample encryption 更可靠
- 在这个场景里比 ffmpeg 的
-decryption_key更少踩坑
七、完整解密链路:从页面参数到最终视频
当 player 信息拿到后,整个链路才能真正跑起来。
Step 1:打开页面并触发真实播放行为
这里的目的是让播放器真正开始工作,并暴露需要的信息。
典型操作包括:
- 注入 XHR 拦截脚本,记录媒体请求 URL
- 触发
video.play() - 等待播放器完成必要的初始化
Step 2:提取关键参数
从 player.config 和 dynamic_video 配置中拿到:
kidkeyTokenplayDomain- 视频 URL
- 音频 URL
Step 3:调用火山引擎 DRM API
请求形式大致如下:
GET https://{playDomain}?{keyToken}&DrmType=22&PlayAuthIds={kid}&ssl=true
返回内容里最重要的是:
PlayAuthContent
注意,这不是最终密钥,而只是下一步的输入。
Step 4:在浏览器环境里加载 WASM 并调用 openBox
这是整个项目最关键的一步。
流程是:
- 下载
aw.wasm - 创建符合要求的
WebAssembly.Memory - 实例化模块
- 把
PlayAuthContent处理成字节数组 - 调用
openBox(ptr, len) - 从返回指针读取结果字符串
最终得到一个 32 字符 hex 字符串,对应 16 字节的 AES-128 key。
Step 5:下载加密媒体流
此时可以下载:
- 视频流
- 音频流
但它们仍然是加密状态。
Step 6:用 mp4decrypt 解密
这里我最终选择了 Bento4 的 mp4decrypt。原因很现实:
- ffmpeg 的
-decryption_key在 CENC 场景下并不总稳定 mp4decrypt对 CENC 的处理更靠谱
命令形态类似:
mp4decrypt --key {KID}:{AES_KEY} encrypted.mp4 decrypted.mp4
Step 7:用 ffmpeg 合并音视频
解密后的音视频流即可无损合并,输出最终 MP4。
完整流程图如下:
sequenceDiagram
participant U as 用户/工具
participant P as 得到页面播放器
participant A as DRM API
participant W as 9KB WASM
participant C as CDN 媒体流
participant D as 本地解密流水线
U->>P: Selenium 打开页面并触发播放
P->>U: 暴露 kid / keyToken / playDomain / media urls
U->>A: 请求 PlayAuthContent
A->>U: 返回 PlayAuthContent
U->>W: openBox(PlayAuthContent)
W->>U: 返回 AES Key
U->>C: 下载加密 video/audio
C->>U: 返回加密媒体文件
U->>D: mp4decrypt 按 KID + AES Key 解密
D->>D: ffmpeg 合并音视频
D->>U: 输出最终 MP4
八、项目实现架构:运行时到底有哪些模块
除了流程视角,再看一张”实现结构图”,更容易理解这是怎么拼起来的。
flowchart TB
UI[tkinter GUI] --> CORE[下载主流程控制器]
CORE --> BROWSER[Selenium + Chrome]
CORE --> API[DRM API 请求模块]
CORE --> DL[下载模块\n单线程/分块并行]
CORE --> DEC[解密模块\nmp4decrypt]
CORE --> MERGE[合并模块\nffmpeg]
BROWSER --> EXTRACT[Vue player 提取脚本]
BROWSER --> WASM[浏览器内 WASM openBox 调用]
EXTRACT --> CORE
API --> CORE
DL --> CORE
DEC --> CORE
MERGE --> CORE
这样看会更清楚:
- 浏览器模块负责进入真实运行时、拿参数、跑 WASM
- 本地流水线负责下载、解密、合并
- 控制层把整条链路组织成一个可重复执行的工具
九、为什么 WASM 必须在”接近真实播放器”的环境里跑
理论上,你可以尝试把 WASM 完全抽离出来做离线调用。但在这个项目里,我最终选择的是:
直接在 Selenium 驱动的浏览器上下文中执行 JavaScript,完成 WASM 实例化和 openBox 调用。
原因有两个:
原因 1:尽量贴近播放器的真实运行环境
既然播放器本来就是在浏览器里这样做的,那么在浏览器环境里复刻,最容易减少上下文偏差。
原因 2:WASM 对运行时要求并不完全”零环境”
这个模块虽然小,但依然依赖:
memorytable- 一些 Emscripten 相关导入函数
而且还有一个容易踩坑的细节:
WebAssembly.Memory的配置必须是{initial: 1, maximum: 1}。
如果开大了,直接报 LinkError。
这类细节也说明:这个模块虽然只有 9KB,但它并不是一个随便扔到哪都能立即调用的”纯函数黑盒”。
十、工程化落地:v1、v2、v3 分别解决了什么问题
项目不是一次写完的,而是经历了明显的版本演进。
flowchart LR
A[v1 命令行原型\nPlaywright\n验证链路可行] --> B[v2 GUI 版\nSelenium + tkinter\n能稳定使用]
B --> C[v3 极速版\n多线程下载 + 浏览器复用 + WASM缓存]
v1:先证明这件事能做
- 命令行形式
- 基于 Playwright
- 核心目标是验证链路:能否拿到 player 信息、能否走通授权与解密
v2:把它做成一个可长期使用的工具
- 切到 Selenium
- 做出 tkinter GUI
- 接入登录、下载、状态显示
- 支持本地桌面使用
v3:提升速度与体验
- 多线程分块下载
- 音视频并行拉取
- 浏览器复用
- WASM 缓存
- 下载速度实时显示
这个版本演进本身也说明了一个工程原则:
原型验证和长期落地,往往不是同一套最优技术决策。
十一、几个关键踩坑,决定了项目能不能真正落地
如果要让我总结这个项目最值钱的经验,我会说不是主流程,而是这些边角问题。
1. 抓到的 key 不一定是最终用于解密的 key
直接 hook crypto API,很可能抓到的是错误路径里的密钥材料。
2. PlayAuthContent 不是 AES key
它只是喂给 openBox() 的输入,不是最终结果。
3. ffmpeg 并不是最好的 CENC 解密工具
在这个场景下,mp4decrypt 明显更稳。
4. WASM 内存配置稍微不对就直接报错
maximum=1 这个约束,如果没注意,会卡很久。
5. Windows 中文路径会影响部分工具
例如 mp4decrypt 在中文路径下会翻车,所以临时目录最好使用英文路径。
这些东西每一条单看都不”高深”,但它们共同决定了项目最后是停留在 demo,还是变成一个真正可用的工具。
十二、如果你想复盘重现,建议按这个顺序来
如果你希望按这篇文章自己复盘,不建议一上来就直接写完整工具。更合理的顺序是:
第一阶段:确认页面和播放器结构
检查目标:
- 能否找到
player实例 - 能否读出
kid、keyToken、playDomain - 能否拿到视频/音频流 URL
如果这一步失败,就不要急着继续做 DRM 和解密。
第二阶段:确认 DRM 授权链
检查目标:
- 用提取出的参数请求 DRM API
- 成功拿到
PlayAuthContent - 确认它与
kid对应
第三阶段:单独验证 WASM 解密
检查目标:
- 获取
aw.wasm - 在浏览器环境里实例化
- 验证
openBox(PlayAuthContent)是否能稳定得到 AES key
第四阶段:验证媒体解密
检查目标:
- 下载加密视频流 / 音频流
- 用
mp4decrypt解密 - 用 ffmpeg 合并输出
第五阶段:再考虑工程化
最后再做这些:
- GUI
- Cookie 管理
- 浏览器复用
- 多线程下载
- 打包分发
这一顺序的好处是:每一步都能独立验证,出问题时也更容易定位。
十三、最后
这个得到视频下载器,不是一个”赚钱点子”,也不是一个拿来秀功能的 demo。它更像一次很完整的个人技术研究:
- 从一个具体需求出发
- 深入播放器黑盒内部
- 做出关键工程决策
- 最后把研究结果变成自己能长期使用的工具
如果一定要说这个项目最让我满意的是什么,我觉得不是”我把视频下下来了”,而是:
我把一个原本只能被使用的系统,变成了一个我真正理解、并能复现的系统。
而这正是我最喜欢的那类项目:
- 不只是得到结果
- 更重要的是知道结果是怎么来的
如果你也对浏览器逆向、播放器链路、WASM 或 DRM 工程实践感兴趣,这类项目会非常上头。因为当你最终把黑盒拆开时,收获的不只是一个工具,而是一整套对系统的理解。