Files
whale-fall-code 2bb83f3685 初始提交:抖音视频批量采集流水线
App 端免签名接口(设备注册 + detail + CDN 直链),一体化流水线
下载→抽音→传COS→记录→删本地,AI 作品四档判定,四份文档:
README(使用指南)、PROGRESS(批次进度)、RE(逆向全过程)、AI(判定规则)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:31:10 +08:00

247 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 抖音接口逆向全过程
> 本文按时间顺序复盘这个项目把「批量下载抖音无水印视频」从 0 走通的全过程。
> 每条结论都标注了验证方式:【实测】= 打过真实接口,【推断】= 有旁证但没做对照实验。
> 对应的代码在 `douyin/` 包里,结论速查表在 [PROGRESS.md](PROGRESS.md) §1。
---
## 0. 目标与约束
把外部抓来的几十万条抖音链接跑成 ASR 数据集:下载无水印 mp4 → 抽 mp3 → 传 COS。
三条硬约束:**不登录**(没有号,批量场景号必死)、**不依赖第三方解析服务**(不稳定、有泄露风险)、
**不依赖 JS 运行时**(部署环境只有 Python + ffmpeg)。
---
## 1. 第一阶段:Web 端——撞在「强制登录」上
最先试的自然是浏览器在用的 web 接口 `www.douyin.com/aweme/v1/web/aweme/detail/`。
这套接口的门槛众所周知:请求参数要带 `a_bogus` 签名(还有 `msToken`、`ttwid`、
`s_v_web_id`、`odin_tt` 一串指纹 Cookie),所以第一步是先把签名搞对。
### 1.1 空 body 的两种成因,花了很久才分开
匿名请求 detail 的表现是 **HTTP 200 + 空 body**,这有歧义:
1. **session 没预热**:新 session 的第一个接口请求必然空,第二个才通。
解法很猥琐但有效——初始化时先打一发 `/aweme/v1/web/aweme/related/` 把这个
「哑弹」消耗掉(`_warm_up()`,见 `douyin/client.py`)。
2. **服务端主动拦截**:响应头 `X-Whale-Throughput-Abort-Data` 里有 base64 JSON,
解码出来是拦截原因(`decode_abort()`)。
分不清这两种就会对着可重试的瞬时态疯狂重试,或者把硬门槛当网络抖动。
### 1.2 关键对照实验:不签名 vs 假签名,表现一模一样
把签名字段换成完全随机的 garbage a_bogus,和干脆不签名,两种请求的响应**逐字节一致**
(都是 200 空 body + `强制登录` 拦截头)。【实测】
这说明:**匿名拿不到 detail 跟签名对不对无关**。继续抠 a_bogus 算法救不了这条路。
### 1.3 绕路尝试:related 接口
`/aweme/v1/web/aweme/related/` 匿名可用、返回完整 `play_addr`——但只含**相关视频**,
不含目标视频自身。【实测】此路不通,只白捡了一个预热接口。
### 1.4 Web 端留档的正确姿势
web 路径没删干净而是留了 `client.py` / `signer.py` / `crypto.py`,因为:
- `Aweme` / `DouyinError` 这些类型被 App 端正常复用;
- 几个坑的记录有复用价值(UA 必须与签名 UA 一致、`browser_version` 参数要跟着对齐、
cookie 预热、拦截头解码);
- 万一 App 端被封,web 是唯一退路——但要先补签名(见 §2)。
---
## 2. 插曲:a_bogus 纯 Python 化,逆了一半
既然 web 端迟早要用(当时这么想),顺手逆了 a_bogus。它的底层是三样公开原语:
| 原语 | 说明 | 状态 |
|---|---|---|
| SM3 | 国标哈希(GB/T 32905-2016) | ✅ 对照官方测试向量验证通过 |
| RC4 | 标准流密码 | ✅ 实现并验证 |
| 自定义 Base64 | 标准 Base64 换了套字符码表 | ✅ 逆出码表 |
这三样现在还在 `douyin/crypto.py` 里,文件底部自带官方向量自检,`test_smoke.py`
每次跑都会验。**卡住的是最后一层**:三个原语的输出按什么顺序拼进 a_bogus 的
字节结构,需要对照混淆过的 `ab.js` 逆出来,没逆完。`ABogusSigner` 文档里原话就是
「剩字节拼装顺序待从 ab.js 逆出来」。
同时期的 `NodeSigner` 路线(本地 node_signer 跑真实 webmssdk)实测能被抖音接受,
但 `node_signer/` 目录不在本项目里。所以 **web 端当前没有可用退路**,这是已知风险。
---
## 3. 第二阶段:转向 App 端——发现「免签名」路线
Web 端被判死刑后转向 App 端。App 接口传统上被认为要 X-Gorgon / X-Khronos 签名,
这恰恰是「不依赖 JS 运行时」最难啃的方向。但实测发现**新版本的 App 端详情接口
根本不验签**。
### 3.1 最早的误判:伪造 device_id 导致的空 body
第一次打 App 端 detail 也是空 body,一度以为「果然还是要签名」。
换用**注册得来的真实** `device_id` / `install_id` 之后立刻正常。【实测】
教训:**空 body 不代表要签名**。App 端的门槛是设备身份的真实性,不是签名。
### 3.2 设备注册:免签名、免 Cookie、免登录
链路只有两步(`douyin/app.py`):
```
POST https://{log.snssdk.com|api.amemv.com|...}/service/2/device_register/
-> device_id / install_id / openudid / cdid / clientudid
GET https://aweme.snssdk.com/aweme/v1/aweme/detail/?device_id=...&iid=...
-> 完整 aweme_detail
```
- 注册是引导调用,**不需要任何签名**,四个 host 互为备份;
- 注册一次身份长期有效,落盘缓存(`.device.json`);
- 从此 detail 随便打,不需要 X-Gorgon、不需要 Cookie、不需要登录。
#### device_id 是怎么来的:我们自己只造指纹,id 是服务器发的
`device_id` 不是客户端算出来的——和真 App 首次启动一样,是**服务器在注册响应里
分配的**。我们这边生成的是四样客户端指纹(`Device._register_once()`):
| 字段 | 生成方式 | 作用 |
|---|---|---|
| `openudid` | 16 位随机 hex | 设备级指纹 |
| `cdid` | 随机 UUID | 设备级指纹(detail 请求也带) |
| `clientudid` | 随机 UUID | 会话级指纹 |
| 机型档案 | 从 8 款机型池随机抽(型号/品牌/Android 版本/分辨率/dpi) | 决定 UA 和上报参数,注册后跟着设备走 |
连同伪装成抖音 9.9.9(`com.ss.android.ugc.aweme`,APP_ID=1128)的完整上报体
(`magic_tag: ss_app_log`,header 里 `device_id=0, install_id=0` 表示「我是新来的,
请发身份」)POST 给注册接口。响应里 `device_id_str` / `install_id_str` 就是分配来的
身份,四个 host 按顺序试,拿到第一个非 `'0'` 的就算注册成功。
服务器拒绝时的行为也是信息:返回 `device_id='0'`(不是错误码),换全新指纹照样被拒、
四个 host 同时被拒、约 10 分钟后自行恢复——说明这是**临时限流,且不按指纹分桶、
不按单个 host 分桶**。按 IP 还是按 UA/机型特征分桶没验证过(缺第二个出口 IP)。
### 3.3 设备档案的自洽性
注册请求里上报机型(device_type / brand / os_version / resolution),后续 detail
请求的 UA 和这些字段必须自洽——「注册报小米、请求 UA 是三星」本身就是可疑特征。
所以 `Device` 把机型档案跟着设备身份一起走。【推断:基于风控常识,未做对照实验】
### 3.4 机型随机化:成本极低的两用对冲
所有注册请求以前在机型维度上完全一致。注册受限到底是按 IP 还是按「UA+机型」
特征分桶,**没验证过**(本机没有第二个出口 IP,做不了对照实验)。
把机型做成 8 款的随机池:如果是特征分桶它能缓解,如果是纯 IP 它无害。【对冲】
---
## 4. 第三阶段:下载链路——比想象中裸
拿到 `aweme_detail` 之后找下载地址,两个发现都免鉴权:
1. **`aweme.video.play_addr.url_list`** 里的 `douyinvod.com` CDN 直链没有防盗链,
裸 `requests` 直接下,Referer 都不查。【实测】
2. **`/aweme/v1/play/?video_id={uri}`** 更干净:免鉴权 302 跳到 CDN。项目下载走的就是它。
CDN 支持 Range,顺势做了断点续传(`douyin/download.py`),落盘文件名直接取
aweme_id——「是否已下载」看文件在不在,不依赖任何去重记录,记录丢了也能无损续跑。
---
## 5. 第四阶段:限流——两层,性质不同
批量一上量必然撞限流。两层限流的表现和处置完全不同:
### 5.1 设备级 detail 配额【实测】
- 单个 `device_id` 约 **575 次** detail 调用后被限,表现是接口一直回 200 空 body;
- **同一 IP 换个新注册的设备立刻恢复** → 配额是设备级而非 IP 级;
- 注册本身免签名、成本极低 → 按量轮换即可,`DevicePool` 每 400 次主动换(留安全余量)。
工程上有个细节:多 worker 并发下可能同时发现「设备死了」。`DevicePool` 用
`generation` 标记当前设备,worker 拿设备时记下代数,发现被限时只有代数还对得上的
才允许触发轮换——避免一个事件把设备连换好几次。
### 5.2 IP 级注册配额【实测】
- 短时间内注册约 **11~12 次**后,**所有** register host 一律返回 `device_id='0'`;
- 这层是 IP 级的,换设备没用,只能**等**。`Device.register()` 指数退避重试
(默认 5 次,30 秒起步翻倍),让批处理停下来等,而不是空转着把剩余任务全标失败。
**这是本项目烧过真金白银的坑**:早期没有退避,一次注册受限直接烧掉 678 条任务。
### 5.3 未解之谜
detail 配额轮换后是否真的按「IP 还是 UA/设备指纹」限流,一直没验证过
(缺第二个出口 IP)。实测跑到第 3 次轮换都正常,暂搁置。
---
## 6. 第五阶段:数据语义层的逆向
接口通了之后,剩下的坑全在「字段怎么读」上。这些是从几十万条真实数据里统计出来的:
### 6.1 AIGC 判定:认 `risk_infos.type`,别认 `is_aigc_media`
`raw.is_aigc_media` 全库 24611 条**全是 false**——名字最像,完全没用。
真正的信号是 `risk_infos.type` 数字码:
| type | 含义 |
|---|---|
| 44 | 作者声明:内容由 AI 生成(最可信,19946 条) |
| 47 | 疑似使用 AI 生成技术(平台检测,措辞存疑,515 条) |
| 76 / 328 | 作品含 AI 生成内容(平台判定,448 条) |
| 45 / 336 / 351 / 54 / 335 | 虚构演绎 / 个人观点 / 来源于网络 / 营销推广——**与 AI 无关的免责声明** |
已知 bug:爬虫层把「risk_infos 有内容」就当 `is_aigc=true`,把免责声明也算进
AIGC,全库误算 1029 条。修法(存 `type` 字段、按 `{44,47,76,328}` 判)记在
PROGRESS.md §7 待办。
### 6.2 不可下载作品的分类
`filter_detail.filter_reason` 能区分失败性质:
- `images_base` → 图文作品(图集),不是视频,抛 `NotAVideo`(当前失败的绝对大头,
AI 池累计 241 条;且 aweme_id 排序下图文扎堆在头部,跑批前几百条失败率会飙到 15%)
- `status_self_see` / `status_audit_self_see` → 仅作者自见,实际不可用
### 6.3 `aigc_forbidden` 只管 web 端
`allow_download=false` 的 `aigc_forbidden` 原因在 web 端会拦下载,但
**App 端接口完全不受影响**。用 20 条探针实测:9 条 web 侧禁下的全部正常下下来了。
所以不需要 `--exclude-forbidden`,禁下名单不是真禁下。
---
## 7. 最终架构
逆向成果固化成一条 9 步流水线(`pipeline.py`),两道「删除闸门」保证
「传完就删本地」不失数据:
```
fetch_detail(App端免签) → 下载mp4(302直链+Range续传) → ffmpeg抽mp3
→ ffprobe实解码探时长 → 闸门①: mp3时长≈mp4时长?
→ 上传mp4/mp3 → head_object核对大小 → 闸门②: 字节数一致?
→ 写record → 删本地
```
任何一步失败:保留本地、记 failed、继续下一条。断点续跑靠 `records/records.jsonl`
(行首 aweme_id 契约,正则抠 id 不解析整行,十几 GB 文件启动扫描 ~1 秒)。
---
## 8. 结论速查(给后来改代码的人)
| 问题 | 答案 |
|---|---|
| web detail 匿名能拿吗 | 不能,强制登录,与签名无关【实测】 |
| App detail 要签名吗 | 不要。要的是**注册来的真实设备身份**【实测】 |
| 设备哪里来 | `/service/2/device_register/`,免签名,身份落盘复用 |
| 下载走哪 | `/aweme/v1/play/?video_id=` 302 跳 CDN,免鉴权,支持 Range |
| 什么时候被限流 | 设备 ~575 次 detail(换设备);IP ~12 次注册(只能退避等)【实测】 |
| 空 body 怎么读 | 先看 `X-Whale-Throughput-Abort-Data`,再怀疑 session 没预热 |
| AIGC 看哪个字段 | `risk_infos.type ∈ {44,47,76,328}`;`is_aigc_media` 全库皆 false |
| 退路 | web 端要先补 a_bogus(SM3/RC4/Base64 已通,字节拼装未逆完)+ 登录 Cookie |