App 端免签名接口(设备注册 + detail + CDN 直链),一体化流水线 下载→抽音→传COS→记录→删本地,AI 作品四档判定,四份文档: README(使用指南)、PROGRESS(批次进度)、RE(逆向全过程)、AI(判定规则)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
12 KiB
抖音接口逆向全过程
本文按时间顺序复盘这个项目把「批量下载抖音无水印视频」从 0 走通的全过程。 每条结论都标注了验证方式:【实测】= 打过真实接口,【推断】= 有旁证但没做对照实验。 对应的代码在
douyin/包里,结论速查表在 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,这有歧义:
- session 没预热:新 session 的第一个接口请求必然空,第二个才通。
解法很猥琐但有效——初始化时先打一发
/aweme/v1/web/aweme/related/把这个 「哑弹」消耗掉(_warm_up(),见douyin/client.py)。 - 服务端主动拦截:响应头
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 之后找下载地址,两个发现都免鉴权:
aweme.video.play_addr.url_list里的douyinvod.comCDN 直链没有防盗链, 裸requests直接下,Referer 都不查。【实测】/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 |