# 抖音接口逆向全过程 > 本文按时间顺序复盘这个项目把「批量下载抖音无水印视频」从 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 |