返回博客列表
逆向工程WASMDRM浏览器自动化Python

我做了一个得到视频下载器:从播放器黑盒到可复盘的工程实现

DRM / CENC / WebAssembly / Selenium — 把一整条加密播放链路拆开、看懂、跑通的完整技术复盘

关键词:DRM / CENC / WebAssembly / Selenium / Chrome / Playwright / ffmpeg / mp4decrypt / Vue / 工程逆向

最近我做了一个 得到视频下载器。先说清楚:这不是盈利项目,也不是准备卖的产品,而是一个个人学习研究项目。它的目标很明确——把我已经购买的得到课程视频下载到本地,用于个人离线学习;更重要的是,借这个项目把一整条原本隐藏在播放器内部的技术链路真正拆开、看懂、跑通。

如果只看结果,这像是一个”下载器项目”;但如果看实现过程,它其实更像一次完整的工程复盘:

  • 浏览器端播放器信息是怎么拿到的
  • DRM 授权接口是怎么参与密钥获取的
  • WebAssembly 为什么会成为真正的核心突破口
  • CENC 媒体应该如何稳定解密
  • 一个研究原型又是怎么一步步收敛成可长期使用的工具

这篇文章会尽量做到两件事:

  1. 讲清楚这个项目是怎么做成的
  2. 让技术读者读完后,能按文章线索复盘并重现核心链路

先给结论:这篇文章能让你带走什么

如果你是带着”我读完后到底能拿到什么”这个问题点进来的,先给结论。

读完这篇文章,你至少能明确以下几件事:

  • 得到视频并不是”抓到 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:

  • schmcenc
  • tenc → 包含 KID
  • senc → 包含 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:密钥 ID
  • keyToken:调用 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.keyToken
  • player.config.playDomain
  • player.config.dashOpts.dynamic_video.dynamic_video_list[0].kid
  • player.config.dashOpts.dynamic_video.dynamic_video_list[0].main_url
  • player.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,但调用并不是”直接扔进去就行”。至少有几个关键点:

  • 需要提供 memorytable
  • 需要提供 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.configdynamic_video 配置中拿到:

  • kid
  • keyToken
  • playDomain
  • 视频 URL
  • 音频 URL

Step 3:调用火山引擎 DRM API

请求形式大致如下:

GET https://{playDomain}?{keyToken}&DrmType=22&PlayAuthIds={kid}&ssl=true

返回内容里最重要的是:

  • PlayAuthContent

注意,这不是最终密钥,而只是下一步的输入。

Step 4:在浏览器环境里加载 WASM 并调用 openBox

这是整个项目最关键的一步。

流程是:

  1. 下载 aw.wasm
  2. 创建符合要求的 WebAssembly.Memory
  3. 实例化模块
  4. PlayAuthContent 处理成字节数组
  5. 调用 openBox(ptr, len)
  6. 从返回指针读取结果字符串

最终得到一个 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 对运行时要求并不完全”零环境”

这个模块虽然小,但依然依赖:

  • memory
  • table
  • 一些 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 实例
  • 能否读出 kidkeyTokenplayDomain
  • 能否拿到视频/音频流 URL

如果这一步失败,就不要急着继续做 DRM 和解密。

第二阶段:确认 DRM 授权链

检查目标:

  • 用提取出的参数请求 DRM API
  • 成功拿到 PlayAuthContent
  • 确认它与 kid 对应

第三阶段:单独验证 WASM 解密

检查目标:

  • 获取 aw.wasm
  • 在浏览器环境里实例化
  • 验证 openBox(PlayAuthContent) 是否能稳定得到 AES key

第四阶段:验证媒体解密

检查目标:

  • 下载加密视频流 / 音频流
  • mp4decrypt 解密
  • 用 ffmpeg 合并输出

第五阶段:再考虑工程化

最后再做这些:

  • GUI
  • Cookie 管理
  • 浏览器复用
  • 多线程下载
  • 打包分发

这一顺序的好处是:每一步都能独立验证,出问题时也更容易定位。


十三、最后

这个得到视频下载器,不是一个”赚钱点子”,也不是一个拿来秀功能的 demo。它更像一次很完整的个人技术研究:

  • 从一个具体需求出发
  • 深入播放器黑盒内部
  • 做出关键工程决策
  • 最后把研究结果变成自己能长期使用的工具

如果一定要说这个项目最让我满意的是什么,我觉得不是”我把视频下下来了”,而是:

我把一个原本只能被使用的系统,变成了一个我真正理解、并能复现的系统。

而这正是我最喜欢的那类项目:

  • 不只是得到结果
  • 更重要的是知道结果是怎么来的

如果你也对浏览器逆向、播放器链路、WASM 或 DRM 工程实践感兴趣,这类项目会非常上头。因为当你最终把黑盒拆开时,收获的不只是一个工具,而是一整套对系统的理解。