App 端免签名接口(设备注册 + detail + CDN 直链),一体化流水线 下载→抽音→传COS→记录→删本地,AI 作品四档判定,四份文档: README(使用指南)、PROGRESS(批次进度)、RE(逆向全过程)、AI(判定规则)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
247 lines
12 KiB
Markdown
247 lines
12 KiB
Markdown
# 抖音接口逆向全过程
|
||
|
||
> 本文按时间顺序复盘这个项目把「批量下载抖音无水印视频」从 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 |
|