Files
douyin_video_download/RE.md
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

12 KiB
Raw Blame History

抖音接口逆向全过程

本文按时间顺序复盘这个项目把「批量下载抖音无水印视频」从 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,这有歧义:

  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