初始提交:抖音视频批量采集流水线

App 端免签名接口(设备注册 + detail + CDN 直链),一体化流水线
下载→抽音→传COS→记录→删本地,AI 作品四档判定,四份文档:
README(使用指南)、PROGRESS(批次进度)、RE(逆向全过程)、AI(判定规则)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:31:10 +08:00
commit 2bb83f3685
39 changed files with 298874 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# 媒体产物(体积大,不入库)
downloads/
audio/
*.mp4
*.mp3
*.m4a
*.tmp
# 数据与产物(体积极大,不入库;lists/ 是凭据,入库)
data/
records/
exports/
work/
# 运行态 / 断点续传记录
.device.json
.downloaded.json
uploaded.txt
*.failed.txt
*.log
# 凭证
cookie.txt
.env
__pycache__/
*.pyc
.idea/

215
AI.md Normal file
View File

@@ -0,0 +1,215 @@
# AI 判定规则详解
> 本文档逐条解释 `douyin/aiclass.py` 的判定逻辑:信号从哪来、四档怎么定、
> 每个词表为什么这么选、已知误收和否决理由。改规则之前必读;
> 每个坑在 `test_aiclass.py` 里都有对应测试(29 个),改完务必跑通。
> 各批筛选结果和下载进度见 [PROGRESS.md](PROGRESS.md) §4.5。
---
## 1. 判定对象:三个文本源,地位不同
每条记录抽出三段文本,权重严格递减:
| 来源 | 构成 | 地位 |
|---|---|---|
| **body**(作品文本) | `desc` + 所有 hashtags 的 name | 主信号场,能定 strong / topic |
| **author**(作者昵称) | `author.nickname` | 只能兜底成 author 档,**绝不往上顶** |
| **music**(音乐标题) | `music.title`,但先剥壳(见 §6) | 同上 |
「作者昵称 / 音乐标题」回答的是**谁发的**,不是**作品是什么**。实测
@牛牛Ai 发普法解说、@你好,我是大福 发真人游泳视频——昵称带 AI 的作品
多数不是 AI。所以 author/music 的命中只能在作品自身毫无信号时兜底成
`author` 档(`classify()` 里 `if tier == "none"` 才采纳)。
## 2. 四档体系
档位之间不重叠,一条记录取**最强**的一档:
```
platform > strong > topic > author > none
```
| 档 | 依据 | 含义 | 可信度 |
|---|---|---|---|
| `platform` | 爬虫打的 `ai.is_aigc` | 抖音官方 AIGC 标 | 最高,且唯一能抓到「作者什么都没标但确实是 AI」的作品——实测 1744 条文案零 AI 字样 |
| `strong` | body 里有明确「这是 AI 做的」信号 | 见 §3 的四路信号 | 高 |
| `topic` | body 只提到 AI 本身 | 在讲 AI(教程/新闻/大模型),不一定是 AI 做的 | 中 |
| `author` | 只有昵称/音乐标题带 AI | 兜底档,会误收 | 低 |
| `none` | 无信号 | | |
⚠️ **已知 bug**:爬虫层把「`risk_infos` 有内容」就当 `is_aigc=true`,把
虚构演绎/个人观点等免责声明也算进了 platform 档,全库误算 1029 条。正确做法
是认 `risk_infos.type ∈ {44, 47, 76, 328}`(44=作者声明、47=疑似、76/328=平台判定),
修法在 PROGRESS.md §7 待办。`raw.is_aigc_media` 全库皆 false,别信。
## 3. strong 档的四路信号(body 里任一成立)
### 3.1 `aigc` token
独立的 `aigc` 字样直接 strong。边界规则见 §5。
### 3.2 生成工具名单 `_GEN_TOOLS`——命中即 strong,不要求 ai 同现
用了生成工具 = 片子是 AI 做的,这是最强的文本证据。名单 70+ 个,分三类:
- **中文工具**:即梦、可灵、海螺(注意这个在歧义表,见 3.5)、海艺、小云雀、
妙鸭、一帧秒创、腾讯智影、讯飞智作、硅基智能、魔音工坊、元气口播、万兴播爆、
闪剪、声音克隆/语音克隆/克隆音色/音色克隆……
- **英文工具**:dreamina、jimeng、seedance、seedream、kling、vidu、pixverse、
pika、luma、runway、sora、viggle、heygen、midjourney、comfyui、flux、
nanobanana、stable diffusion、liblib、suno、udio、veo3、hunyuanvideo、
cogvideo、controlnet、domoai、synthesia、deepbrain……
- **生成动词**:文生视频、图生视频、文生图、图生图、生成式
**选词标准**(2026-08 扩充时定的):「这串字在中文短视频文案里不可能有别的意思」。
全部在 12.7 万条真实数据上验过——要么零命中(备着以后用),要么命中全是真 AI。
反过来,这些被明确**拒收**:`tripo`(撞 tripod 三脚架)、`seaweed`(海藻)、
`lora`(撞 valorant,实测命中 3 条全是无畏契约)、`captions`、`playground`。
### 3.3 AI 漫剧专有名词 `_MANJU`——单独出现即 strong
AI 漫剧已形成固定圈子,这些词在 18.4 万条数据里几乎只出现在 AI 作品上
(括号=全库命中/仍漏掉的):
- 圈内词:漫剧(1568/64)、治愈系漫画(80/0)、渡灵书(62/0)、超级裂变(46/0)、
二次元动漫(33/0)、月落小溪(25/4)、七罪创世(21/0)、晚夏小梦(67/4)、
灵绘漫客(12/0)、neowow(12/0)、猫箱跨次元相遇(114/12) 等
- **红果片名**(字节免费短剧 App 的 AI 漫剧):万妖图录传、发配边关、
罪妻开荒养出战神、斩仙台、杂灵根……**来源是新闻报道热榜,不是红果完整片库**,
红果不公开片库,遇到新片名往这加
实测**拒收**的(题材 ≠ AI 标记):西游记(+26 真·玄奘故里游记)、孙悟空(+16)、
宫崎骏(+10 龙猫童年)、民间故事、睡前故事、国漫推荐(+4《剑来》真人制作)、
原创动漫(+18 手绘二次元)、创作者激励计划、未来导演扶持计划、开放赛道(后三个是
抖音通用活动)。
**为什么「红果」两个字不能进表**:红果主体是真人短剧,AI 漫剧只是一个赛道。
实测 3 条命中 2 条真人短剧(《家里家外2》《冷御失守》)、1 条演员面试。
要抓红果的 AI 漫剧只能靠具体片名。
### 3.4 `ai` + 创作语境词 `_GEN_CTX`——同现才成立
单独的 `ai` 只能算 topic。必须跟 41 个创作/生成语境词之一同现才升级 strong:
短剧、漫剧、动画、视频、绘画、创作、制作、生成、分身、配音、换脸、特效、导演、
数字人、虚拟人、成片、写真、转绘、扩图、提示词……
反过来,中文泛词(动画/绘画/分身/动漫)**不跟 ai 同现就一律不算**——830 条
`#原创动画` 全是手绘,早期版本单独算信号误收几千条。`剪映`(375)/`关键帧`(314)/
`蒙版`(203)/`转场` 是剪辑术语,根本不是 AI 信号。
### 3.5 歧义工具 `_AMBIG_GEN`——必须跟 ai/aigc 同现
工具名本身是常用词,单独出现几乎全是误判(数据来自全库统计):
| 词 | 误判实例 | 真命中 |
|---|---|---|
| 豆包 | 224 条:狗名、豆沙包、「反强包之神」 | 145 条,全带 AI 字样 |
| 海螺 | 13 条:三峰「海螺」帐篷 | 全带 AI 字样 |
| 元宝 | 11 条:316 不锈钢元宝勺 | 全带 AI 字样 |
| 万相 | 17 条:《龙神万相》、万相集 | 全带 AI 字样 |
| 星流 | 「明星流量」子串 | 全带 AI 字样 |
| 清影 | 「起舞弄清影」 | 全带 AI 字样 |
真命中**无一例外**同时带 AI 字样,所以这道门槛不掉召回。同机制的还有
`_AMBIG_CHAT`(豆包、元宝、混元,归 topic 档)。
## 4. topic 档:在讲 AI,不一定是 AI 做的
- **模型名单** `_CHAT_TOOLS`:chatgpt、deepseek、kimi、通义、千问、qwen、文心、
ernie、智谱、glm、copilot、gemini、grok,及中文泛词 人工智能、大模型、
神经网络、机器学习、深度学习
- **独立成词的** `_CHAT_RE`:gpt、claude、llm、agi、ai agent(要求词边界,
避免 gpt 撞上别的串)
命中即 topic;若同时撞了 §3 的 strong 信号,取 strong。
## 5. 归一化与词边界:AI 的三种写法、词中词的两难
### 5.1 AI 的冒充写法
`ai` 的第二个字符实际匹配四种:`i`、小写 `l`、罗马数字 `Ⅰ`/`ⅰ`(U+2160/U+2170):
- `Al`(大写 A + 小写 L):标签 `抖音al` 出现 129 次,文案有 `#Al创作`
- `#AⅠ漫剧`:罗马数字冒充,与 `Al` 同套路
### 5.2 独立词边界
- `_AI` / `_AIGC`:前后不能接字母数字(挡 email、detail、again、hair),
但**允许接中文**(`AI绘画` 要命中)
- `_alt()`(工具/模型/漫剧名单共用的编译器):纯 ASCII 词只挡字母、
**不挡数字和中文**——数据里满地 `Seedance2.0`、`sora2`、`runninghub0.21`、
`libtv创作者`,一刀切 `\b` 会丢 3500+ 条真命中
- 但真正的词中词要挡:`udio` 藏在 `Grass Studio`/`theonestudio` 里 17 次、
`flux` 藏在 `Fluxxwave` 曲名里 1 次(都是音乐标题混进 author 档的)
## 6. 音乐标题剥壳
抖音默认原声的标题就是个壳,里面是作者昵称复读,不含作品信息:
```
jsonl: @<昵称>创作的原声
csv: @<昵称>创作的原声 - <昵称> ← 后缀那半截也要剥
```
**不剥会怎样**:昵称带 AI 会连「创作」一起命中 `_GEN_CTX`,作者昵称就从
music 这条路偷偷升到 strong——实测 771 条这样混进来(@冠宇Aigc 的猴子炒菜、
@牛牛Ai 的大案纪实)。剥完后音乐标题只剩 16 条还有命中,这条路本来就没什么
价值,只配给 author 档兜底。注意 csv 的壳多一截 ` - 昵称`,剥壳正则必须两种都覆盖,
否则 bug 在 csv 输入上复发。
## 7. 明确否决的信号(放开只进误判,实测一条真的都捞不到)
| 否决词 | 污染来源 |
|---|---|
| 数字人 | 数字人民币 |
| 分身 | 微信分身、五五分身材 |
| 换脸 | 「这套穿搭能换脸」 |
| lora | 无畏契约 valorant |
| tripo | tripod 三脚架 |
| seaweed | 海藻 |
| 度加 | 「亏损幅度加大」 |
| 万彩 | 「29万彩礼」 |
| 生数 | 「诞生数副名场面」 |
| 节点 | 「关键节点」 |
| 抽卡 | 原神 |
| 捏脸 | 燕云十六声 |
| 国漫 | 《剑来》真人制作 |
| 对口型 | 真人对口型 |
测试锚点:`test_rejected_candidates_stay_none`、`test_digital_rmb_is_not_a_digital_human`、
`test_hongguo_platform_name_is_not_an_ai_signal`。
## 8. 维护指南
**加新工具名**:
1. 先问「这串字在中文短视频文案里有没有别的意思」。没有 → `_GEN_TOOLS`;
是常用词但真命中都带 AI → `_AMBIG_GEN`;是对话模型 → `_CHAT_TOOLS`;
常用词模型 → `_AMBIG_CHAT`
2. 在真实数据上跑一遍验证:命中应全为真 AI,或至少零命中
3. 加测试用例到 `test_aiclass.py`(用真实文案,别造)
4. 红果新片名 → `_MANJU` 片名区;通用活动标签别收
**判档结果怎么用**:`clean_ai.py --tier platform,strong` 是默认推荐组合
(可下载清单的标准切法);`topic` 档是「讲 AI 的新闻科普」,多数采集场景不要;
`--platform-needs-text` 用于卡「平台标了 AIGC 但文案零信号」的带货视频,但会误伤
@栖光 类 AI 古风美图作者(文案一个 AI 字都不写),这批要按是否带货再分
(`lists/platform无佐证_*.txt`)。
## 9. 测试对照表(改规则的回归网)
| 测试 | 守什么 |
|---|---|
| `test_platform_label_wins_even_with_no_ai_text` | platform 档优先,文案零信号也成立 |
| `test_strong_*`(5 个) | 四路 strong 信号:复合词、工具名、aigc、Al 冒充 |
| `test_topic_*`(2 个) | 纯 AI 提及、模型名只给 topic |
| `test_author_never_upgrades_a_scored_item` | 昵称/music 绝不把已定级的作品往上顶 |
| `test_default_original_sound_is_not_a_work_signal` / `test_sound_wrapper_csv_form_is_stripped` | 音乐壳剥壳(jsonl + csv 两种写法) |
| `test_english_words_containing_ai_are_not_signals` | email/detail/hair 不算 |
| `test_editing_jargon_is_not_ai` / `test_generic_chinese_words_alone_are_not_ai` | 剪辑术语、中文泛词单独不成立 |
| `test_ambiguous_tool_*`(2 个) | 歧义工具单独不算、带 AI 才算 |
| `test_roman_numeral_impersonates_ai` / `test_strong_handles_capital_A_lowercase_L` | 两种 AI 冒充写法 |
| `test_rejected_candidates_stay_none` + 同组 | §7 否决清单 |
| `test_manju_*`(3 个) | 漫剧词单独成立、红果不成立、题材词不收 |
| `test_veo3_beats_a_weak_author_signal` | 强信号压弱信号 |

390
PROGRESS.md Normal file
View File

@@ -0,0 +1,390 @@
# 任务进度 / 踩坑记录
> 项目介绍和各代码文件说明见 [README.md](README.md)。本文档记录接口踩坑、
> 各批筛选与下载进度、已知问题与待办,随批次滚动更新。
## 抖音视频批量采集 → 音频提取 → COS 上传
把一批 `douyin.com/video/...` 链接跑成 ASR 可用的数据集:
**下载无水印 mp4 → 抽 mp3 → 探时长 → 传腾讯 COS → 出清单(jsonl / xlsx)**。
全程纯 Python,不依赖第三方解析服务、不依赖 JS 运行时、**不需要登录**。
---
## 1. 结论先行:接口怎么走通的
> 这些结论的验证过程和完整时间线见 [RE.md](RE.md)(逆向全过程复盘)。
> 这里只放结论,改代码前先看。
| 路线 | 状态 | 说明 |
|---|---|---|
| web `/aweme/v1/web/aweme/detail/` | **要登录** | 匿名请求返回 HTTP 200 **空 body**。真实原因在响应头 `X-Whale-Throughput-Abort-Data`(base64 JSON):`{"content":"anonymous","name":"强制登录"}`。跟 a_bogus 签名对不对**无关**——伪造的随机 a_bogus 和完全不签名,表现一模一样。 |
| App `/aweme/v1/aweme/detail/` | **可用,且不用签名** | 先打 `/service/2/device_register/` 拿 `device_id` / `install_id`,带上就能取详情。**不需要 X-Gorgon、不需要 Cookie、不需要登录。** 这是本项目默认路径。 |
| CDN `play_addr` 直链 | 免鉴权 | `aweme.video.play_addr.url_list` 里的 douyinvod 地址没有防盗链,裸 `requests` 直接下。 |
| `/aweme/v1/play/?video_id={uri}` | 免鉴权 | 302 跳 CDN,零鉴权。下载走的就是它。 |
### 两层限流(跑批量必然撞上)
1. **设备级 detail 配额**:单个 `device_id` 约 **575 次** detail 调用后被限,表现为 HTTP 200 空 body。
→ `douyin/batch.py` 的 `DevicePool` 到配额就换设备;用 `generation` 标记当前设备,避免多个 worker 同时发现失败把设备连换好几次。
2. **IP 级注册配额**:短时间内注册约 **11~12** 次后,**所有** register host 一律返回 `device_id='0'`。
→ `Device.register()` 指数退避重试(默认 5 次,30s 起步翻倍),让批处理**停下来等**,而不是空转着把剩余任务全标成失败。
第 2 条是踩过的坑:早期没有退避,一次注册受限直接烧掉 678 条任务。
### 作品拿不到时怎么区分
看 `filter_detail.filter_reason`:
- `images_base` → 图文作品(图集),本项目不处理,抛 `NotAVideo`
- `status_self_see` / `status_audit_self_see` → 仅作者自见,实际不可用
---
## 2. 目录结构
```
├── pipeline.py 【核心】一体化流水线:下载→抽音→上传→记录→删本地
├── scripts/
│ ├── clean_ai.py 从链接 jsonl 里筛出 AI 相关作品,出链接清单
│ ├── jsonl_to_xlsx.py jsonl → xlsx(认 records/records.jsonl 和 records/manifest.jsonl 两种)
│ └── export_sample_xlsx.py records.jsonl → 交付用样本 xlsx(支持 --ai-only / --limit)
├── tests/
│ ├── test_smoke.py 离线冒烟测试(不联网)
│ ├── test_meta.py 标题/标签拆分、记录追加的测试
│ ├── test_aiclass.py AI 判定的测试(用例全取自真实数据)
│ └── test_pipeline.py 流水线端到端测试(ffmpeg 真跑,网络和 COS 打桩)
│
├── douyin/
│ ├── app.py 【核心】设备注册 + App 端 detail
│ ├── batch.py 并发批处理、DevicePool 设备轮换
│ ├── download.py 带 Range 续传的下载、去重 Registry
│ ├── meta.py 标题/标签拆分、records.jsonl 追加写
│ ├── aiclass.py 判定作品是不是 AI 相关,分四档,见 §4.5
│ ├── cos.py COS 上传 + 上传后大小校验
│ ├── audio.py ffmpeg/ffprobe 薄封装:找可执行文件、拼命令、探时长
│ ├── urls.py 短链还原、aweme_id 提取
│ ├── client.py Aweme/DouyinError 等共用类型 + web 后端请求路径
│ ├── signer.py a_bogus 签名(仅 web 后端用,当前跑不起来,见下)
│ └── crypto.py SM3 / RC4 / 自定义 Base64(未完成的纯 Python 签名器的积木)
│
├── data/ 源数据(外部抓来的,clean_ai.py 的输入。删了没法重新筛,别动)
│ ├── 抖音链接20260827_40087.jsonl 40087 条
│ ├── 抖音ai视频_20260831_87110.jsonl 87110 条(其实是露营帐篷爬取,AI 占 3.3%)
│ ├── 抖音ai视频_20260901_57261.jsonl 57261 条(同上,AI 占 2.8%)
│ ├── 抖音ai视频_20260902_42391.jsonl 42391 条(9 月 AI 批,AI 占 90.9%)
│ ├── 抖音ai视频_20260904_62171.jsonl 62171 条(同上,AI 占 88.3%)
│ ├── 抖音ai视频20260908_118469.jsonl 118469 条(同上,AI 占 95.1%)
│ ├── 所有作品.csv 4839 条,字段与 jsonl 不同,见 §4.5 第 8 条
│
├── records/ 流水线产物与断点记录
│ ├── records.jsonl 全字段元信息(含 raw),断点续跑靠它
│ ├── pipeline_failed.txt 失败清单:aweme_id / 阶段 / 原因(重跑据此续传)
│ └── manifest.jsonl 老产物:第一批 5393 条的 3 字段清单,见 §5
│
├── exports/ 交付/导出产物(xlsx / csv)
├── lists/ 各批的链接清单,也是"哪批下了什么"的凭据
└── logs/ 各批跑批日志
`work/` 是流水线的临时目录,跑的时候自建、传完即删,仓库里不留。
**web 后端目前是跑不起来的,别把它当后路。** 具体状态:
- `client.py` 里 `Aweme` / `DouyinClient` / `DouyinError` 这几个类型被 `app.py`、`batch.py`
正常用着,**不是死代码**;只有它的 web 请求路径闲置。
- `signer.py` 两个后端都不可用:`NodeSigner` 要 `node_signer/sign.js`,**这个目录不在项目里**;
`ABogusSigner` 是纯 Python 实现,它自己的文档写着"剩字节拼装顺序待从 ab.js 逆出来",没写完。
- `crypto.py` 的 SM3 / RC4 / 自定义 Base64 对过官方测试向量(`test_smoke.py` 每次都验),
但它只是那个**没写完的** `ABogusSigner` 的积木,`signer.py` 并不 import 它。
所以真要走 web 端,得先补 `node_signer/`(或者把 ab.js 逆完)**再**准备登录 Cookie。
App 端被封的话这里没有现成退路,得先做上面这件事。
**本地媒体文件已全部删除**(`downloads/` 的 5393 个 mp4、`audio/` 的 5393 个 mp3),东西都在 COS 上。旧的分阶段脚本依赖本地文件,要用得先重新下载;`pipeline.py` 不受影响——它本来就是边跑边删。
输入链接文件 `all_video_links(1).txt` 也已不在,重跑需要重新提供。
---
## 3. 环境
```bash
pip install -r requirements.txt
```
另需系统里有 `ffmpeg` / `ffprobe`。实测环境:Python 3.11.0 + ffmpeg 2025-07-31 (gyan.dev full build)。
COS 凭证走环境变量,别写进命令行(会留在 shell history 里):
```powershell
$env:COS_SECRET_ID = "..."
$env:COS_SECRET_KEY = "..."
```
---
## 4. 一体化流水线(推荐)
```bash
$env:COS_SECRET_ID="..."; $env:COS_SECRET_KEY="..."
# 先小批试跑,加 --keep-local 不删本地文件,人工看一眼产出
python pipeline.py --from-file links.txt --region ap-chongqing --limit 20 --keep-local
# 确认没问题再放开
python pipeline.py --from-file links.txt --region ap-chongqing --workers 8
# 出 xlsx(--columns full 带上标题、作者、标签、发布时间)
python scripts/jsonl_to_xlsx.py -i records/records.jsonl --columns full -o "exports/抖音-四川话.xlsx"
# 出交付样本 xlsx(--ai-only 只导 AI 档,--limit 限量)
python scripts/export_sample_xlsx.py --ai-only -o "exports/AI视频样本.xlsx"
```
每条视频端到端走完 9 步才算完:下载 → 抽 mp3 → 探时长 → 校验 → 传两个对象
→ 核对大小 → 写 record → 删本地。**磁盘峰值只有 `workers × (mp4+mp3)`,
约 100 MB,与总量无关**——分阶段跑得先摊 53 GB。
元信息全进 `records/records.jsonl`,一行一条,`raw` 内嵌,字段见下。断点续跑靠它,
重跑同一批会跳过已完成的(连下载都不会发生)。COS 上同名对象一律覆盖。
`pipeline.py` 的 `--records` / `--failed` 默认就指向 `records/` 目录。
### 两道闸门
删本地文件是唯一不可逆的操作,所以卡两道,任一不过就保留文件、记 failed、继续下一条:
1. **抽取闸门** —— mp3 用 `ffprobe -count_frames` **实际解码**算时长,与 mp4 比对,
差超过 `max(1s, 2%)` 判定截断。
注意不能用 `format=duration`:它读的是 LAME 的 Xing 头,声明的是原始总帧数。
实测把 mp3 截断到 20%,`format=duration` 报的仍是完整的 10.0078s,一字不差;
`ffmpeg -f null -` 同样拦不住(rc=0、无 stderr)。只有实解码能发现。
2. **上传闸门** —— 不信 `upload_file` 没抛异常,而是 `head_object` 取回
`Content-Length` 跟本地字节数比对,两个对象都对上才删。
### records.jsonl 字段
```json
{"aweme_id":"...","title":"剥掉标签的纯标题","desc":"服务端原样",
"tags":["..."],"author":{"nickname":"...","uid":"...","sec_uid":"..."},
"create_time":1582635538,"create_time_str":"2020-02-25 21:38:58",
"duration_sec":41.702993,"duration_ms_api":41750,
"stats":{"digg":0,"comment":0,"collect":0,"share":0,"play":0},
"music":{...},"cover_url":"...","video":{"uri":"...","width":1280,"height":720},
"cos_mp4":"cos://...","cos_mp3":"cos://...","size_mp4":0,"size_mp3":0,
"source_url":"...","fetched_at":0,"raw":{ ...服务端原始 JSON... }}
```
三条要注意的:
- **`aweme_id` 必须是每行第一个字段**。续跑扫描用正则从行首抠 id,不解析整行
(几百 MB 的文件差别是 ~1 秒对十几秒)。重排字段不会报错,只会让启动悄悄变慢。
- **`duration_sec` 和 `duration_ms_api` 不是一回事**。前者是 ffprobe 实测,后者是
服务端声明值,实测同一条 41703 vs 41750 ms。切分要用 `duration_sec`。
- **`tags` 取自 `desc` 的正则全集,不是 `text_extra`**。实测 12 条样本里有 2 条的
标签在 `desc` 里有、`text_extra` 里没有,以后者为准会静默丢标签。
`raw` 内嵌导致单行几十 KB、整个文件十几 GB,所以读取一律逐行流式,
`jsonl_to_xlsx.py` / `export_sample_xlsx.py` 已经改成流式了。
---
## 4.5 从链接 jsonl 里筛 AI 作品
> 判定规则的逐条详解(词表选词标准、误收数据、否决清单、维护指南)见 [AI.md](AI.md)。
`clean_ai.py` 吃的是外部抓来的链接 jsonl(带 desc / hashtags / author / ai 等字段),
判定逻辑在 `douyin/aiclass.py`,分四档,取最强的一档:
| 档 | 依据 | 含义 |
|---|---|---|
| `platform` | `ai.is_aigc` | 抖音自己打的 AIGC 标 |
| `strong` | 文案/标签有 AIGC、AI 生成工具名、ai+创作类词、AI 赛事标签 | 作品声明是 AI 做的 |
| `topic` | 只提到 AI 本身(ai 教程、人工智能、大模型、ChatGPT) | 在讲 AI,不一定是 AI 做的 |
| `author` | 只有作者昵称或音乐标题带 AI | 最弱,会误收 |
```bash
python scripts/clean_ai.py -i 抖音链接xxx.jsonl --stats # 先看分布
python scripts/clean_ai.py -i 抖音链接xxx.jsonl --tier platform,strong -o ai # 出 ai.jsonl + ai.txt
python pipeline.py --from-file ai.txt --region ap-chongqing # 直接接流水线
```
**要注意的**(都是从真实数据里挖出来的,改规则前先看 `test_aiclass.py`):
1. **`platform` 是唯一能抓到"作者没标但确实是 AI"的信号。** 实测 1744 条被平台标了
AIGC,文案标签里一个 ai 字都没有(脑筋急转弯、历史解说、原创动画)。纯关键词过滤
会整片漏掉。反过来 9000+ 条有明确 AI 信号却没被平台标——两路互补,都得要。
2. **`Al`(大写 A + 小写 L)冒充 AI。** 标签 `抖音al` 出现 129 次,文案里也有 `#Al创作`。
不归一化就漏。反过来 `ai` 是 email/detail/again/hair 的常见片段,只能认前后不接
字母数字的独立 `ai`。
3. **音乐标题默认是 `@<作者昵称>创作的原声`**,40087 条里 32555 条是这个壳。昵称带 AI
会连"创作"一起命中,等于让作者昵称从 music 这条路偷偷升到 `strong`——实测 771 条
这样混进来。现在先剥壳,剥完只当弱信号(全库剥完只剩 16 条还有命中,本来就没价值)。
4. **中文泛词不单独成立。** `动画`/`绘画`/`分身`/`动漫` 必须跟 AI 词同现,否则 830 条
`#原创动画` 全被误收。`剪映`(375)/`关键帧`(314)/`蒙版`(203)/`转场` 是剪辑术语,不是 AI。
5. **工具名不含 ai 字样的一大票**:即梦 / seedance / libtv / flovaai / 小云雀 /
runninghub / oiioii / shotlab / veo3,只能靠名单,名单要跟着平台活动更新。
6. **一批工具名本身是常用词,必须要求跟 ai/aigc 同现**(见 `_AMBIG_GEN` / `_AMBIG_CHAT`)。
实测误判:豆包 224 条(狗名、豆沙包)、万相 17 条(《龙神万相》)、海螺 13 条
(三峰的"海螺"帐篷)、元宝 11 条(316 不锈钢元宝勺)、星流 1 条("明星流量"子串)、
清影 1 条("起舞弄清影")。而真命中的**无一例外**都同时带 AI 字样,所以这道门槛
不掉召回——旧文件重跑 15218 → 15204,掉的正好是这些。
7. **英文工具名要挡词中词,但只挡字母,不挡数字和中文。** `udio` 藏在 `Grass Studio`
里 17 次、`flux` 藏在 `Fluxxwave` 曲名里。可是数据里满地都是 `Seedance2.0`、`sora2`、
`runninghub0.21`、`libtv创作者`、`flovaai超创`,一刀切加 `` 会丢 3500+ 条真命中。
8. **别放开"数字人"/"换脸"/"分身"当独立信号。** 数字人民币含"数字人"、微信分身含"分身"、
穿搭视频也说"这套穿搭能换脸"。实测放开只进误判,一条真的都捞不到。
同理否掉的还有:lora(撞 valorant)、tripo(撞 tripod 三脚架)、seaweed(海藻)、
度加("亏损幅度加大")、万彩("29万彩礼")、生数("诞生数副名场面")、节点("关键节点")、
抽卡(原神)、捏脸(燕云十六声)、国漫(《剑来》真人制作)、对口型(真人对口型)。
这 8 条否决理由都写成了测试,见 `test_rejected_candidates_stay_none`。
9. **AI 还有第三种冒充写法:罗马数字。** `#AⅠ漫剧` 用的是 U+2160,跟 `Al` 一个套路。
10. **csv 的原声标题多一截 ` - 昵称`**(`@X创作的原声 - X`),剥壳正则要一起覆盖,
否则第 3 条那个作者昵称升档的 bug 会在 csv 输入上复发。
### platform 档要不要求文案佐证
抖音给 **AI 配音 / AI 封面**的普通带货视频也打 AIGC 标。露营帐篷那两批里,
`platform` 档有一半(2045 条)文案和作者昵称都零信号,点开是"四脚伞帐篷带广告位"
"抖音私密账号是什么"。`--platform-needs-text` 就是卡这个的,作者昵称带 AI 也算佐证。
**但这个开关会误伤一类真 AI**:@栖光 那种做 AI 古风美图的,文案只写
`#古风 #国风古韵 #意境`,一个 AI 字都不会写。这类只有平台标签能抓到。
所以无佐证的那批不是垃圾,得按"是不是带货"再分一刀(见 `lists/platform无佐证_*.txt`)。
### 抖音的 AIGC 字段:认 risk_infos.type,别认 is_aigc_media
`raw.risk_infos.type` 是数字码,一一对应:
| type | content | 含义 |
|---|---|---|
| 44 | 作者声明:内容由 AI 生成 | 作者主动声明,19946 条 |
| 47 | 疑似使用了 AI 生成技术,请谨慎甄别 | 平台检测,措辞不确定,515 条 |
| 76 / 328 | 作品含 AI 生成内容 | 平台判定,448 条 |
**这几个才是 AI。**下面这些是别的免责声明,跟 AI 无关:
| type | content | 条数 |
|---|---|---|
| 45 | 作者声明:虚构演绎,仅供娱乐 | 868 |
| 336 / 351 | 个人观点,仅供参考 | 111 |
| 54 | 作者声明:内容来源于网络 | 13 |
| 335 | 作者声明:内容含营销推广信息 | 10 |
**已知 bug**:爬虫那层把「`risk_infos` 有内容」就当 `is_aigc=true`,把上面这些
免责声明也算进 `platform` 档了,全库误算 **1029 条**。修法见 §7。
另外 `raw.is_aigc_media` 全库 24611 条**全是 `false`**,名字最像却完全没用,别信。
### 各批筛选结果
| 源文件 | 总条数 | AI 占比 | 出的清单 |
|---|---|---|---|
| 抖音链接20260827_40087.jsonl | 40087 | 39.1% | `ai_videos.txt` 15218 |
| 抖音ai视频_20260831_87110.jsonl | 87110 | **3.3%** | `ai_87110_严格.txt` 1366 |
| 抖音ai视频_20260901_57261.jsonl | 57261 | **2.8%** | `ai_57261.txt` 776 |
| 抖音ai视频_20260902_42391.jsonl | 42391 | 90.9% | `ai_42391.txt` 37703 |
| 抖音ai视频_20260904_62171.jsonl | 62171 | 88.3% | `ai_62171.txt` 52360 |
| 抖音ai视频20260908_118469.jsonl | 118469 | 95.1% | `ai_118469.txt` 106447 |
| 所有作品.csv | 4839 | 未筛 | `csv_links.txt` 4839(全量下的) |
前两个文件(87110 / 57261)名字叫"ai视频",**实际是露营帐篷带货爬取**
(标签 TOP:户外露营 10568、帐篷 9346),AI 只占 3% 左右。9 月的三批
(42391 / 62171 / 118469)才是真 AI 池,且 platform 档占绝对大头——这三批
`allow_download=false` 里 `aigc_forbidden` 多达 2.8 万 / 3.7 万 / 7.5 万条,
但 App 端接口不受此限(见下)。拿到新文件先跑 `--stats` 看一眼,别信文件名。
`aigc_forbidden` 那道限制**只作用于网页端,App 端接口不受影响**——已用 20 条探针
验过,9 条 web 侧 `allow_download=false` 的全都正常下下来了。不用 `--exclude-forbidden`。
### lists/ 里各清单的关系
| 清单 | 条数 | 是什么 |
|---|---|---|
| `all_video_links(3).txt` | 3557 | 第一批输入 |
| `ai_videos.txt` | 15218 | 40087 批筛出的 AI |
| `csv_links.txt` | 4839 | 所有作品.csv 全量(未筛) |
| `ai_87110_严格.txt` / `ai_57261.txt` | 1366 / 776 | 露营两批筛出的 AI |
| `ai_42391.txt` / `ai_62171.txt` / `ai_118469.txt` | 37703 / 52360 / 106447 | 9 月三批筛出的 AI(platform+strong 档) |
| `ai_合并待下载.txt` | 66411 | 62171 全量 + 42391 部分 + 118469 无,合并去重后的跑批输入 |
| `ai_待下载.txt` | 2611 | 旧池剩余(platform 无佐证两档 + 零星),已下过 99%,基本作废 |
| `platform无佐证_非带货.txt` | 776 | AI 古风/漫剧/语录图文 |
| `platform无佐证_带货.txt` | 1331 | 待下(不建议):帐篷等实拍带货 |
截至 2026-09-29,9 月三批的下载进度都是 97%+(118469 批 103702/106447、
42391 批 37254/37703、62171 批 50989/52360),三批合计还有约 4565 条
不在 records.jsonl 里(含图文、已删除等下不动的)。
## 5. 自检
```bash
python tests/test_smoke.py # 离线冒烟:SM3/RC4/base64 自测、id 提取、Registry
python tests/test_meta.py # 标题/标签拆分、records.jsonl 追加与续跑
python tests/test_aiclass.py # AI 判定(用例全取自真实数据)
python tests/test_pipeline.py # 端到端:ffmpeg 真跑,网络和 COS 打桩
```
### 老产物 manifest.jsonl
`records/manifest.jsonl` 是第一批 5393 条四川话数据的清单(`cos_mp4` / `cos_mp3` / `duration` 三字段),
`exports/抖音-四川话.xlsx` 就是它的 xlsx 版。**它和 `records/records.jsonl` 零重合**——那 5393 条不在
`records/records.jsonl` 里,所以这个文件是这批数据在 COS 上的唯一记录,别删。
生成它的那套分阶段脚本(`main.py` / `extract_audio.py` / `probe_durations.py` /
`upload_cos.py`)已经删掉,功能全被 `pipeline.py` 覆盖了。这个项目没有 git,删了就是真没了——
但那四个脚本做的事 `pipeline.py` 一条命令全包含,唯一不能复现的是 manifest.jsonl 的三字段老格式。
其中 `probe_durations.probe()` 被 `pipeline.py` 用着,已搬进 `douyin/audio.py`。
---
## 6. 跑批结果
`records/records.jsonl` 累计 **219144 条 / 8676.02 小时 / 2483.43 GB(mp4)**,
全部在 `cos://asr-dig-data-1253181912/0005/zh/`。
9 月三批 AI 池(42391 / 62171 / 118469)已基本下完,各批进度 97%+。
早期按 AI 判档的拆分(platform 有佐证 5423 条 / strong 9036 条等)是 9/1 时点的
快照,那之后 records.jsonl 又进了十几万科目的 9 月 AI 批,旧数字只作历史参考。
### COS 前缀 0005/zh/ 混了八批
```
5393 四川话 只在 manifest.jsonl 里,不在 records.jsonl
3517 第一批链接
14884 AI 筛选批(40087 源)
4813 csv 全量批
2093 87110 + 57261 两批
37254 42391 批
50989 62171 批
103702 118469 批
```
**按前缀取数会一次拿到全部**,要区分只能走 `records.jsonl`。
## 7. 待办 / 已知问题
**该修的**
- **`is_aigc` 把免责声明当成了 AIGC**,全库误算 1029 条(见 §4.5)。爬虫那层应改成
`risk_infos.type in {44, 47, 76, 328}`,并把 `type` 一起存进记录——这样才能区分
「作者声明」(44) 和「平台检测」(47),两者可信度不同。改完 `platform` 档会掉约 1029 条,
真 AI 一条不少(那些走 strong)。
- **图文作品(`aweme_type` 68/61/55/163)不支持**,需要 `images[]` 分支。这是目前失败
的绝对大头:87110 批 38 条失败里 33 条是图文,AI 池里累计 241 条下不了。
图文在按 aweme_id 排序的清单里**扎堆在头部**(早期 AI 内容多是 Midjourney 出图),
所以跑批前几百条失败率会飙到 15%,后面掉到 2-3%,别被吓到。
**已知取舍**
- 最长一条 3398.988 秒(约 57 分钟),若 ASR 侧对单条时长有上限需要另行切分
- `split_desc` 在标签名以 `#名字` 形式也出现在正文里时会连正文一起删(见
`test_split_repeated_tagname_in_body_is_lossy`);服务端给了可信偏移时不会
- 设备配额轮换的触发条件是**每 550 次 detail**,但到底是按 IP 还是按 UA/设备指纹限流
一直没验证过。实测跑到第 3 次轮换都正常,暂时不用管
- `topic` / `author` 两档从没进过下载清单。`author` 档现在 14 条**全部来自音乐标题**
(`【AI配文风景滤镜】的原声` 这类抖音模板),作者昵称一条都没有——因为凡是作者叫 AI 的,
作品本身也都标了 AI,昵称这条线提供不了增量,它的价值只是给 `platform` 档做佐证
**安全**
- 聊天记录里出现过一组 COS 密钥,**建议轮换**。凭证只能走 `COS_SECRET_ID` /
`COS_SECRET_KEY` 环境变量,`CosUploader` 不读命令行参数

246
RE.md Normal file
View File

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

260
README.md Normal file
View File

@@ -0,0 +1,260 @@
# 抖音视频批量采集 → 音频提取 → COS 上传
把一批 `douyin.com/video/...` 链接跑成 ASR 可用的数据集:
**下载无水印 mp4 → 抽 mp3 → 探时长 → 传腾讯 COS → 出清单(jsonl / xlsx)**。
全程纯 Python,不依赖第三方解析服务、不依赖 JS 运行时、**不需要登录**。
核心走 App 端接口:先注册设备拿 `device_id`,用它取作品详情,CDN 直链下载。
任务进度、各批下载情况见 [PROGRESS.md](PROGRESS.md);
接口是怎么逆出来的(web 端为何弃用、App 端免签名链路、两层限流)见 [RE.md](RE.md)。
---
## 快速开始
```bash
pip install -r requirements.txt # 另需系统装有 ffmpeg / ffprobe
```
```powershell
$env:COS_SECRET_ID="..."; $env:COS_SECRET_KEY="..." # COS 凭证只走环境变量
# 先小批试跑,加 --keep-local 不删本地文件,人工看一眼产出
python pipeline.py --from-file lists/ai_62171.txt --region ap-chongqing --limit 20 --keep-local
# 确认没问题再放开(断点续跑:重启自动跳过已完成的)
python pipeline.py --from-file lists/ai_62171.txt --region ap-chongqing --workers 8
# 自检
python tests/test_smoke.py && python tests/test_meta.py && python tests/test_aiclass.py && python tests/test_pipeline.py
```
---
## 流水线详解
`pipeline.py` 是项目核心:一条命令完成 下载 → 抽音 → 上传 → 记录 → 删本地。
和分阶段跑(全下完再全抽完再全传完)的本质区别是磁盘占用:分阶段要把整批
mp4 摊在盘上(上一批 53 GB),流水线每条走完即删,**峰值只有
`workers × (mp4+mp3)` ≈ 100 MB,与总量无关**。
### 每条视频的 9 步
```
1. fetch_detail DevicePool 取设备 → App 端 detail → 元信息 + raw
2. 下载 mp4 play_addr 直链 / /aweme/v1/play/ 302,Range 续传
3. 抽 mp3 ffmpeg 转码(CPU 密集,信号量限到核数)
4. 探时长 ffprobe 读 mp4;mp3 用 -count_frames 实解码
5. 校验时长 mp3 实解时长 ≈ mp4 时长? ← 抽取闸门
6. 上传 mp4 COS,覆盖同名
7. 上传 mp3 同上
8. 核对大小 put() 内部 head_object 比对字节数 ← 删除闸门
9. 落库 + 删本地 写 records.jsonl,然后才 unlink mp4/mp3
```
**为什么第 4 步必须实解码**:LAME 写的 Xing 头声明的是原始总帧数,
`ffprobe format=duration` 直接信它不解码——实测把 mp3 截断到 20%,
它报的还是完整的 10.0078s,`ffmpeg -f null -` 同样拦不住(rc=0 无报错)。
只有 `-count_frames` 真解码能发现截断。代价可忽略:120s 的 mp3 是
0.109s 对 0.053s。两道闸门任一不过:保留本地文件、记 failed、继续下一条。
### 启动阶段:四件事做完才开跑
1. **解析输入**:`--from-file` 或命令行链接,经 `urls.py` 一律还原成
aweme_id,顺手去重、剔除已完成的(`aid in log`)、统计无法解析的。
2. **切段**(`plan_batch`,默认 `--max-items 12000`):单进程跑到 ~1.5 万条,
Windows 桌面堆被几万个 ffmpeg/ffprobe 子进程耗光,之后 CreateProcess
一律失败(rc=3221225794)。症状好认:完成数冻结、失败数猛涨、work/
暴涨。是**累积量**问题不是并发问题。段跑完 `os.execv` 重启自己,
断点记录不丢。
3. **清扫 work/**(`sweep_work_dir`):删两类残留——aweme_id 已在
records.jsonl 里的(早传上 COS 了)、`.part` 半截下载。**不碰失败项的
文件**:上传失败/抽音截断是故意留着的,重跑省一次下载。长批跑起来后
每 5 分钟还会自动清扫一次(光靠开跑前扫一次,跑到一半磁盘就满)。
4. **预热设备池**(`DevicePool`):惰性注册,先主动要一次设备。开跑就
注册不上 = IP 还在冷却期,按退避策略等待并重启(见下),而不是让每个
worker 各自撞一遍把整批标失败。
### 并发模型:两类资源分开限
| 资源 | 限流方式 | 默认值 |
|---|---|---|
| 网络(下载/上传/detail) | `--workers` 线程池 | 8 |
| CPU(ffmpeg/ffprobe) | `ffmpeg-workers` 信号量 | CPU 核数 |
下载用裸 `okhttp/3.12.13` UA 的 thread-local session(CDN 不查设备头);
每个 worker 的 detail 请求走 `DevicePool.current()` 共享设备身份。
### 限流的两级应对
**设备级 detail 配额**(~575 次/设备,默认 550 就换):detail 返回「空 body」
即判定设备被限,`pool.report_dead(gen)` 换设备重试一次。`generation` 标记
保证多 worker 同时发现失败时只换一轮。
**IP 级注册配额**(~12 次注册后所有 host 返回 `device_id='0'`):换设备没用,
只能等冷却。此时拉起全局 `abort`:后续任务直接跳过不计失败,当前批收尾后按
`next_abort_wait` 指数退避(默认 30 分钟起步、连撞翻倍、上限 4 小时)等待并
`os.execv` 重启续跑。连撞计数存在环境变量 `_DY_ABORT_STREAK` 里(execv 换进程
映像后靠它记住状态);但凡这一轮出过活就说明是跑着跑着才触发的限流,下次从
底价重等。**早期没有这套机制,一次注册受限直接烧掉 678 条任务。**
### 失败处理
- `StageError` 带阶段名(detail / download / audio / probe / upload),
连同原因追加进 `records/pipeline_failed.txt`,一行一条:`id\t阶段\t原因`。
- 顶层还有 `except Exception` 兜底记 `unknown`,**一条炸不掉整批**。
- 失败项的本地文件**故意保留**:重跑同一命令即可续传(已完成的自动跳过,
失败项重新走全流程)。跑完退出码:0 全成,4 有失败,3 开跑即注册失败,
5 撞上 IP 限流停下等手动重跑。
### 进度报告
后台线程每 15 秒打一行:`[已完成+失败/总数] 完成 失败 | GB 上传 | 条/分 | 剩约 N 分`,
兼做周期清扫的节拍器。
### 入口脚本
`pipeline.py` 在根目录(主入口),其余脚本在 `scripts/`,测试在 `tests/`。
| 文件 | 作用 |
|---|---|
| `pipeline.py` | **核心**。一体化流水线,逐步过程、闸门与限流应对见上面「流水线详解」 |
| `scripts/clean_ai.py` | 从外部抓来的链接 jsonl 里筛出 AI 相关作品,产出链接清单直接喂 pipeline。判定分四档(platform / strong / topic / author),先 `--stats` 看分布再 `-o` 出清单 |
| `scripts/jsonl_to_xlsx.py` | 把 `records/records.jsonl`(或老的 `manifest.jsonl`)清洗导出成交付用 xlsx:剔坏行、剔非法时长/前缀、按 mp4 地址去重。流式读,raw 不进内存 |
| `scripts/export_sample_xlsx.py` | 按甲方字段表把 records.jsonl 导成样本 xlsx(样本 ID / 标题 / 生成方式 / 工具模型 / 分辨率 / 发布时间 / AI 标识等),支持 `--ai-only`、`--limit` |
### 测试(`tests/`)
| 文件 | 覆盖 |
|---|---|
| `test_smoke.py` | 离线冒烟:crypto 官方向量自检、链接解析、文件名清洗、去重 Registry |
| `test_meta.py` | 标题/标签拆分(split_desc)、records.jsonl 追加写与幂等 |
| `test_aiclass.py` | AI 判定,用例全部取自真实数据(含 `Al` 冒充、罗马数字 Ⅰ 等冒充写法) |
| `test_pipeline.py` | 流水线端到端:ffmpeg 真跑,网络和 COS 打桩。覆盖两道删除闸门(时长不符 / 上传大小不符必须保留本地) |
### `douyin/` 包
| 文件 | 作用 |
|---|---|
| `app.py` | **核心**。App 端接口客户端:`/service/2/device_register/` 注册设备拿 `device_id`/`install_id`,带它打 `/aweme/v1/aweme/detail/` 取详情。无签名、无 Cookie、无登录。设备级 detail 配额约 575 次,超限换设备 |
| `batch.py` | 并发批处理骨架:ThreadPoolExecutor 调度、每 worker 独立 Session、`DevicePool` 用 generation 标记轮换设备(避免多 worker 重复换设备)、IP 级注册限流指数退避 |
| `download.py` | 带 Range 的断点续传下载;落盘文件名即 aweme_id,「是否已下」看文件在不在;`Registry` 去重记录 |
| `meta.py` | 标题/标签拆分(split_desc,认 desc 正则全集不认 text_extra)+ `RecordLog`:records.jsonl 线程安全追加写、行首 aweme_id 快速扫描续跑。约定:**aweme_id 必须是每行第一个字段** |
| `aiclass.py` | 判定作品是否 AI 相关,四档取最强:platform(平台 AIGC 标)> strong(文案/标签明确 AI 信号)> topic(在讲 AI)> author(仅昵称带 AI)。规则详解见 [AI.md](AI.md) |
| `cos.py` | 腾讯 COS 上传。`put()` 上传后必 `head_object` 取 Content-Length 与本地字节比对,对不上算失败——删除闸门卡在这 |
| `audio.py` | ffmpeg/ffprobe 薄封装:定位可执行文件、mp4 抽音轨(默认转 mp3 192k,`copy` 模式直接封装 m4a 无损)、`-count_frames` 实解码探时长(format=duration 会被 Xing 头骗过,不能用) |
| `urls.py` | 链接解析:裸 id / 分享短链 / 整段分享口令 / video / note / modal_id 一律还原成 aweme_id |
| `client.py` | Web 端客户端(`Aweme` / `DouyinError` 等共用类型 + web 请求路径)。类型被正常用着;**web 请求路径闲置**——匿名被强制登录,见 PROGRESS.md |
| `signer.py` | a_bogus 签名器接口。`NodeSigner` 需要项目外的 `node_signer/sign.js`(不在仓库里);`ABogusSigner` 纯 Python 版没写完。web 端真要走通得先补这块 |
| `crypto.py` | 签名器的密码学积木:SM3 / RC4 / 自定义 Base64,带官方测试向量自检。目前只被 test_smoke 验证,未被 signer 使用 |
### 数据目录
| 目录 | 内容 |
|---|---|
| `data/` | 源数据 jsonl/csv(外部抓取的输入,删了没法重新筛,别动) |
| `records/` | `records.jsonl`(流水线产物 + 断点记录,一行一条含 raw)、`manifest.jsonl`(第一批老格式清单)、`pipeline_failed.txt`(失败清单) |
| `exports/` | xlsx/csv 交付导出物 |
| `lists/` | 各批的链接清单,也是「哪批下了什么」的凭据 |
| `logs/` | 各批跑批日志 |
---
## 使用指南
### 环境准备
```bash
pip install -r requirements.txt
```
| 依赖 | 用途 | 备注 |
|---|---|---|
| Python ≥ 3.11 | | 实测 3.11.0 |
| `ffmpeg` / `ffprobe` | 抽音轨、探时长 | 需在 PATH,实测 2025-07-31 gyan.dev full build |
| `requests` | 接口/下载 | |
| `cos-python-sdk-v5` | COS 上传 | 延迟导入,不传 COS 可不装 |
| `openpyxl` | xlsx 导出 | 延迟导入,不出 xlsx 可不装 |
COS 凭证只走环境变量(不进命令行、不进代码):
```powershell
$env:COS_SECRET_ID = "..."
$env:COS_SECRET_KEY = "..."
```
### 典型工作流:从一批源数据到交付
```bash
# ① 拿到外部抓的源 jsonl,先放 data/,看 AI 分布(别信文件名,先 --stats)
python scripts/clean_ai.py -i data/抖音ai视频_20260904_62171.jsonl --stats
# ② 筛出 platform+strong 档,出链接清单到 lists/
python scripts/clean_ai.py -i data/抖音ai视频_20260904_62171.jsonl --tier platform,strong -o lists/ai_62171
# ③ 小批试跑 20 条,--keep-local 不删本地,人工检查 work/ 里的 mp4/mp3
python pipeline.py --from-file lists/ai_62171.txt --region ap-chongqing --limit 20 --keep-local
# ④ 正式跑批。断点续跑靠 records/records.jsonl,中断/重启/换机器都能续
python pipeline.py --from-file lists/ai_62171.txt --region ap-chongqing --workers 8
# ⑤ 失败的重跑:同一条命令再跑一遍即可(已完成的自动跳过,
# 失败项的本地文件还留着,省一次下载)
python pipeline.py --from-file lists/ai_62171.txt --region ap-chongqing --workers 8
# ⑥ 出交付表
python scripts/jsonl_to_xlsx.py -i records/records.jsonl --columns full -o "exports/全量.xlsx"
python scripts/export_sample_xlsx.py --ai-only -o "exports/AI视频样本.xlsx"
```
### pipeline.py 参数速查
| 参数 | 默认 | 说明 |
|---|---|---|
| `target...` / `--from-file` | | 链接或 aweme_id,二选一或混用;文件里 `#` 开头行视为注释 |
| `--region` | **必填** | COS 地域,如 ap-chongqing |
| `--bucket` / `--prefix` | asr-dig-data-1253181912 / 0005/zh/ | COS 落点 |
| `--workers` | 8 | 网络并发数 |
| `--bitrate` | 192k | mp3 码率 |
| `--ffmpeg-workers` | CPU 核数 | ffmpeg 并发上限 |
| `--device-quota` | 550 | 单设备 detail 调用上限(实测 ~575 被限,留余量) |
| `--limit` | 0 | 只跑前 N 条,试跑用 |
| `--max-items` | 12000 | 单进程跑满即自动重启接着跑,防 Windows 桌面堆耗尽;0=不切 |
| `--abort-wait` / `--abort-wait-cap` | 1800s / 14400s | 撞 IP 级注册限流后的等待/上限,连撞翻倍 |
| `--keep-local` | 关 | 上传后不删本地文件(试跑检查用) |
| `--records` / `--failed` | records/ 下两个文件 | 元信息落点 / 失败清单 |
退出码:0 全成;4 有失败(见 failed 文件,重跑同命令续传);3 开跑即注册失败;
5 撞上 IP 限流已停下,等几小时冷却后重跑同命令。
### clean_ai.py 参数速查
| 参数 | 默认 | 说明 |
|---|---|---|
| `-i` | 必填 | 源 jsonl |
| `--stats` | | 只看分布不落盘,拿到新文件第一步 |
| `--tier` | platform,strong | 保留哪些档(platform/strong/topic/author/none) |
| `-o` | 空 | 输出前缀,生成 `<前缀>.txt`(要 `--with-jsonl` 才另出 jsonl) |
| `--platform-needs-text` | 关 | platform 档要求文案也有 AI 信号。会误伤文案零信号的 AI 古风作者,见 AI.md §8 |
| `--exclude-forbidden` | 关 | 剔 allow_download=false 的。**不需要**——aigc_forbidden 只限 web 端 |
| `--samples N` | 0 | 每档打印 N 条样例到 stderr,复核用 |
### 导出参数速查
```bash
python scripts/jsonl_to_xlsx.py --list-columns # 先看有哪些列可选
python scripts/jsonl_to_xlsx.py --columns full ... # full = 标题/作者/标签/时长等常用全字段
python scripts/export_sample_xlsx.py --ai-only --limit 500 ...
```
### 常见问题
- **跑到一半卡住、完成数冻结、失败猛涨**:撞 Windows 桌面堆上限了。Ctrl-C 后重跑
同命令即可(断点续跑),或调低 `--max-items` 让重启更频繁。
- **大量「空 body」失败**:设备配额到了属正常(会自动换设备);若报
「IP 级注册配额耗尽」,流水线会自己等待续跑,放着不管即可。
- **跑批前几百条失败率 15%**:正常。图文作品(图集)扎堆在 aweme_id 排序的头部,
后面会掉到 2-3%。
- **想只重试失败项**:把 failed 文件第一列抠出来当输入重跑,或直接重跑原命令
(已完成项跳过,代价只是启动时扫一遍 records)。

View File

@@ -0,0 +1,132 @@
# 抖音采集流水线一体化 —— 设计
日期:2026-08-25
## 目标
把现在的 4 个独立阶段(下载 → 抽 mp3 → 探时长 → 传 COS)合成一条流水线,
每条视频端到端走完就删本地文件,并把标题等全部元信息记进一个本地 jsonl。
## 动机
1. **磁盘**:分阶段跑必须把全部 mp4 摊在盘上(本次 53 GB),单条流水线的
峰值只有 `workers × (mp4+mp3)`,约 100 MB,且与总量无关。
2. **元信息丢失**:现在只有 `.downloaded.json` 记了 author/desc/uri,且它跟着
输出目录一起被删掉了。元信息需要一个独立于媒体文件的落点。
## 架构
一个 worker 端到端负责一条,不再分阶段:
```
1. fetch_detail(id) → Aweme + raw
2. 下载 mp4 → {--work-dir}/{id}.mp4
3. ffmpeg 抽 mp3 → {--work-dir}/{id}.mp3 [信号量限流]
4. ffprobe 探 mp4 时长;mp3 用 -count_frames 实际解码算时长
5. 校验 |mp3实解时长 - mp4时长| <= max(1s, 2%) ← 抽取闸门
6. 上传 mp4 → COS(覆盖同名对象)
7. 上传 mp3 → COS(覆盖同名对象)
8. head_object 核对两个对象 Content-Length == 本地字节数 ← 删除闸门
9. 追加 record 到 records.jsonl,然后删本地 mp4 / mp3
```
工作目录由 `--work-dir` 指定,默认 `work/`,mp4 和 mp3 同放一处——
反正传完就删,不需要像原来那样分 `downloads/` 和 `audio/`。
### 并发
- 下载、上传是网络密集 → 按 `--workers` 并发(默认 8)
- ffmpeg 是 CPU 密集 → 单独用信号量限到 `cpu_count`,避免 8 个 worker
同时撞进第 3 步把 CPU 打满
### 失败处理
任何一步失败:**保留本地文件不删**,写入 `pipeline_failed.txt`(id + 阶段 + 原因),
继续下一条。不中断整批。
### 断点续跑
启动时扫 `records.jsonl` 已完成的 id。为避免对数百 MB 的文件逐行 `json.loads`,
**`aweme_id` 必须是每行的第一个字段**,扫描用 `^\{"aweme_id":"(\d+)"` 直接抠。
这是一个契约,重排字段会让启动扫描从 ~1s 退化到十几秒。
## 数据
### records.jsonl(唯一落点,仅本地,不上传 COS)
每行一条,字段顺序固定,`aweme_id` 在最前:
```json
{"aweme_id":"6797367856917990659",
"title":"剥掉标签后的纯标题",
"desc":"服务端原样 desc,一字不改",
"tags":["标签1","标签2"],
"author":{"nickname":"...","uid":"...","sec_uid":"..."},
"create_time":1582635538,
"create_time_str":"2020-02-25 21:38:58",
"duration_sec":41.702993,
"duration_ms_api":41750,
"stats":{"digg":0,"comment":0,"collect":0,"share":0,"play":0},
"music":{"title":"...","author":"...","url":"..."},
"cover_url":"...",
"video":{"uri":"...","width":1280,"height":720},
"cos_mp4":"cos://asr-dig-data-1253181912/0005/zh/{id}.mp4",
"cos_mp3":"cos://asr-dig-data-1253181912/0005/zh/{id}.mp3",
"size_mp4":4026560,
"size_mp3":1001230,
"source_url":"https://www.douyin.com/video/{id}",
"fetched_at":1756000000,
"raw":{ ...服务端 aweme_detail 原样... }}
```
`duration_sec`(ffprobe 精确解码值)与 `duration_ms_api`(服务端声明值)**都留**:
实测同一条 41703 ms vs 41750 ms,不等价,前者才能用于 ASR 切分。
预计体积 200~400 MB / 5400 条。因此所有读取必须逐行流式。
### 标题拆分
- `desc` —— 原样照抄,唯一真相来源
- `title` —— 用 `text_extra` 的 start/end 偏移剥掉标签,再用正则兜底
- `tags` —— **正则从 `desc` 提取全集**,`text_extra` 只用来补 hashtag_id
理由:实测 12 条样本中有 2 条的标签在 `desc` 里有、`text_extra` 里没有
(如 `#非洲搞笑视频`、`#女人`)。以 `text_extra` 为准会静默丢标签。
## 文件改动
| 文件 | 改动 |
|---|---|
| `pipeline.py` | 新增,流水线入口 |
| `douyin/meta.py` | 新增:`split_desc()` / `build_record()` / `RecordLog` |
| `douyin/cos.py` | 新增:从 `upload_cos.py` 抽出 `CosUploader`(含 `verify()`) |
| `douyin/client.py` | `Aweme` 补 tags / duration_ms / uid / sec_uid / stats / width / height |
| `jsonl_to_xlsx.py` | 改流式读取,支持 `records.jsonl`,加 `--columns` |
| `test_smoke.py` | 加 `split_desc` / `RecordLog` 离线测试 |
原有 4 个单阶段脚本保留不动,仍可单独使用。
## 安全
- 删除是唯一不可逆操作,卡在第 5、8 两道闸门之后
- **mp3 截断校验必须实际解码**。`ffprobe format=duration` 读的是 LAME 写的
Xing 头,它声明的是原始总帧数:实测把 mp3 截断到 20%,报的仍然是完整的
10.0078s,和没截断时一字不差。`ffmpeg -f null -` 也拦不住(rc=0、无 stderr)。
只有 `-count_frames` 会真解码,代价是 120s 的 mp3 多花 0.056s(0.109 vs 0.053)
- `--keep-local` 关闭删除,供试跑
- COS 上已存在同名对象时**直接覆盖**,不做存在性检查、不比对远端大小。
本地这一份永远是权威版本。少一个分支,行为也更好预测。
- 重复劳动由 `records.jsonl` 的断点续跑拦掉(已完成的 id 整条跳过,
连下载都不会发生),所以不需要再靠 COS 存在性去省事
- COS 凭证只从环境变量 `COS_SECRET_ID` / `COS_SECRET_KEY` 读
### 重跑老数据的后果
拿原来那 5406 条链接重跑,会重新下载约 53 GB 并覆盖 COS 上现有的 68 GB。
这是预期行为——覆盖后本地与 COS 一致,且拿到全量元信息。
## 不做
- 不回填已下载的 5393 条(除非显式拿老链接重跑)
- 不改 `manifest.jsonl` / `抖音-四川话.xlsx` 既有格式
- 不支持图文作品

37
douyin/__init__.py Normal file
View File

@@ -0,0 +1,37 @@
"""抖音单视频无水印下载。"""
from .app import AppClient, Device, DeviceRegisterError, NotAVideo
from .batch import BatchStats, run_batch
from .client import (
Aweme,
DouyinClient,
DouyinError,
LoginRequired,
decode_abort,
play_url_from_uri,
)
from .cos import CosError, CosUploader, SizeMismatch
from .download import Registry, download, safe_filename
from .meta import RecordLog, build_record, iter_records, split_desc
from .signer import (
ABogusConfig,
ABogusSigner,
NodeSigner,
SignError,
Signer,
SignerNotConfigured,
)
from .urls import ParseError, extract_aweme_id
__all__ = [
"AppClient", "Device", "DeviceRegisterError", "NotAVideo",
"BatchStats", "run_batch",
"Aweme", "DouyinClient", "DouyinError", "LoginRequired", "decode_abort",
"play_url_from_uri",
"Registry", "download", "safe_filename",
"CosError", "CosUploader", "SizeMismatch",
"RecordLog", "build_record", "iter_records", "split_desc",
"ABogusConfig", "ABogusSigner", "NodeSigner", "SignError", "Signer",
"SignerNotConfigured",
"ParseError", "extract_aweme_id",
]

258
douyin/aiclass.py Normal file
View File

@@ -0,0 +1,258 @@
"""判定一条抖音作品是不是 AI 相关,以及是哪一类相关。
分四档,档位之间不重叠,取最强的那一档:
platform 抖音自己打了 AIGC 标(ai.is_aigc)。最可信,而且是唯一能抓到
"作者什么都没标、但确实是 AI 生成"的信号 —— 实测 1744 条属于
这种,文案标签里一个 ai 字都没有(脑筋急转弯、历史解说、原创动画)。
strong 文案/标签里有明确的"AI 生成作品"信号:AIGC、AI 生成类工具名、
ai+创作类词、平台的 AI 赛事标签。
topic 只提到 AI 本身(ai 教程、人工智能、大模型、ChatGPT 之类),
片子讲的是 AI,但不一定是 AI 做的。
author 作品本身没信号,只有作者昵称里带 AI/AIGC。最弱,会误收
(实测 @牛牛Ai 发的普法解说、@你好,我是大福 的真人游泳视频)。
坑(都是从这批 40087 条真实数据里挖出来的,别顺手删):
1. "Al"(大写 A + 小写 L)被当成 AI 用,标签 `抖音al` 出现 129 次,
文案里也有 `#Al创作`。必须归一化,否则整片漏掉。
2. 反过来,`ai` 是英文单词的常见片段(email / detail / again / hair),
所以只认前后不接字母数字的独立 ai,不能用 in 判断。
3. `剪映`/`关键帧`/`蒙版`/`转场`/`剪辑教程` 是剪辑术语,不是 AI。
它们在这批数据里量很大(剪映 375、关键帧 314、蒙版 203),
早期版本把"动画/绘画/分身"这类词单独当信号,误收了几千条。
所以中文泛词一律要求跟 AI 词同现,不单独成立。
4. 工具名不含 ai 字样的一大票:即梦 / seedance / libtv / flovaai /
小云雀 / 随变 / runninghub / oiioii / shotlab,只能靠名单。
5. 音乐标题默认是 `@<作者昵称>创作的原声`,40087 条里有 32555 条是这个壳。
昵称带 AI 就会连"创作"一起命中,等于让作者昵称从 music 这条路
偷偷升到 strong —— 实测 771 条这样混进来(@冠宇Aigc 的猴子炒菜、
@牛牛Ai 的大案纪实)。所以先剥壳,剥完只当弱信号:全库剥完只剩
16 条音乐标题还有命中,这条路本来就没什么价值。
注意壳有两种写法:jsonl 里是 `@昵称创作的原声`,csv 导出的是
`@昵称创作的原声 - 昵称`,后缀那半截也要一起剥。
6. 一批工具名本身就是常用词,单独出现几乎全是误判,必须要求跟 ai/aigc
同现(见 _AMBIG_*)。实测:豆包 224 条误判(狗名、豆沙包、"反强包
之神")对 145 条真命中;海螺 13 条是三峰的"海螺"帐篷;元宝 11 条是
元宝勺辅食餐具;万相 17 条是《龙神万相》和万相集;星流 1 条来自
"明星流量"(子串);清影 1 条来自"起舞弄清影"。而真命中的那些**无一
例外**都同时带 AI 字样,所以加这道门槛不掉召回。
7. 别把"数字人""换脸""分身"放开成单独信号:数字人民币含"数字人"、
微信分身/五五分身材含"分身"、穿搭视频也说"这套穿搭能换脸"。
实测放开只进误判,一条真的都捞不到。
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Tuple
# 独立的 ai —— 前后不能接字母数字,挡掉 email、detail、hair。
# 第二个字符收了三种冒充写法:i、小写 L、罗马数字 Ⅰ/ⅰ(U+2160/U+2170)。
# 实测标签 `抖音al` 129 次、`#AⅠ漫剧` 1 条。别改成简单的 in 判断。
_I = r'[ilⅠⅰ]'
_AI = re.compile(r'(?<![a-z0-9])a' + _I + r'(?![a-z0-9])', re.I)
_AIGC = re.compile(r'(?<![a-z0-9])a' + _I + r'gc(?![a-z0-9])', re.I)
# 生成类工具 / 平台。命中即 strong —— 用了这些东西,片子就是 AI 做的。
_GEN_TOOLS = [
"即梦", "dreamina", "jimeng", "seedance", "seedream", "可灵", "kling",
"hailuo", "vidu", "pixverse", "pika", "luma", "runway", "sora",
"viggle", "heygen", "midjourney", "comfyui", "runninghub", "flux",
"nanobanana", "nano banana", "stable diffusion", "liblib",
"krea", "ideogram", "recraft", "leonardo",
"higgsfield", "suno", "udio", "libtv", "flovaai", "海艺", "小云雀",
"shotlab", "sekoai", "movieflow", "updream", "cineart", "oiioii",
"minimax", "妙鸭", "一帧秒创", "触手ai", "拍我ai", "美图ai",
"文生视频", "图生视频", "文生图", "图生图", "生成式",
# --- 下面这批是 2026-08 补的。全部在 12.7 万条真实数据上验过:
# 要么零命中(纯属给以后的文件备着),要么命中的全是真 AI,
# 没有一个引入新误判。判断标准是"这串字在中文短视频文案里
# 不可能有别的意思",所以像 tripo(三脚架 tripod)、seaweed
# (海藻)、lora(撞 valorant)、captions、playground 这些
# 一律没收 —— 实测 lora 命中 3 条全是无畏契约电竞。
"veo3", "wan2.1", "wanx", "通义万相", "hunyuanvideo", "混元视频",
"cogvideo", "open-sora", "animatediff", "controlnet", "智谱清影",
"step-video", "hedra", "kaiber", "haiper", "moonvalley", "genmo",
"domoai", "synthesia", "akool", "deepbrain", "opusclip", "argil",
"kreadoai", "腾讯智影", "讯飞智作", "硅基智能", "魔音工坊", "元气口播",
"万兴播爆", "闪剪", "声音克隆", "语音克隆", "克隆音色", "音色克隆",
]
# AI 漫剧圈的专有名词:**单独出现即 strong**,不要求跟 ai 同现。
# 抖音的 AI 漫剧已经形成固定圈子,这些词在 18.4 万条真实数据里几乎只出现在
# AI 作品上(括号=全库命中/其中现在还漏掉的)。选词标准是"这串字在中文短视频
# 文案里不可能有别的意思":
# 漫剧(1568/64) 治愈系漫画(80/0) 渡灵书(62/0) 超级裂变(46/0) 漫剧推荐(38/2)
# 二次元动漫(33/0) 月落小溪(25/4) 七罪创世(21/0) 晚夏小梦(67/4)
# 灵绘漫客(12/0) neowow(12/0) 造梦国度(2/0) 猫箱跨次元相遇(114/12)
# 没收的(实测污染严重,它们是题材不是 AI 标记):
# 西游记(+26 真·玄奘故里游记) 孙悟空(+16) 宫崎骏(+10 龙猫童年) 民间故事(+7《狼来了》)
# 睡前故事(+5 爷俩演西游) 神话故事(+2) 国漫推荐(+4《剑来》真人制作)
# 原创动漫(+18 手绘二次元) 创作者激励计划(+12 原神/卡西尼号) 未来导演扶持计划(+4)
# 开放赛道(+2) —— 后两个是抖音通用活动,不专属 AI
_MANJU = [
"漫剧", "治愈系漫画", "渡灵书", "超级裂变", "二次元动漫", "月落小溪",
"七罪创世", "晚夏小梦", "灵绘漫客", "neowow", "造梦国度", "猫箱跨次元相遇",
# 红果(字节的免费短剧 App)上的 AI 漫剧片名。来源是新闻报道的热榜,
# **不是红果的完整目录** —— 红果不公开片库,AI 漫剧实际有成百上千部,
# 这里只有见诸报道的头部作品,以后遇到新的往这加。
# 在 18.4 万条上验过:16/18 零命中(纯属备着),万妖图录传 1 条且正是
# 现在漏掉的真 AI 漫剧,零污染。
"万妖图录传", "发配边关", "罪妻开荒养出战神", "儿媳的重生棋局",
"斩仙台", "菩提临世", "聚宝仙盆", "杂灵根", "渔乡守真心", "金钱照冷暖",
"杀猪辣媳", "夫人一个过肩摔", "瘫子总裁站起来", "凡人百世书",
"都重生了谁还装富二代",
# **"红果"/"红果短剧" 不能进这个表**:红果主体是真人短剧,AI 漫剧只是
# 其中一个赛道。实测 3 条命中里 2 条是真人短剧(《家里家外2》《冷御失守》),
# 1 条是短剧演员面试。要抓红果的 AI 漫剧只能靠具体片名。
]
# 名字本身是常用词的生成类工具:只有跟 ai/aigc 同现才算 strong。
# 见模块开头第 6 条 —— 单独出现基本全是误判,真命中都带 AI 字样。
_AMBIG_GEN = [
"海螺", "白日梦", "万相", "可图", "清影", "星流", "秒画",
"造梦日记", "随变",
]
# 对话/通用大模型。这些更像是"在聊 AI",归 topic。
_CHAT_TOOLS = [
"chatgpt", "deepseek", "kimi", "通义", "千问", "qwen", "文心",
"ernie", "智谱", "glm", "copilot", "gemini", "grok",
"人工智能", "大模型", "神经网络", "机器学习", "深度学习",
]
# 同上,但归 topic 档的那批常用词模型名
_AMBIG_CHAT = ["豆包", "元宝", "混元"]
# 单独成词才算,避免 gpt 撞上别的串
_CHAT_RE = re.compile(r'(?<![a-z0-9])(?:gpt|claude|llm|agi|ai\s*agent)(?![a-z0-9])')
# 跟 ai 同现才成立的"创作/生成"语境词。单独出现一律不算。
_GEN_CTX = [
"短剧", "漫剧", "动画", "动漫", "视频", "短片", "电影", "影视", "绘画",
"绘图", "创作", "制作", "生成", "分身", "剧情", "作画", "配音", "换脸",
"特效", "导演", "演员", "数字人", "虚拟人", "成片", "新影像", "影像",
"浪潮", "大赛", "赛道", "大赏", "工作台", "写真", "画师", "作品", "片子",
"微短剧", "番剧", "剪辑", "转绘", "扩图", "修图", "提示词",
]
_GEN_CTX_RE = re.compile("|".join(map(re.escape, _GEN_CTX)))
def _alt(words: List[str]) -> re.Pattern:
"""把词表编成一个正则。纯 ASCII 的词加字母边界:前后不能紧接字母,
但**允许接数字和中文** —— 数据里满地都是 `Seedance2.0`、`sora2`、
`runninghub0.21`、`libtv创作者`、`flovaai超创`,一刀切加 \\b 会全丢。
只挡真正的词中词:`udio` 藏在 `theonestudio`/`Grass Studio` 里 17 次,
`flux` 藏在 `Fluxxwave` 曲名里 1 次,两个都是从 music 混进 author 档的。
"""
parts = []
for w in words:
e = re.escape(w)
if re.fullmatch(r"[a-z0-9 .\-]+", w):
e = r"(?<![a-z])" + e + r"(?![a-z])"
parts.append(e)
return re.compile("|".join(parts))
_TOOL_RE = _alt(_GEN_TOOLS)
_CHATW_RE = _alt(_CHAT_TOOLS)
_MANJU_RE = _alt(_MANJU)
_AMBIG_GEN_RE = _alt(_AMBIG_GEN)
_AMBIG_CHAT_RE = _alt(_AMBIG_CHAT)
# 抖音默认原声的标题壳,里面就是作者昵称,不含额外信息。
# jsonl 是 `@昵称创作的原声`,csv 导出多一截 ` - 昵称`,见模块开头第 5 条。
_SOUND_WRAP = re.compile(r'^@.*?(?:创作的原声|的原声)(?:\s*-\s*.*)?$')
TIERS = ("platform", "strong", "topic", "author", "none")
_RANK = {t: i for i, t in enumerate(TIERS)}
def _fields(rec: Dict[str, Any]) -> Tuple[str, str, str]:
"""(作品文本, 作者昵称, 音乐标题)。作品文本 = desc + 所有标签名。"""
tags = [t.get("name", "") if isinstance(t, dict) else str(t)
for t in (rec.get("hashtags") or [])]
body = " ".join([rec.get("desc") or ""] + tags)
author = (rec.get("author") or {}).get("nickname") or ""
music = ((rec.get("music") or {}).get("title") or "").strip()
if _SOUND_WRAP.match(music):
music = "" # 只是作者昵称的复读,见模块开头第 5 条
return body, author, music
def _scan(text: str, where: str) -> Tuple[str, List[str]]:
"""扫一段文本,返回 (档位, 命中的信号)。档位只可能是 strong/topic/none。"""
low = text.lower()
sig: List[str] = []
tier = "none"
has_aigc = bool(_AIGC.search(low))
has_ai = bool(_AI.search(low))
# 歧义词表的开关:文本里得先有 ai/aigc 字样,那些常用词才作数
ai_present = has_ai or has_aigc
if has_aigc:
sig.append(f"{where}:aigc")
tier = "strong"
for m in sorted(set(_TOOL_RE.findall(low))):
sig.append(f"{where}:工具:{m}")
tier = "strong"
# 漫剧专名单独成立,不要求 ai 同现
for m in sorted(set(_MANJU_RE.findall(low))):
sig.append(f"{where}:漫剧:{m}")
tier = "strong"
if ai_present:
for m in sorted(set(_AMBIG_GEN_RE.findall(low))):
sig.append(f"{where}:工具:{m}")
tier = "strong"
if has_ai:
ctx = sorted(set(_GEN_CTX_RE.findall(text)))
if ctx:
sig.append(f"{where}:ai+{'/'.join(ctx[:4])}")
tier = "strong"
else:
sig.append(f"{where}:ai")
if tier == "none":
tier = "topic"
chat = set(_CHATW_RE.findall(low)) | set(_CHAT_RE.findall(low))
if ai_present:
chat |= set(_AMBIG_CHAT_RE.findall(low))
chat = sorted(chat)
if chat:
sig.append(f"{where}:模型:{'/'.join(chat[:4])}")
if tier == "none":
tier = "topic"
return tier, sig
def classify(rec: Dict[str, Any]) -> Dict[str, Any]:
"""判一条记录。返回 {tier, signals, is_aigc}。"""
body, author, music = _fields(rec)
signals: List[str] = []
tier = "none"
is_aigc = bool((rec.get("ai") or {}).get("is_aigc"))
label = ((rec.get("ai") or {}).get("label") or "").strip()
if is_aigc:
tier = "platform"
signals.append(f"platform:{label or 'is_aigc'}")
t, s = _scan(body, "desc")
signals.extend(s)
if _RANK[t] < _RANK[tier]:
tier = t
# 作者昵称和音乐标题都只是"谁发的",不是"作品是什么"。
# 只在作品自己毫无信号时兜底成 author 档,绝不往上顶。
for where, text in (("author", author), ("music", music)):
wt, wsig = _scan(text, where)
if wt != "none":
signals.extend(wsig)
if tier == "none":
tier = "author"
return {"tier": tier, "signals": signals, "is_aigc": is_aigc}

343
douyin/app.py Normal file
View File

@@ -0,0 +1,343 @@
"""App 端接口客户端 —— 无签名、无 Cookie、无登录。
实测链路(每一步都验过):
1. POST /service/2/device_register/ 引导调用,不需要任何签名
-> device_id / install_id
2. GET /aweme/v1/aweme/detail/ 带上真实 device_id/iid 即可
-> 完整 aweme_detail,含 video.play_addr.uri
3. GET /aweme/v1/play/?video_id=URI 免鉴权,302 到 douyinvod CDN
-> mp4
对比 web 端:web 的 /aweme/v1/web/aweme/detail/ 对匿名用户返回
{"name":"强制登录"},且要算 a_bogus。App 端两样都不需要。
早先测 App 端拿到空 body,是因为 device_id/iid 是伪造的;换成注册得来的
真实凭据就正常了 —— 空 body 不代表要签名。
"""
from __future__ import annotations
import json
import random
import time
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import urlencode
import requests
from .client import Aweme, DouyinClient, DouyinError
REGISTER_HOSTS = (
"https://log.snssdk.com/service/2/device_register/",
"https://api.amemv.com/service/2/device_register/",
"https://log-api.amemv.com/service/2/device_register/",
"https://toblog.ctobsnssdk.com/service/2/device_register/",
)
API_HOSTS = (
"https://aweme.snssdk.com",
"https://api.amemv.com",
)
APP_VERSION = "9.9.9"
VERSION_CODE = "990"
UPDATE_VERSION_CODE = "99013"
APP_ID = "1128"
CHANNEL = "douyin_official"
# 机型池。openudid/cdid 本来就每次随机,但 UA、机型、分辨率这些以前是写死的,
# 意味着我们所有注册请求在这些维度上完全一致。
#
# 注册受限时到底是按 IP 还是按"UA+机型"这类特征分桶,我们没验证过——本机没有
# 第二个出口 IP,做不了对照实验。把机型随机化是成本极低的两用手段:
# 如果是按机型特征分桶,它能缓解;如果是纯 IP,它无害。
#
# 每项是 (device_type, device_brand, os_version, os_api, resolution, dpi)。
DEVICE_POOL = (
("SM-G9750", "samsung", "12", "31", "1080*2340", "420"),
("SM-S9080", "samsung", "13", "33", "1440*3088", "560"),
("PDRM00", "OPPO", "12", "31", "1080*2400", "480"),
("V2118A", "vivo", "12", "31", "1080*2376", "480"),
("M2012K11AC", "Xiaomi", "13", "33", "1080*2400", "440"),
("ELS-AN00", "HUAWEI", "12", "31", "1176*2400", "480"),
("21091116AC", "Redmi", "12", "31", "1080*2400", "440"),
("NOH-AN01", "HONOR", "12", "31", "1344*2772", "480"),
)
# 兼容旧引用:默认取池子里第一项
DEVICE_TYPE, DEVICE_BRAND, OS_VERSION, OS_API = DEVICE_POOL[0][:4]
class DeviceRegisterError(DouyinError):
pass
class NotAVideo(DouyinError):
"""作品存在,但不是视频(图文/图集)。"""
def _hex(n: int) -> str:
return "".join(random.choice("0123456789abcdef") for _ in range(n))
class Device:
"""一台虚拟设备的身份。注册一次可长期复用,所以落盘缓存。"""
def __init__(self, device_id: str, install_id: str, openudid: str,
cdid: str, clientudid: str,
profile: Optional[tuple] = None) -> None:
self.device_id = device_id
self.install_id = install_id
self.openudid = openudid
self.cdid = cdid
self.clientudid = clientudid
# 机型档案必须跟着设备走:注册时报的是小米、后续 detail 请求的 UA
# 却是三星,这种自相矛盾本身就是个可疑特征。
self.profile = tuple(profile) if profile else DEVICE_POOL[0]
@property
def device_type(self) -> str:
return self.profile[0]
@property
def device_brand(self) -> str:
return self.profile[1]
@property
def os_version(self) -> str:
return self.profile[2]
@property
def os_api(self) -> str:
return self.profile[3]
@property
def resolution(self) -> str:
return self.profile[4]
@property
def dpi(self) -> str:
return self.profile[5]
@property
def user_agent(self) -> str:
return (f"com.ss.android.ugc.aweme/{UPDATE_VERSION_CODE} "
f"(Linux; U; Android {self.os_version}; zh_CN; {self.device_type}; "
f"Build/QP1A.190711.020; Cronet/TTNetVersion:6c7b9b1a 2022-11-29)")
def to_dict(self) -> Dict[str, Any]:
return {
"device_id": self.device_id, "install_id": self.install_id,
"openudid": self.openudid, "cdid": self.cdid,
"clientudid": self.clientudid, "profile": list(self.profile),
}
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "Device":
# profile 是后加的,旧缓存没有这个字段,退回池子第一项
return cls(d["device_id"], d["install_id"], d["openudid"],
d["cdid"], d["clientudid"], d.get("profile"))
# ------------------------------------------------------------------ #
@classmethod
def register(cls, session: Optional[requests.Session] = None,
timeout: float = 20.0, retries: int = 5,
backoff: float = 30.0) -> "Device":
"""向 ByteDance 注册一台新设备。这一步不需要签名。
短时间内注册太多次,所有 host 都会返回 device_id='0'。实测:
指纹(openudid/cdid)每次都是新的照样被拒,4 个 host 同时被拒,
约 10 分钟后自行恢复 —— 所以是临时限流,且不是按设备指纹或按
单个 host 分桶的。
至于分桶的键到底是 IP 还是 UA/机型这类特征,我们没验证过:本机
没有第二个出口 IP,做不了对照实验。DEVICE_POOL 的随机化对两种
情况都无害,权当两用。
这里做指数退避重试而不是直接抛错,否则调用方会把后续所有条目
瞬间判为失败。
"""
last: Optional[Exception] = None
for attempt in range(retries):
try:
return cls._register_once(session, timeout)
except DeviceRegisterError as exc:
last = exc
if attempt < retries - 1:
wait = backoff * (2 ** attempt)
print(f" [注册受限] 等待 {wait:.0f}s 后重试 "
f"({attempt + 1}/{retries})", flush=True)
time.sleep(wait)
raise last # type: ignore[misc]
@classmethod
def _register_once(cls, session: Optional[requests.Session] = None,
timeout: float = 20.0) -> "Device":
session = session or requests.Session()
openudid = _hex(16)
cdid = str(uuid.UUID(int=random.getrandbits(128)))
clientudid = str(uuid.UUID(int=random.getrandbits(128)))
profile = random.choice(DEVICE_POOL)
dev_type, dev_brand, os_ver, os_api, resolution, dpi = profile
ua = (f"com.ss.android.ugc.aweme/{UPDATE_VERSION_CODE} "
f"(Linux; U; Android {os_ver}; zh_CN; {dev_type}; "
f"Build/QP1A.190711.020; Cronet/TTNetVersion:6c7b9b1a 2022-11-29)")
query = {
"ac": "wifi", "channel": CHANNEL, "aid": APP_ID, "app_name": "aweme",
"version_code": VERSION_CODE, "version_name": APP_VERSION,
"device_platform": "android", "os": "android", "ssmix": "a",
"device_type": dev_type, "device_brand": dev_brand,
"language": "zh", "os_api": os_api, "os_version": os_ver,
"manifest_version_code": VERSION_CODE, "resolution": resolution,
"dpi": dpi, "update_version_code": UPDATE_VERSION_CODE,
"_rticket": str(int(time.time() * 1000)),
"cdid": cdid, "openudid": openudid,
}
payload = {
"magic_tag": "ss_app_log",
"header": {
"display_name": "抖音",
"update_version_code": int(UPDATE_VERSION_CODE),
"manifest_version_code": int(VERSION_CODE), "aid": int(APP_ID),
"channel": CHANNEL, "package": "com.ss.android.ugc.aweme",
"app_version": APP_VERSION, "version_code": int(VERSION_CODE),
"sdk_version": "2.13.5", "os": "Android",
"os_version": os_ver, "os_api": int(os_api),
"device_model": dev_type, "device_brand": dev_brand,
"device_manufacturer": dev_brand, "cpu_abi": "arm64-v8a",
"density_dpi": int(dpi), "display_density": "mdpi",
"resolution": resolution.replace("*", "x"),
"language": "zh", "timezone": 8,
"access": "wifi", "not_request_sender": 0, "region": "CN",
"tz_name": "Asia/Shanghai", "tz_offset": 28800,
"sim_region": "cn", "openudid": openudid,
"clientudid": clientudid, "cdid": cdid,
"rom": "QP1A.190711.020", "rom_version": f"{dev_brand}/{os_ver}",
"device_id": 0, "install_id": 0,
},
"_gen_time": int(time.time() * 1000),
}
headers = {
"User-Agent": ua,
"Content-Type": "application/json; charset=utf-8",
"Accept-Encoding": "gzip",
"X-SS-REQ-TICKET": str(int(time.time() * 1000)),
}
errors = []
for host in REGISTER_HOSTS:
try:
r = session.post(host + "?" + urlencode(query),
data=json.dumps(payload), headers=headers,
timeout=timeout)
data = r.json()
except (requests.RequestException, ValueError) as exc:
errors.append(f"{host}: {type(exc).__name__}")
continue
did = str(data.get("device_id_str") or data.get("device_id") or "")
iid = str(data.get("install_id_str") or data.get("install_id") or "")
if did and did != "0":
return cls(did, iid, openudid, cdid, clientudid, profile)
errors.append(f"{host}: device_id={did!r}")
raise DeviceRegisterError("设备注册全部失败: " + "; ".join(errors))
@classmethod
def load_or_register(cls, cache: str | Path,
session: Optional[requests.Session] = None) -> "Device":
cache = Path(cache)
if cache.exists():
try:
return cls.from_dict(json.loads(cache.read_text(encoding="utf-8")))
except (ValueError, KeyError, OSError):
pass
dev = cls.register(session)
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(dev.to_dict(), indent=2), encoding="utf-8")
return dev
class AppClient:
"""按 aweme_id 取详情。不需要签名、Cookie 或登录态。"""
def __init__(self, device: Optional[Device] = None,
cache: str | Path = ".device.json",
timeout: float = 25.0) -> None:
self.session = requests.Session()
self.device = device or Device.load_or_register(cache, self.session)
self.timeout = timeout
self.session.headers.update({
"User-Agent": self.device.user_agent,
"Accept-Encoding": "gzip",
"Connection": "keep-alive",
"sdk-version": "2",
})
def _params(self) -> Dict[str, str]:
return {
"device_platform": "android", "os": "android", "ssmix": "a",
"_rticket": str(int(time.time() * 1000)),
"cdid": self.device.cdid, "channel": CHANNEL, "aid": APP_ID,
"app_name": "aweme", "version_code": VERSION_CODE,
"version_name": APP_VERSION, "manifest_version_code": VERSION_CODE,
"update_version_code": UPDATE_VERSION_CODE,
"resolution": self.device.resolution, "dpi": self.device.dpi,
"device_type": self.device.device_type,
"device_brand": self.device.device_brand,
"language": "zh", "os_api": self.device.os_api,
"os_version": self.device.os_version,
"openudid": self.device.openudid,
"device_id": self.device.device_id, "iid": self.device.install_id,
"ac": "wifi", "ts": str(int(time.time())),
}
def fetch_detail(self, aweme_id: str, retries: int = 3) -> Aweme:
last = ""
for attempt in range(retries):
host = API_HOSTS[attempt % len(API_HOSTS)]
params = self._params()
params["aweme_id"] = aweme_id
url = f"{host}/aweme/v1/aweme/detail/?" + urlencode(params)
try:
r = self.session.get(url, timeout=self.timeout)
except requests.RequestException as exc:
last = f"{type(exc).__name__}: {exc}"
continue
if not r.content:
last = f"HTTP {r.status_code} 空 body"
time.sleep(1 + attempt)
continue
try:
data: Dict[str, Any] = r.json()
except ValueError:
last = f"非 JSON: {r.text[:120]!r}"
continue
detail = data.get("aweme_detail")
if not detail:
# 服务端会在 filter_detail 里说明为什么不给正片,
# 例如图文作品 filter_reason=images_base
f = data.get("filter_detail") or {}
reason = f.get("filter_reason") or ""
if reason == "images_base":
raise NotAVideo(f"{aweme_id} 是图文作品(图集),不是视频")
if reason:
raise DouyinError(
f"{aweme_id} 被服务端过滤:{reason}"
+ (f"({f.get('notice')})" if f.get("notice") else "")
)
raise DouyinError(
f"无 aweme_detail,status_code={data.get('status_code')},"
"作品可能已删除或为私密"
)
return DouyinClient.parse(detail)
raise DouyinError(f"App detail 连续 {retries} 次失败({last})")

71
douyin/audio.py Normal file
View File

@@ -0,0 +1,71 @@
"""ffmpeg / ffprobe 的薄封装:找可执行文件、拼抽音命令、探时长。
批量抽取的调度不在这里 —— `pipeline.py` 自己按条编排(下载→抽音→上传→删),
所以这个模块只提供单次调用要用的零件。
从 mp4 里提取音轨。
源音频是 AAC 44.1kHz 立体声 128kbps,据此有两种模式:
mp3 转码成 MP3。有损转有损,必然掉质量。码率给太高只是浪费空间
(源就 128k),默认 192k 是留余量避免二次编码劣化,不是"更清晰"。
copy 直接把 AAC 流拷进 .m4a 容器。无损、秒完、体积更小。
只有在必须是 .mp3 后缀时才用 mp3 模式。
依赖 ffmpeg,需在 PATH 里。
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from typing import List, Optional
class FFmpegMissing(RuntimeError):
pass
def ensure_ffmpeg(exe: str = "ffmpeg") -> str:
path = shutil.which(exe)
if not path:
raise FFmpegMissing(
f"找不到 {exe}。装一个并加进 PATH,或用 --ffmpeg 指定完整路径。")
return path
def build_cmd(ffmpeg: str, src: Path, dst: Path, mode: str, bitrate: str) -> List[str]:
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y", "-i", str(src), "-vn"]
if mode == "copy":
# 不重新编码,直接搬运 AAC 流
cmd += ["-acodec", "copy"]
else:
cmd += ["-acodec", "libmp3lame", "-b:a", bitrate]
cmd.append(str(dst))
return cmd
def probe(path, ffprobe: str = "ffprobe", timeout: float = 60.0):
"""探一个媒体文件的容器时长,探不出来返回 None。
时长取自 mp4(源),不取 mp3 —— mp3 是转码产物,虽然实测两者一致,
但源文件才是权威。
注意这里读的是容器声明的 format=duration,对 mp4 没问题,
但**不能拿去判断 mp3 有没有被截断**:LAME 写的 Xing 头声明的是原始
总帧数,ffprobe 直接信它而不解码,实测把 mp3 截到 20% 它报的还是原时长。
那种场景要用 pipeline.decoded_seconds()(-count_frames 真解码)。
"""
cmd = [ffprobe, "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(path)]
try:
p_ = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return None
try:
d = float((p_.stdout or "").strip())
except ValueError:
return None
return d if d > 0 else None

197
douyin/batch.py Normal file
View File

@@ -0,0 +1,197 @@
"""批量下载:并发、可断点续跑、单条失败不影响整批。
设计要点:
- 设备身份只注册一次,各 worker 共用;但每个 worker 有独立 Session
(requests.Session 并发下不保证安全)
- 落盘文件名就是 aweme_id,所以「是否已下载」直接看文件在不在,
不依赖去重记录 —— 记录丢了也能无损续跑
- 失败条目写 failed.txt,跑完可直接拿它当输入重试
"""
from __future__ import annotations
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional, Sequence
import requests
from .app import AppClient, Device, NotAVideo
from .client import DouyinError
from .download import Registry, download
class DevicePool:
"""设备身份池。
实测单个 device_id 在约 575 次 detail 调用后被限流,表现是接口一直
回 HTTP 200 空 body;同一 IP 下换个新注册的设备立刻恢复 —— 说明配额
是设备级而非 IP 级。注册本身免签名、成本极低,所以按量轮换即可。
用 generation 标记当前设备:worker 拿到的 gen 和池里的对得上才允许
触发轮换,避免多个 worker 同时发现失败、把设备连换好几次。
"""
def __init__(self, max_calls: int = 400, initial: Optional[Device] = None) -> None:
self._lock = threading.Lock()
self._device = initial
self._gen = 0
self._calls = 0
self.max_calls = max_calls
self.rotations = 0
def current(self) -> tuple[Device, int]:
with self._lock:
if self._device is None or self._calls >= self.max_calls:
self._rotate_locked("配额用尽")
self._calls += 1
return self._device, self._gen
def report_dead(self, gen: int) -> None:
"""worker 发现当前设备被限流时调用。"""
with self._lock:
if gen == self._gen:
self._rotate_locked("被限流")
def _rotate_locked(self, why: str) -> None:
self._device = Device.register()
self._gen += 1
self._calls = 0
self.rotations += 1
print(f" [设备轮换#{self.rotations}] {why} -> "
f"device_id={self._device.device_id}", flush=True)
@dataclass
class BatchStats:
total: int = 0
done: int = 0
skipped: int = 0
not_video: int = 0
failed: int = 0
bytes_: int = 0
lock: threading.Lock = field(default_factory=threading.Lock)
@property
def finished(self) -> int:
return self.done + self.skipped + self.not_video + self.failed
def run_batch(aweme_ids: Sequence[str], out_dir: Path, device: Optional[Device] = None,
workers: int = 5, delay: float = 0.3,
registry: Optional[Registry] = None,
log_every: int = 25, max_calls_per_device: int = 400) -> BatchStats:
out_dir.mkdir(parents=True, exist_ok=True)
registry = registry or Registry(out_dir / ".downloaded.json")
pool = DevicePool(max_calls=max_calls_per_device, initial=device)
stats = BatchStats(total=len(aweme_ids))
failed_path = out_dir / "failed.txt"
# 每轮重来,否则上一轮已修复的失败会一直留在文件里造成误导
failed_path.write_text("", encoding="utf-8")
failed_lock = threading.Lock()
local = threading.local()
t0 = time.time()
def client_for(device_: Device, gen: int) -> AppClient:
"""每线程每代设备一个 Session(Session 并发不安全)。"""
if getattr(local, "gen", None) != gen:
local.client = AppClient(device=device_)
local.gen = gen
return local.client
def dl_session() -> requests.Session:
"""下载用的 Session:CDN 免鉴权,不该占设备配额,也不随设备轮换重建。"""
if not hasattr(local, "dl"):
local.dl = requests.Session()
local.dl.headers["User-Agent"] = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
)
return local.dl
def record_failure(aweme_id: str, why: str) -> None:
with failed_lock:
with open(failed_path, "a", encoding="utf-8") as f:
f.write(f"{aweme_id}\t{why}\n")
def fetch_with_rotation(aweme_id: str):
"""限流表现为空 body。碰到就换设备再试一次,而不是直接判失败。"""
last: Optional[Exception] = None
for _ in range(2):
dev, gen = pool.current()
client = client_for(dev, gen)
if delay:
time.sleep(delay)
try:
return client.fetch_detail(aweme_id, retries=2)
except NotAVideo:
raise
except DouyinError as exc:
last = exc
if "空 body" in str(exc):
pool.report_dead(gen)
continue
raise
raise last # type: ignore[misc]
def handle(aweme_id: str) -> None:
dest = out_dir / f"{aweme_id}.mp4"
# 文件在就算下过,不必碰网络也不依赖记录
if dest.exists() and dest.stat().st_size > 0:
with stats.lock:
stats.skipped += 1
return
try:
aweme = fetch_with_rotation(aweme_id)
except NotAVideo as exc:
with stats.lock:
stats.not_video += 1
record_failure(aweme_id, f"not_video: {exc}")
return
except Exception as exc:
with stats.lock:
stats.failed += 1
record_failure(aweme_id, f"detail: {type(exc).__name__}: {exc}")
return
if not aweme.video_urls:
with stats.lock:
stats.not_video += 1
record_failure(aweme_id, "no_video_url")
return
try:
result = download(dl_session(), aweme.video_urls, dest, progress=False)
except Exception as exc:
with stats.lock:
stats.failed += 1
record_failure(aweme_id, f"download: {type(exc).__name__}: {exc}")
return
registry.add(aweme_id, result.path, result.size,
desc=aweme.desc, author=aweme.author, uri=aweme.uri)
with stats.lock:
stats.done += 1
stats.bytes_ += result.size
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = [executor.submit(handle, a) for a in aweme_ids]
for n, _ in enumerate(as_completed(futures), 1):
if n % log_every == 0 or n == len(futures):
el = time.time() - t0
rate = n / el if el else 0
eta = (len(futures) - n) / rate if rate else 0
print(f"[{n}/{len(futures)}] 成功{stats.done} 跳过{stats.skipped} "
f"图集{stats.not_video} 失败{stats.failed} "
f"| {stats.bytes_ / 1024 ** 3:.2f}GB "
f"| {rate * 60:.0f}条/分 | 换设备{pool.rotations}次 "
f"| 剩约 {eta / 60:.0f}分",
flush=True)
registry.flush()
return stats

289
douyin/client.py Normal file
View File

@@ -0,0 +1,289 @@
"""抖音 Web 接口客户端。
实测要点(都是打真实接口验出来的):
1. 空 body 有两种成因,必须分开看:
- 新 session 的第一个请求必然空,第二个才通 —— cookie 预热,见 _warm_up()
- 服务端主动拦截 —— 响应头 X-Whale-Throughput-Abort-Data 里有 base64 原因
2. /aweme/detail/ 对匿名用户返回 abort={"name":"强制登录"},必须带登录 Cookie。
3. /aweme/related/ 匿名可用,返回完整 play_addr,但不含目标视频自身。
4. 请求头 UA 必须与签名时的 UA 完全一致,params 里的 browser_version 也要跟着对齐。
"""
from __future__ import annotations
import base64
import json
import random
import string
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from urllib.parse import urlencode
import requests
from .signer import Signer
DETAIL_URL = "https://www.douyin.com/aweme/v1/web/aweme/detail/"
RELATED_URL = "https://www.douyin.com/aweme/v1/web/aweme/related/"
TTWID_URL = "https://ttwid.bytedance.com/ttwid/union/register/"
BASE_PARAMS: Dict[str, str] = {
"device_platform": "webapp",
"aid": "6383",
"channel": "channel_pc_web",
"pc_client_type": "1",
"version_code": "190500",
"version_name": "19.5.0",
"cookie_enabled": "true",
"screen_width": "1920",
"screen_height": "1080",
"browser_language": "zh-CN",
"browser_platform": "Win32",
"browser_name": "Chrome",
"browser_version": "126.0.0.0",
"browser_online": "true",
"engine_name": "Blink",
"engine_version": "126.0.0.0",
"os_name": "Windows",
"os_version": "10",
"cpu_core_num": "8",
"device_memory": "8",
"platform": "PC",
"downlink": "10",
"effective_type": "4g",
"round_trip_time": "50",
}
PLAY_URL = "https://www.douyin.com/aweme/v1/play/"
def play_url_from_uri(uri: str, ratio: str = "1080p", watermark: bool = False) -> str:
"""由 video URI 拼出播放直链。
实测这个接口完全免鉴权:无 a_bogus、无 Cookie、无登录,裸 requests 即可,
302 跳到 douyinvod.com 的 CDN 地址。所以只要拿到了 URI,后续下载不再需要
任何身份——URI 值得缓存复用。
"""
path = "playwm" if watermark else "play"
return f"https://www.douyin.com/aweme/v1/{path}/?video_id={uri}&ratio={ratio}&line=0"
class DouyinError(RuntimeError):
pass
class LoginRequired(DouyinError):
"""服务端明确要求登录。"""
@dataclass
class Aweme:
aweme_id: str
desc: str
author: str
create_time: int
video_urls: List[str]
cover_urls: List[str]
music_url: Optional[str]
uri: str
raw: Dict[str, Any]
@property
def is_video(self) -> bool:
return bool(self.video_urls or self.uri)
def play_url(self, ratio: str = "1080p") -> str:
"""免鉴权直链。play_addr 里的地址带时效,URI 换出来的是现算的。"""
if not self.uri:
raise DouyinError("该作品没有 video URI")
return play_url_from_uri(self.uri, ratio)
def _rand(n: int, pool: str) -> str:
return "".join(random.choice(pool) for _ in range(n))
def gen_ms_token(length: int = 107) -> str:
return _rand(length, string.ascii_letters + string.digits + "=-_")
def gen_s_v_web_id() -> str:
body = "".join(
"_" if i in (8, 13, 18, 23) else random.choice("0123456789abcdef")
for i in range(32)
)
return f"verify_{format(int(time.time()), 'x')}_{body}"
def decode_abort(resp: requests.Response) -> str:
"""解出服务端拦截原因;没有则返回空串。"""
raw = resp.headers.get("X-Whale-Throughput-Abort-Data")
if not raw:
return ""
try:
info = json.loads(base64.b64decode(raw))
return info.get("name") or info.get("content") or ""
except (ValueError, TypeError):
return "unknown"
class DouyinClient:
def __init__(self, signer: Signer, cookie: str = "", timeout: float = 25.0,
warm_up: bool = True) -> None:
self.signer = signer
self.timeout = timeout
self.user_agent = signer.user_agent
self.fp = gen_s_v_web_id()
# params 里的浏览器版本必须跟签名用的 UA 对上,否则服务端会判不一致
self.params = dict(BASE_PARAMS)
if "Chrome/" in self.user_agent:
ver = self.user_agent.split("Chrome/")[1].split(" ")[0]
self.params["browser_version"] = ver
self.params["engine_version"] = ver
self.session = requests.Session()
self.session.headers.update({
"User-Agent": self.user_agent,
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "https://www.douyin.com/",
})
self._logged_in = bool(cookie)
if cookie:
self.session.headers["Cookie"] = cookie
else:
self._bootstrap_cookies()
self._warmed = False
if warm_up:
self._warm_up()
# ------------------------------------------------------------------ #
def _bootstrap_cookies(self) -> None:
try:
self.session.post(TTWID_URL, timeout=self.timeout, json={
"region": "cn", "aid": 1768, "needFid": False,
"service": "www.ixigua.com",
"migrate_info": {"ticket": "", "source": "node"},
"cbUrlProtocol": "https", "union": True,
})
except requests.RequestException:
pass
self.session.cookies.set("s_v_web_id", self.fp, domain=".douyin.com")
self.session.cookies.set("odin_tt", _rand(160, "0123456789abcdef"),
domain=".douyin.com")
def _warm_up(self) -> None:
"""新 session 的第一个接口请求必然返回空,先打一发把它消耗掉。"""
if self._warmed:
return
try:
self._get(RELATED_URL, {"aweme_id": "1", "count": "1", "filterGids": "1"})
except (DouyinError, requests.RequestException):
pass
self._warmed = True
def _build_url(self, base: str, extra: Dict[str, str]) -> str:
p = dict(self.params)
p.update(extra)
p["msToken"] = gen_ms_token()
return f"{base}?{urlencode(p)}"
def _get(self, base: str, extra: Dict[str, str],
retries: int = 3) -> Dict[str, Any]:
"""签名 + 请求 + 解析。
「空 body 且无拦截原因」是可重试的瞬时态(session 还没热起来),
「空 body 且原因含登录」是硬门槛,立即抛错不重试。
"""
last = ""
for attempt in range(1, retries + 1):
url = self.signer.sign_url(self._build_url(base, extra), self.fp)
resp = self.session.get(url, timeout=self.timeout)
if resp.content:
try:
return resp.json()
except ValueError as exc:
raise DouyinError(f"响应不是 JSON: {resp.text[:200]!r}") from exc
reason = decode_abort(resp)
if "登录" in reason:
raise LoginRequired(
f"接口要求登录(服务端返回 {reason!r})。"
"请用 --cookie 传入已登录的抖音 Cookie。"
)
last = (f"HTTP {resp.status_code}"
+ (f",拦截原因 {reason!r}" if reason else ",无拦截原因"))
if attempt < retries:
time.sleep(2 * attempt)
raise DouyinError(f"接口连续 {retries} 次返回空 body({last})")
# ------------------------------------------------------------------ #
def fetch_detail(self, aweme_id: str) -> Aweme:
"""按 aweme_id 取作品详情。匿名调用会抛 LoginRequired。"""
self.session.headers["Referer"] = f"https://www.douyin.com/video/{aweme_id}"
data = self._get(DETAIL_URL, {"aweme_id": aweme_id})
detail = data.get("aweme_detail")
if not detail:
raise DouyinError(
f"响应无 aweme_detail,status_code={data.get('status_code')},"
"作品可能已删除或为私密"
)
return self.parse(detail)
def fetch_related(self, aweme_id: str, count: int = 10) -> List[Aweme]:
"""相关推荐。匿名可用,但不含 aweme_id 自身。"""
data = self._get(RELATED_URL, {
"aweme_id": aweme_id, "count": str(count), "filterGids": aweme_id,
})
return [self.parse(a) for a in (data.get("aweme_list") or [])]
# ------------------------------------------------------------------ #
@staticmethod
def parse(detail: Dict[str, Any]) -> Aweme:
video = detail.get("video") or {}
# play_addr 带水印,playwm -> play 即无水印源。
# bit_rate 里有多档清晰度,优先挑码率最高的。
urls: List[str] = []
gears = sorted(
(b for b in (video.get("bit_rate") or []) if b.get("play_addr")),
key=lambda b: b.get("bit_rate") or 0, reverse=True,
)
for gear in gears:
urls.extend((gear["play_addr"].get("url_list") or []))
urls.extend((video.get("play_addr") or {}).get("url_list") or [])
seen: set[str] = set()
video_urls = []
for u in urls:
u = u.replace("playwm", "play")
if u not in seen:
seen.add(u)
video_urls.append(u)
cover = video.get("origin_cover") or video.get("cover") or {}
music_list = ((detail.get("music") or {}).get("play_url") or {}).get("url_list") or []
return Aweme(
aweme_id=str(detail.get("aweme_id", "")),
desc=(detail.get("desc") or "").strip(),
author=((detail.get("author") or {}).get("nickname") or "").strip(),
create_time=int(detail.get("create_time") or 0),
video_urls=video_urls,
cover_urls=list(cover.get("url_list") or []),
music_url=music_list[0] if music_list else None,
uri=(video.get("play_addr") or {}).get("uri") or "",
raw=detail,
)

94
douyin/cos.py Normal file
View File

@@ -0,0 +1,94 @@
"""腾讯云 COS 上传,带上传后校验。
流水线传完就删本地文件,所以「传成功了吗」这个判断必须硬。
`upload_file` 不抛异常只说明请求没报错,不说明对象真的落到桶里、
大小也对。所以 put() 一律在上传后 head_object 取回 Content-Length
跟本地字节数比对,对不上就当失败——删除闸门卡在这个返回值上。
"""
from __future__ import annotations
import os
import threading
from pathlib import Path
from typing import Optional
DEFAULT_BUCKET = "asr-dig-data-1253181912"
DEFAULT_PREFIX = "0005/zh/"
class CosError(RuntimeError):
pass
class SizeMismatch(CosError):
"""对象传上去了,但远端大小和本地对不上。"""
def object_url(key: str, bucket: str, region: str, style: str = "cos") -> str:
if style == "cos":
return f"cos://{bucket}/{key}"
if style == "https":
return f"https://{bucket}.cos.{region}.myqcloud.com/{key}"
return f"/{bucket}/{key}"
class CosUploader:
"""线程安全的上传器。同名对象一律覆盖,不做存在性检查。
重复劳动由上层的 records.jsonl 断点续跑拦掉(已完成的 id 整条跳过,
连下载都不会发生),所以这里不需要再靠 object_exists 去省事——
少一个分支,行为也更好预测。
"""
def __init__(self, region: str, bucket: str = DEFAULT_BUCKET,
prefix: str = DEFAULT_PREFIX,
secret_id: str = "", secret_key: str = "",
url_style: str = "cos", scheme: str = "https") -> None:
secret_id = secret_id or os.environ.get("COS_SECRET_ID", "")
secret_key = secret_key or os.environ.get("COS_SECRET_KEY", "")
if not secret_id or not secret_key:
raise CosError(
"缺 COS 凭证。设环境变量 COS_SECRET_ID / COS_SECRET_KEY,"
"别写在命令行里(会留在 shell history)。")
from qcloud_cos import CosConfig, CosS3Client
self.bucket = bucket
self.region = region
self.prefix = prefix.strip("/") + "/" if prefix.strip("/") else ""
self.url_style = url_style
self._client = CosS3Client(CosConfig(
Region=region, SecretId=secret_id, SecretKey=secret_key, Scheme=scheme))
self._lock = threading.Lock()
def key_for(self, name: str) -> str:
return f"{self.prefix}{name}"
def url_for(self, key: str) -> str:
return object_url(key, self.bucket, self.region, self.url_style)
def remote_size(self, key: str) -> Optional[int]:
try:
return int(self._client.head_object(Bucket=self.bucket, Key=key)["Content-Length"])
except Exception:
return None
def put(self, local: Path, key: str) -> tuple[str, int]:
"""上传并校验。返回 (url, 字节数)。校验不过抛 SizeMismatch。"""
local = Path(local)
size = local.stat().st_size
if size == 0:
raise CosError(f"{local.name} 是空文件,拒绝上传")
# EnableMD5 关掉是为了速度;正确性靠上传后的 head_object 校验兜底
self._client.upload_file(
Bucket=self.bucket, Key=key, LocalFilePath=str(local),
EnableMD5=False, PartSize=10, MAXThread=2,
)
remote = self.remote_size(key)
if remote is None:
raise SizeMismatch(f"{key} 上传后 head_object 拿不到,判定失败")
if remote != size:
raise SizeMismatch(f"{key} 大小不符:COS={remote} 本地={size}")
return self.url_for(key), size

179
douyin/crypto.py Normal file
View File

@@ -0,0 +1,179 @@
"""a_bogus 依赖的底层密码学原语:SM3、RC4、自定义 Base64。
这三样都是公开算法,与抖音无关,可以独立验证正确性:
- SM3 见 GB/T 32905-2016,本文件底部带官方测试向量自检
- RC4 标准流密码
- 自定义 Base64 只是把标准码表换成抖音自己那套字符顺序
"""
from __future__ import annotations
from typing import Iterable, List, Sequence
# --------------------------------------------------------------------------- #
# SM3
# --------------------------------------------------------------------------- #
_IV = [
0x7380166F, 0x4914B2B9, 0x172442D7, 0xDA8A0600,
0xA96F30BC, 0x163138AA, 0xE38DEE4D, 0xB0FB0E4E,
]
_MASK = 0xFFFFFFFF
def _rotl(x: int, n: int) -> int:
n &= 31
return ((x << n) | (x >> (32 - n))) & _MASK
def _ff(j: int, x: int, y: int, z: int) -> int:
return (x ^ y ^ z) if j < 16 else ((x & y) | (x & z) | (y & z))
def _gg(j: int, x: int, y: int, z: int) -> int:
return (x ^ y ^ z) if j < 16 else ((x & y) | ((~x & _MASK) & z))
def _p0(x: int) -> int:
return x ^ _rotl(x, 9) ^ _rotl(x, 17)
def _p1(x: int) -> int:
return x ^ _rotl(x, 15) ^ _rotl(x, 23)
def _cf(v: Sequence[int], block: bytes) -> List[int]:
"""压缩函数 CF(V, B)。"""
w = [int.from_bytes(block[i * 4:i * 4 + 4], "big") for i in range(16)]
for j in range(16, 68):
w.append(
_p1(w[j - 16] ^ w[j - 9] ^ _rotl(w[j - 3], 15))
^ _rotl(w[j - 13], 7)
^ w[j - 6]
)
w1 = [w[j] ^ w[j + 4] for j in range(64)]
a, b, c, d, e, f, g, h = v
for j in range(64):
t = 0x79CC4519 if j < 16 else 0x7A879D8A
ss1 = _rotl((_rotl(a, 12) + e + _rotl(t, j)) & _MASK, 7)
ss2 = ss1 ^ _rotl(a, 12)
tt1 = (_ff(j, a, b, c) + d + ss2 + w1[j]) & _MASK
tt2 = (_gg(j, e, f, g) + h + ss1 + w[j]) & _MASK
d = c
c = _rotl(b, 9)
b = a
a = tt1
h = g
g = _rotl(f, 19)
f = e
e = _p0(tt2)
return [x ^ y for x, y in zip(v, (a, b, c, d, e, f, g, h))]
def sm3(data: bytes) -> bytes:
"""SM3 摘要,返回 32 字节。"""
bit_len = len(data) * 8
padded = data + b"\x80"
padded += b"\x00" * ((56 - len(padded) % 64) % 64)
padded += bit_len.to_bytes(8, "big")
v = list(_IV)
for i in range(0, len(padded), 64):
v = _cf(v, padded[i:i + 64])
return b"".join(x.to_bytes(4, "big") for x in v)
def sm3_to_list(data: bytes | Iterable[int]) -> List[int]:
"""SM3 摘要,返回 32 个 int(a_bogus 内部按字节数组传递)。"""
if not isinstance(data, (bytes, bytearray)):
data = bytes(bytearray(data))
return list(sm3(bytes(data)))
# --------------------------------------------------------------------------- #
# RC4
# --------------------------------------------------------------------------- #
def rc4(key: bytes, data: bytes) -> bytes:
"""标准 RC4。加解密同一函数。"""
s = list(range(256))
j = 0
klen = len(key)
for i in range(256):
j = (j + s[i] + key[i % klen]) & 0xFF
s[i], s[j] = s[j], s[i]
out = bytearray(len(data))
i = j = 0
for n, ch in enumerate(data):
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
out[n] = ch ^ s[(s[i] + s[j]) & 0xFF]
return bytes(out)
# --------------------------------------------------------------------------- #
# 自定义码表 Base64
# --------------------------------------------------------------------------- #
STD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
def b64_encode(data: bytes, alphabet: str, pad: bool = True) -> str:
"""按给定 64(+1) 字符码表做 Base64 编码。
alphabet 长度为 64 时不补位;为 65 时最后一位作为填充字符。
"""
if len(alphabet) not in (64, 65):
raise ValueError(f"码表长度必须是 64 或 65,收到 {len(alphabet)}")
fill = alphabet[64] if len(alphabet) == 65 else ""
out: List[str] = []
for i in range(0, len(data), 3):
chunk = data[i:i + 3]
n = int.from_bytes(chunk + b"\x00" * (3 - len(chunk)), "big")
quad = [alphabet[(n >> shift) & 0x3F] for shift in (18, 12, 6, 0)]
keep = len(chunk) + 1 # 1字节->2字符, 2字节->3字符, 3字节->4字符
out.extend(quad[:keep])
if pad and fill:
out.extend(fill * (4 - keep))
return "".join(out)
# --------------------------------------------------------------------------- #
# 自检
# --------------------------------------------------------------------------- #
# GB/T 32905-2016 附录 A 给出的两组标准测试向量
_SM3_VECTORS = [
(b"abc",
"66c7f0f462eeedd9d1f2d46bdc10e4e24167c4875cf2f7a2297da02b8f4ba8e0"),
(b"abcd" * 16,
"debe9ff92275b8a138604889c18e5a4d6fdb70e5387e5765293dcba39c0c5732"),
]
def self_test() -> None:
"""跑通即说明 SM3/RC4/Base64 实现无误。"""
for msg, want in _SM3_VECTORS:
got = sm3(msg).hex()
assert got == want, f"SM3({msg[:8]!r}...) = {got}, 期望 {want}"
# RC4 官方测试向量:key="Key", plaintext="Plaintext" -> BBF316E8D940AF0AD3
assert rc4(b"Key", b"Plaintext").hex().upper() == "BBF316E8D940AF0AD3"
# 用标准码表时,自定义 Base64 应与 base64 模块完全一致
import base64
import os
for n in range(1, 40):
raw = os.urandom(n)
assert b64_encode(raw, STD_ALPHABET) == base64.b64encode(raw).decode()
print("crypto self-test OK: SM3 / RC4 / custom-base64 均通过")
if __name__ == "__main__":
self_test()

174
douyin/download.py Normal file
View File

@@ -0,0 +1,174 @@
"""断点续传下载 + 已下载去重。"""
from __future__ import annotations
import json
import os
import re
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, Optional
import requests
CHUNK = 256 * 1024
# Windows 文件名非法字符,外加控制字符
_ILLEGAL = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
def safe_filename(name: str, max_len: int = 80) -> str:
name = _ILLEGAL.sub("_", name).strip(" .")
name = re.sub(r"\s+", " ", name)
if len(name) > max_len:
name = name[:max_len].rstrip()
return name or "untitled"
class Registry:
"""记录已下载的 aweme_id,避免重复下载。
批量场景下会被多线程写,所以加锁;并且不再每条都落盘(5000 条时
每次全量 dump 是 O(n^2) 的 IO),改成攒够 save_every 条再写,
结束时用 flush() 收尾。文件名本身就是 aweme_id,记录丢了也能靠
目录重建,所以这个折中是安全的。
"""
def __init__(self, path: Path, save_every: int = 1) -> None:
self.path = path
self.save_every = save_every
self._lock = threading.Lock()
self._pending = 0
self._data: Dict[str, dict] = {}
if path.exists():
try:
self._data = json.loads(path.read_text(encoding="utf-8"))
except (ValueError, OSError):
self._data = {}
def __contains__(self, aweme_id: str) -> bool:
entry = self._data.get(aweme_id)
if not entry:
return False
# 记录在但文件被手动删了,就当没下过
return Path(entry.get("file", "")).exists()
def get(self, aweme_id: str) -> Optional[dict]:
return self._data.get(aweme_id)
def add(self, aweme_id: str, file: Path, size: int, desc: str = "",
author: str = "", uri: str = "") -> None:
"""记一条。文件名只是 aweme_id,作者/标题这些就全靠这里留存。"""
with self._lock:
self._data[aweme_id] = {
"file": str(file),
"size": size,
"author": author,
"desc": desc,
"uri": uri,
"time": int(time.time()),
}
self._pending += 1
if self._pending >= self.save_every:
self._save_locked()
def flush(self) -> None:
"""强制落盘。批量结束时调用。"""
with self._lock:
if self._pending:
self._save_locked()
def save(self) -> None:
with self._lock:
self._save_locked()
def _save_locked(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
tmp.write_text(json.dumps(self._data, ensure_ascii=False, indent=2),
encoding="utf-8")
tmp.replace(self.path)
self._pending = 0
@dataclass
class DownloadResult:
path: Path
size: int
resumed: bool
skipped: bool = False
def _human(n: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n:.1f}{unit}"
n /= 1024
return f"{n:.1f}TB"
def download(session: requests.Session, urls: Iterable[str], dest: Path,
timeout: float = 30.0, retries: int = 4,
progress: bool = True) -> DownloadResult:
"""下载到 dest,支持 Range 续传;urls 为多个备用直链,逐个尝试。
抖音的视频直链会 302 到 CDN,且校验 Referer/UA,所以必须带上原 session 的头。
"""
dest.parent.mkdir(parents=True, exist_ok=True)
part = dest.with_suffix(dest.suffix + ".part")
if dest.exists() and dest.stat().st_size > 0:
return DownloadResult(dest, dest.stat().st_size, resumed=False, skipped=True)
url_list = [u for u in urls if u]
if not url_list:
raise ValueError("没有可用的下载直链")
last_err: Optional[Exception] = None
for url in url_list:
for attempt in range(1, retries + 1):
done = part.stat().st_size if part.exists() else 0
headers = {"Range": f"bytes={done}-"} if done else {}
try:
with session.get(url, headers=headers, stream=True,
timeout=timeout, allow_redirects=True) as r:
if done and r.status_code == 200:
# 服务端不认 Range,只能从头来
done = 0
part.unlink(missing_ok=True)
elif done and r.status_code != 206:
r.raise_for_status()
else:
r.raise_for_status()
total = int(r.headers.get("Content-Length") or 0) + done
mode = "ab" if done else "wb"
with open(part, mode) as f:
for chunk in r.iter_content(CHUNK):
if not chunk:
continue
f.write(chunk)
done += len(chunk)
if progress:
pct = f"{done / total * 100:5.1f}%" if total else " ? "
print(f"\r {pct} {_human(done)}"
+ (f" / {_human(total)}" if total else ""),
end="", flush=True)
if progress:
print()
if total and done < total:
raise IOError(f"下载不完整: {done}/{total}")
part.replace(dest)
return DownloadResult(dest, done, resumed=bool(headers))
except (requests.RequestException, IOError) as exc:
last_err = exc
if progress:
print(f"\r 第 {attempt}/{retries} 次失败: {exc}")
time.sleep(min(2 ** attempt, 8))
raise RuntimeError(f"所有直链均下载失败,最后一个错误: {last_err}")

252
douyin/meta.py Normal file
View File

@@ -0,0 +1,252 @@
"""元信息:标题/标签拆分,以及 records.jsonl 的追加写。
records.jsonl 是流水线唯一的落点,一条视频一行,raw 也内嵌在里面,
所以单行可能几十 KB、整个文件几百 MB。由此有两条硬性约定:
1. 读取一律逐行流式,任何地方都不许把整个文件读进内存。
2. `aweme_id` 必须是每行的第一个字段。续跑扫描靠 _ID_RE 从行首直接
抠 id,不解析整行 JSON —— 400 MB 的差别是 ~1 秒 对 十几秒。
重排字段不会报错,只会让启动悄悄变慢,所以这是契约不是排版。
"""
from __future__ import annotations
import json
import re
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
# 行首抠 id,不解析整行
_ID_RE = re.compile(r'^\{"aweme_id":\s*"(\d+)"')
# 一个标签:# 后面跟到下一个空白或下一个 # 为止。
# 抖音有 "#四川话#方言" 这种连写,所以 # 也要作为终止符。
_TAG_RE = re.compile(r"#([^\s#]+)")
# 标签两端常粘着括号、引号、逗号之类的标点,剥掉
_EDGE_PUNCT = "()()[]【】{}「」<>《》\"'“”‘’,,.。!!??::;;、~~-—_/\\|"
def _clean_tag(name: str) -> str:
"""归一化一个标签候选,判定不是标签就返回空串。"""
name = (name or "").strip().strip(_EDGE_PUNCT).strip()
# 带 @ 的是提及,不是标签。实测 desc 里有作者手打的
# "#(提示词出自@青木",抖音自己也没把它收进 text_extra。
if not name or "@" in name:
return ""
return name
def _dedupe(items) -> List[str]:
"""按大小写不敏感去重,保留首次出现的写法。
抖音把 text_extra 里的标签统一转成了小写,desc 里却是作者的原始大小写。
实测同一条视频拿到 ['一站式AI创作工作台', ..., '一站式ai创作工作台', ...],
同一个标签存了两份。desc 排在前面,所以保留的是原始大小写。
"""
seen, out = set(), []
for x in items:
x = _clean_tag(x)
if not x:
continue
k = x.casefold()
if k not in seen:
seen.add(k)
out.append(x)
return out
def split_desc(desc: Optional[str],
text_extra: Optional[Sequence[Dict[str, Any]]] = None,
) -> Tuple[str, List[str]]:
"""把 desc 拆成 (纯标题, 标签列表)。
标签以 desc 为准(正则取全集),text_extra 只用来补充。实测 12 条样本
里有 2 条的标签在 desc 里有、text_extra 里没有,以 text_extra 为准会
静默丢标签。
剥标题时优先用 text_extra 给的 start/end 偏移——它能认出 "C# 教程"
里的 # 不是标签。但偏移不一定可信(越界、错位都见过),所以每个都先
验证 desc[start:end] 确实是 "#" + hashtag_name,验不过就退回正则。
兜底删的是 "#" + 已知标签名 这个精确串,不是任意 #,所以 "C# 教程"
是安全的。唯一的有损场景:标签名以 "#名字" 的形式也出现在正文里,
那两处会一起被删掉(见 test_split_repeated_tagname_in_body_is_lossy)。
"""
desc = (desc or "").strip()
entries = list(text_extra or [])
# 两件事要分开:
# raw_hits —— desc 里 # 后面的原文,用来剥标题(必须能字面匹配上)
# tags —— 清洗+去重后的结果,是对外的字段
# 混用会导致 "#(括号标签)" 剥不掉:清洗后的名字没有那对括号,
# replace 匹配不上,标签就留在标题里了。
raw_hits = _TAG_RE.findall(desc)
from_te = [e.get("hashtag_name") for e in entries if e.get("hashtag_name")]
tags = _dedupe(raw_hits + from_te)
if not desc:
return "", tags
# 能用偏移剥掉的标签,逐个记下要挖掉的区间
spans: List[Tuple[int, int]] = []
covered = set()
for e in entries:
name = e.get("hashtag_name")
start, end = e.get("start"), e.get("end")
if not name or not isinstance(start, int) or not isinstance(end, int):
continue
if not (0 <= start < end <= len(desc)):
continue
if desc[start:end] != "#" + name:
continue
spans.append((start, end))
covered.add(name)
title = desc
for start, end in sorted(spans, reverse=True): # 从后往前挖,偏移不会串位
title = title[:start] + title[end:]
# 偏移没盖到的,用 desc 里的原文删(不是清洗过的 tags)
for hit in raw_hits:
if hit not in covered:
title = title.replace("#" + hit, "")
return re.sub(r"\s+", " ", title).strip(), tags
def build_record(aweme, *, duration_sec: float, cos_mp4: str, cos_mp3: str,
size_mp4: int, size_mp3: int,
source_url: str = "") -> Dict[str, Any]:
"""组装一条 record。字段顺序是有意的,aweme_id 必须在最前。"""
raw = aweme.raw or {}
title, tags = split_desc(aweme.desc, raw.get("text_extra"))
author = raw.get("author") or {}
stat = raw.get("statistics") or {}
music = raw.get("music") or {}
video = raw.get("video") or {}
ct = int(aweme.create_time or 0)
return {
"aweme_id": str(aweme.aweme_id),
"title": title,
"desc": aweme.desc,
"tags": tags,
"author": {
"nickname": aweme.author,
"uid": str(author.get("uid") or ""),
"sec_uid": str(author.get("sec_uid") or ""),
},
"create_time": ct,
"create_time_str": (time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ct))
if ct else ""),
"duration_sec": duration_sec,
# 服务端声明值,与 ffprobe 解出来的不等(实测 41750 vs 41703 ms)。
# 两个都留着,切分要用 duration_sec。
"duration_ms_api": video.get("duration") or raw.get("duration") or 0,
"stats": {
"digg": stat.get("digg_count", 0),
"comment": stat.get("comment_count", 0),
"collect": stat.get("collect_count", 0),
"share": stat.get("share_count", 0),
"play": stat.get("play_count", 0),
},
"music": {
"title": (music.get("title") or "").strip(),
"author": (music.get("author") or "").strip(),
"url": aweme.music_url or "",
},
"cover_url": aweme.cover_urls[0] if aweme.cover_urls else "",
"video": {
"uri": aweme.uri,
"width": video.get("width") or 0,
"height": video.get("height") or 0,
},
"cos_mp4": cos_mp4,
"cos_mp3": cos_mp3,
"size_mp4": size_mp4,
"size_mp3": size_mp3,
"source_url": source_url or f"https://www.douyin.com/video/{aweme.aweme_id}",
"fetched_at": int(time.time()),
"raw": raw,
}
class RecordLog:
"""records.jsonl 的追加写 + 已完成 id 集合。多线程安全。"""
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = threading.Lock()
self._ids = self._scan()
def _scan(self) -> set:
"""流式扫已有 id。只看行首,不解析整行。"""
ids = set()
if not self.path.exists():
return ids
with open(self.path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
m = _ID_RE.match(line)
if m:
ids.add(m.group(1))
return ids
def __contains__(self, aweme_id: str) -> bool:
return str(aweme_id) in self._ids
def __len__(self) -> int:
return len(self._ids)
@property
def ids(self) -> set:
return set(self._ids)
def _needs_newline(self) -> bool:
"""上一次可能被 Ctrl-C 打断,留下没换行的半行。"""
try:
if self.path.stat().st_size == 0:
return False
except OSError:
return False
with open(self.path, "rb") as f:
f.seek(-1, 2)
return f.read(1) != b"\n"
def write(self, record: Dict[str, Any]) -> bool:
"""追加一条。已存在同 id 则跳过,返回 False。"""
aweme_id = str(record.get("aweme_id") or "")
if not aweme_id:
raise ValueError("record 缺 aweme_id")
with self._lock:
if aweme_id in self._ids:
return False
self.path.parent.mkdir(parents=True, exist_ok=True)
prefix = "\n" if self._needs_newline() else ""
line = json.dumps(record, ensure_ascii=False, separators=(",", ":"))
if not line.startswith('{"aweme_id":'):
raise ValueError("aweme_id 必须是第一个字段,见本模块开头的约定")
with open(self.path, "a", encoding="utf-8") as f:
f.write(prefix + line + "\n")
self._ids.add(aweme_id)
return True
def iter_records(path: Path):
"""逐行读 records.jsonl,跳过坏行(可能有被打断留下的半行)。"""
path = Path(path)
if not path.exists():
return
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
yield json.loads(line)
except ValueError:
continue

156
douyin/signer.py Normal file
View File

@@ -0,0 +1,156 @@
"""a_bogus 签名器。
两种后端,接口相同、可互换:
NodeSigner 调本地 node_signer(真实 webmssdk 跑在伪造浏览器环境里)。
当前唯一可用的实现,已实测能被抖音接受。
ABogusSigner 纯 Python 实现。底层 SM3/RC4/自定义 Base64 已在 crypto.py
验证通过,剩字节拼装顺序待从 ab.js 逆出来。
签名器同时决定 User-Agent:a_bogus 把 UA 编进签名,服务端会交叉校验,
所以请求头的 UA 必须与签名时用的完全一致。
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Protocol, Sequence
class SignError(RuntimeError):
pass
class SignerNotConfigured(SignError):
pass
class Signer(Protocol):
"""client 只依赖这两个成员。"""
@property
def user_agent(self) -> str:
"""签名时使用的 UA,请求头必须用同一个。"""
...
def sign_url(self, url: str, fp: str = "") -> str:
"""给一条裸 URL,返回带 a_bogus 的完整 URL。"""
...
# --------------------------------------------------------------------------- #
# Node 后端
# --------------------------------------------------------------------------- #
class NodeSigner:
"""通过 node_signer/sign.js 签名。
node 启动 + 加载 347KB 的 SDK 大约 0.7s,所以支持 batch 一次签多条,
把这份固定开销摊掉。
"""
def __init__(self, signer_dir: str | Path, node: str = "node",
timeout: float = 120.0) -> None:
self.dir = Path(signer_dir)
self.node = node
self.timeout = timeout
self._ua: str | None = None
script = self.dir / "sign.js"
if not script.exists():
raise SignerNotConfigured(f"找不到签名脚本: {script}")
def _run(self, args: Sequence[str], stdin: str | None = None) -> dict:
try:
proc = subprocess.run(
[self.node, "sign.js", *args], cwd=self.dir, input=stdin,
capture_output=True, text=True, encoding="utf-8",
timeout=self.timeout,
)
except FileNotFoundError as exc:
raise SignerNotConfigured(f"找不到 node 可执行文件: {self.node}") from exc
except subprocess.TimeoutExpired as exc:
raise SignError(f"签名超时(>{self.timeout}s)") from exc
out = (proc.stdout or "").strip()
if not out:
raise SignError(f"签名器无输出,rc={proc.returncode},stderr={proc.stderr[:400]}")
try:
# sign.js 只在最后一行吐 JSON,前面可能有 SDK 自己的噪声
return json.loads(out.splitlines()[-1])
except ValueError as exc:
raise SignError(f"签名器输出不是 JSON: {out[:300]!r}") from exc
@property
def user_agent(self) -> str:
if self._ua is None:
probe = self._run(["https://www.douyin.com/aweme/v1/web/aweme/detail/?aweme_id=1", ""])
self._ua = probe.get("userAgent") or ""
if not self._ua:
raise SignError("签名器没有返回 userAgent")
return self._ua
def sign_url(self, url: str, fp: str = "") -> str:
res = self._run([url, fp])
if not res.get("ok"):
raise SignError(f"签名失败: {res.get('error')}")
self._ua = res.get("userAgent") or self._ua
return res["signedUrl"]
def sign_many(self, urls: List[str], fp: str = "") -> List[str]:
"""一次 node 启动签完多条。"""
if not urls:
return []
res = self._run(["--batch", fp], stdin=json.dumps(urls))
if not res.get("ok"):
raise SignError(f"批量签名失败: {res.get('error')}")
self._ua = res.get("userAgent") or self._ua
signed = []
for item in res["results"]:
if not item.get("ok"):
raise SignError(f"某条签名失败: {item.get('error')} url={item.get('rawUrl')}")
signed.append(item["signedUrl"])
return signed
# --------------------------------------------------------------------------- #
# 纯 Python 后端(待补)
# --------------------------------------------------------------------------- #
@dataclass
class ABogusConfig:
"""a_bogus 算法常量。必须与线上 webmssdk.js 当前版本一致。"""
alphabets: Dict[str, str] = field(default_factory=dict)
ua_key: bytes = b""
arguments: List[int] = field(default_factory=list)
browser_fp: str = ""
end_string: str = ""
user_agent: str = ""
def validate(self) -> None:
missing = [n for n in ("alphabets", "ua_key", "arguments",
"browser_fp", "end_string", "user_agent")
if not getattr(self, n)]
if missing:
raise SignerNotConfigured("a_bogus 常量未配置,缺少: " + ", ".join(missing))
class ABogusSigner:
"""纯 Python a_bogus。crypto.py 里的 SM3/RC4/Base64 已验证可用。"""
def __init__(self, config: ABogusConfig) -> None:
config.validate()
self.config = config
@property
def user_agent(self) -> str:
return self.config.user_agent
def sign_url(self, url: str, fp: str = "") -> str:
raise NotImplementedError(
"a_bogus 字节拼装顺序待从 ab.js 逆出。目前请用 NodeSigner。"
)

85
douyin/urls.py Normal file
View File

@@ -0,0 +1,85 @@
"""链接解析:把用户随手粘的东西还原成 aweme_id。
支持的输入形态:
7xxxxxxxxxxxxxxxxxx 裸 id
https://v.douyin.com/xxxxxxx/ 分享短链(需要一次网络跳转)
4.53 复制打开抖音... https://v.douyin.com/xxx/ ... 整段分享口令
https://www.douyin.com/video/7xxxxxx
https://www.douyin.com/note/7xxxxxx 图文
https://www.douyin.com/?modal_id=7xxxxxx
https://www.iesdouyin.com/share/video/7xxxxxx/
"""
from __future__ import annotations
import re
from typing import Optional
import requests
# 从一段中文分享口令里把 url 抠出来
URL_RE = re.compile(r"https?://[-\w./?=&%#:]+")
SHORT_HOSTS = ("v.douyin.com", "v.iesdouyin.com")
# 按优先级尝试的 id 提取规则
ID_PATTERNS = (
re.compile(r"/(?:video|note|slides|share/video|share/note)/(\d{15,25})"),
re.compile(r"[?&](?:modal_id|aweme_id|item_ids|vid)=(\d{15,25})"),
)
BARE_ID_RE = re.compile(r"^\d{15,25}$")
class ParseError(ValueError):
"""输入里找不到可用的 aweme_id。"""
def _first_url(text: str) -> Optional[str]:
m = URL_RE.search(text)
return m.group(0) if m else None
def _extract_id(url: str) -> Optional[str]:
for pat in ID_PATTERNS:
m = pat.search(url)
if m:
return m.group(1)
return None
def resolve_short_link(url: str, session: requests.Session, timeout: float = 15.0) -> str:
"""跟随 v.douyin.com 短链跳转,返回落地长链。"""
resp = session.get(url, timeout=timeout, allow_redirects=True)
return resp.url
def extract_aweme_id(raw: str, session: Optional[requests.Session] = None) -> str:
"""从任意输入中解析出 aweme_id,必要时联网还原短链。"""
raw = raw.strip()
if BARE_ID_RE.match(raw):
return raw
url = _first_url(raw)
if not url:
raise ParseError(f"输入里没找到链接也不是纯数字 id: {raw[:80]!r}")
aid = _extract_id(url)
if aid:
return aid
if any(h in url for h in SHORT_HOSTS):
if session is None:
session = requests.Session()
session.headers["User-Agent"] = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 "
"Mobile/15E148 Safari/604.1"
)
final = resolve_short_link(url, session)
aid = _extract_id(final)
if aid:
return aid
raise ParseError(f"短链 {url} 跳转到 {final},但里面没有 aweme_id")
raise ParseError(f"无法从 {url} 中解析出 aweme_id")

106447
lists/ai_118469.txt Normal file

File diff suppressed because it is too large Load Diff

37703
lists/ai_42391.txt Normal file

File diff suppressed because it is too large Load Diff

776
lists/ai_57261.txt Normal file
View File

@@ -0,0 +1,776 @@
https://www.douyin.com/video/6943122417938173219
https://www.douyin.com/video/7519393200386723091
https://www.douyin.com/video/7520105860204596492
https://www.douyin.com/video/7553798797877366062
https://www.douyin.com/video/7553888041010548014
https://www.douyin.com/video/7554172873657699610
https://www.douyin.com/video/7554258413786615049
https://www.douyin.com/video/7554547768417586478
https://www.douyin.com/video/7554715238831033609
https://www.douyin.com/video/7554912956857797939
https://www.douyin.com/video/7554985407327243546
https://www.douyin.com/video/7555476793818500378
https://www.douyin.com/video/7556159053320293658
https://www.douyin.com/video/7557202537238056218
https://www.douyin.com/video/7557575002803932426
https://www.douyin.com/video/7558027934854892809
https://www.douyin.com/video/7558340211575409966
https://www.douyin.com/video/7560697691928726803
https://www.douyin.com/video/7562089250788674870
https://www.douyin.com/video/7564399768768630067
https://www.douyin.com/video/7564639934120791334
https://www.douyin.com/video/7564751054412287258
https://www.douyin.com/video/7564963930150374682
https://www.douyin.com/video/7565127231735893298
https://www.douyin.com/video/7565377771719970094
https://www.douyin.com/video/7565702956490738970
https://www.douyin.com/video/7565853315041414410
https://www.douyin.com/video/7566154309616831782
https://www.douyin.com/video/7566232008305757486
https://www.douyin.com/video/7566587563641785641
https://www.douyin.com/video/7568905480349764902
https://www.douyin.com/video/7569632988347026738
https://www.douyin.com/video/7570184180492012827
https://www.douyin.com/video/7572001072161993427
https://www.douyin.com/video/7572375979444751642
https://www.douyin.com/video/7572546058958474546
https://www.douyin.com/video/7573139707496811822
https://www.douyin.com/video/7573235518420672511
https://www.douyin.com/video/7573494590506798345
https://www.douyin.com/video/7573934793147092233
https://www.douyin.com/video/7574237778314414335
https://www.douyin.com/video/7574249747658083611
https://www.douyin.com/video/7574604143612988723
https://www.douyin.com/video/7575569253295855862
https://www.douyin.com/video/7576562406902533426
https://www.douyin.com/video/7577274848418762030
https://www.douyin.com/video/7578033313944718602
https://www.douyin.com/video/7578684709299965210
https://www.douyin.com/video/7579632942910147894
https://www.douyin.com/video/7579885234519428371
https://www.douyin.com/video/7580211967593349254
https://www.douyin.com/video/7580230312265878799
https://www.douyin.com/video/7580552466681367842
https://www.douyin.com/video/7580630563740978467
https://www.douyin.com/video/7580638698766970218
https://www.douyin.com/video/7580998521541758854
https://www.douyin.com/video/7581546931097488683
https://www.douyin.com/video/7582021145629576494
https://www.douyin.com/video/7582089479017876772
https://www.douyin.com/video/7582410806042627328
https://www.douyin.com/video/7582619119854423339
https://www.douyin.com/video/7582831090910383398
https://www.douyin.com/video/7583258850694810943
https://www.douyin.com/video/7583747521584074020
https://www.douyin.com/video/7584028010996600106
https://www.douyin.com/video/7585002696411778313
https://www.douyin.com/video/7585245325321850147
https://www.douyin.com/video/7585250568013171987
https://www.douyin.com/video/7585376145323986186
https://www.douyin.com/video/7585867986922360070
https://www.douyin.com/video/7586983813100703019
https://www.douyin.com/video/7587226343230786843
https://www.douyin.com/video/7587605794049314099
https://www.douyin.com/video/7587646301697871158
https://www.douyin.com/video/7587704115791252776
https://www.douyin.com/video/7587764268461886756
https://www.douyin.com/video/7588206930322311635
https://www.douyin.com/video/7588395674413075758
https://www.douyin.com/video/7588741008712666419
https://www.douyin.com/video/7589089208669605171
https://www.douyin.com/video/7589501379968185252
https://www.douyin.com/video/7589641625794530602
https://www.douyin.com/video/7590229657761402138
https://www.douyin.com/video/7590966760849804570
https://www.douyin.com/video/7591301498874155236
https://www.douyin.com/video/7591398292845238633
https://www.douyin.com/video/7592202513383270649
https://www.douyin.com/video/7593191283851579497
https://www.douyin.com/video/7593229928586366259
https://www.douyin.com/video/7594116662737052671
https://www.douyin.com/video/7594440381572435209
https://www.douyin.com/video/7594775095534162422
https://www.douyin.com/video/7594816345863114010
https://www.douyin.com/video/7594819503284817215
https://www.douyin.com/video/7595153279307807601
https://www.douyin.com/video/7595473187765552454
https://www.douyin.com/video/7595854224455231849
https://www.douyin.com/video/7595893672957300022
https://www.douyin.com/video/7596256747297705094
https://www.douyin.com/video/7596635881218983914
https://www.douyin.com/video/7596998473229052081
https://www.douyin.com/video/7597056616344659347
https://www.douyin.com/video/7597334429228453147
https://www.douyin.com/video/7597361008847818161
https://www.douyin.com/video/7597406545911008538
https://www.douyin.com/video/7597422956876269482
https://www.douyin.com/video/7597427547168656694
https://www.douyin.com/video/7597621989137829147
https://www.douyin.com/video/7597691941626875323
https://www.douyin.com/video/7597817277886596964
https://www.douyin.com/video/7597989218547289394
https://www.douyin.com/video/7598122640762621219
https://www.douyin.com/video/7598134393957299633
https://www.douyin.com/video/7598399984580578603
https://www.douyin.com/video/7598402730087959834
https://www.douyin.com/video/7598445953053461787
https://www.douyin.com/video/7598771708765506862
https://www.douyin.com/video/7599474626430766382
https://www.douyin.com/video/7599911866143395118
https://www.douyin.com/video/7600690419866090235
https://www.douyin.com/video/7601020122957877733
https://www.douyin.com/video/7602102264887523045
https://www.douyin.com/video/7602499280254946569
https://www.douyin.com/video/7602522606725500202
https://www.douyin.com/video/7602836692906560778
https://www.douyin.com/video/7603211416799120650
https://www.douyin.com/video/7603323120782445862
https://www.douyin.com/video/7603360772999122214
https://www.douyin.com/video/7603630329710038318
https://www.douyin.com/video/7603932736688065802
https://www.douyin.com/video/7604054141173828905
https://www.douyin.com/video/7604108812122918163
https://www.douyin.com/video/7604380466937875738
https://www.douyin.com/video/7604727626364339441
https://www.douyin.com/video/7604904484086549796
https://www.douyin.com/video/7605118039188704522
https://www.douyin.com/video/7606182228908920090
https://www.douyin.com/video/7606552755691408650
https://www.douyin.com/video/7606605160692993314
https://www.douyin.com/video/7606625462822931727
https://www.douyin.com/video/7606942633830649134
https://www.douyin.com/video/7606980034263321892
https://www.douyin.com/video/7606995703236444239
https://www.douyin.com/video/7607320526113049906
https://www.douyin.com/video/7607400979381767443
https://www.douyin.com/video/7608043185789741947
https://www.douyin.com/video/7608413074671848730
https://www.douyin.com/video/7608542392756572358
https://www.douyin.com/video/7609180667515063592
https://www.douyin.com/video/7609867642543948282
https://www.douyin.com/video/7609984557378538790
https://www.douyin.com/video/7610146429398502690
https://www.douyin.com/video/7611830655957451177
https://www.douyin.com/video/7612620405915967999
https://www.douyin.com/video/7612626386531978547
https://www.douyin.com/video/7612851474276222066
https://www.douyin.com/video/7612879197770000370
https://www.douyin.com/video/7612917384076067199
https://www.douyin.com/video/7613574269888632433
https://www.douyin.com/video/7613668005389569306
https://www.douyin.com/video/7613778328596839926
https://www.douyin.com/video/7614082236790423466
https://www.douyin.com/video/7614439185005879659
https://www.douyin.com/video/7614482669908380937
https://www.douyin.com/video/7614742399537949540
https://www.douyin.com/video/7615276283405424298
https://www.douyin.com/video/7615549848285079921
https://www.douyin.com/video/7615638874379732964
https://www.douyin.com/video/7615807141757114873
https://www.douyin.com/video/7615816260149053289
https://www.douyin.com/video/7615845470321052974
https://www.douyin.com/video/7615930582690984617
https://www.douyin.com/video/7616185378906643301
https://www.douyin.com/video/7616312371875733958
https://www.douyin.com/video/7616395708984272767
https://www.douyin.com/video/7616413276741586203
https://www.douyin.com/video/7616713139165482278
https://www.douyin.com/video/7616766668118914303
https://www.douyin.com/video/7617325809296002559
https://www.douyin.com/video/7617460880041635001
https://www.douyin.com/video/7617514945508298155
https://www.douyin.com/video/7617709513994933881
https://www.douyin.com/video/7617842610997153893
https://www.douyin.com/video/7617844922620023286
https://www.douyin.com/video/7617905644313595174
https://www.douyin.com/video/7617906023080152366
https://www.douyin.com/video/7617906321320430874
https://www.douyin.com/video/7618055262938794986
https://www.douyin.com/video/7618239783752856191
https://www.douyin.com/video/7618431510190082438
https://www.douyin.com/video/7618686925839520083
https://www.douyin.com/video/7618781384173244581
https://www.douyin.com/video/7618917413900036934
https://www.douyin.com/video/7619123455581080883
https://www.douyin.com/video/7619142462464491195
https://www.douyin.com/video/7619175919709727665
https://www.douyin.com/video/7619313117805251903
https://www.douyin.com/video/7619320302955442003
https://www.douyin.com/video/7619327283395956009
https://www.douyin.com/video/7619366626797833482
https://www.douyin.com/video/7619642803363009478
https://www.douyin.com/video/7619655047777019633
https://www.douyin.com/video/7619739298111261958
https://www.douyin.com/video/7619855484144684294
https://www.douyin.com/video/7620573913735132914
https://www.douyin.com/video/7620760577976533945
https://www.douyin.com/video/7621408706206469426
https://www.douyin.com/video/7621463684434414883
https://www.douyin.com/video/7621824306896440576
https://www.douyin.com/video/7621870175855463723
https://www.douyin.com/video/7622348326750440710
https://www.douyin.com/video/7622551801807654180
https://www.douyin.com/video/7622551849857551652
https://www.douyin.com/video/7622551867939228970
https://www.douyin.com/video/7622916648307019043
https://www.douyin.com/video/7622926694719128037
https://www.douyin.com/video/7623037925130833186
https://www.douyin.com/video/7623393918260836709
https://www.douyin.com/video/7623445134398524714
https://www.douyin.com/video/7623634756546071846
https://www.douyin.com/video/7623830379681647878
https://www.douyin.com/video/7624392177145105679
https://www.douyin.com/video/7624529056972803338
https://www.douyin.com/video/7624826104108338432
https://www.douyin.com/video/7624830305488080168
https://www.douyin.com/video/7625810880374082826
https://www.douyin.com/video/7625878643599756595
https://www.douyin.com/video/7625996760858594603
https://www.douyin.com/video/7626338305965182258
https://www.douyin.com/video/7626697056581192179
https://www.douyin.com/video/7626715245750406435
https://www.douyin.com/video/7626725992530332978
https://www.douyin.com/video/7627113956481584435
https://www.douyin.com/video/7627116296735444260
https://www.douyin.com/video/7627404581693181238
https://www.douyin.com/video/7627424782224559398
https://www.douyin.com/video/7627433230710492470
https://www.douyin.com/video/7627827715109965083
https://www.douyin.com/video/7627839718314495274
https://www.douyin.com/video/7628147653880089896
https://www.douyin.com/video/7628486944548244763
https://www.douyin.com/video/7628550586404703514
https://www.douyin.com/video/7628907808217451827
https://www.douyin.com/video/7629016832065768754
https://www.douyin.com/video/7629023262042787107
https://www.douyin.com/video/7629364084495944987
https://www.douyin.com/video/7629674904291495206
https://www.douyin.com/video/7629715248747957547
https://www.douyin.com/video/7630002143101046026
https://www.douyin.com/video/7630035011151990035
https://www.douyin.com/video/7630435755621453097
https://www.douyin.com/video/7630800798733192490
https://www.douyin.com/video/7631132552295746835
https://www.douyin.com/video/7631152747122855174
https://www.douyin.com/video/7631513007029325075
https://www.douyin.com/video/7631888456557350170
https://www.douyin.com/video/7632002655170153754
https://www.douyin.com/video/7632128546911112499
https://www.douyin.com/video/7632250717218131234
https://www.douyin.com/video/7632317146051169582
https://www.douyin.com/video/7632677096627735842
https://www.douyin.com/video/7632891709134736649
https://www.douyin.com/video/7633051373524847899
https://www.douyin.com/video/7633337273710333230
https://www.douyin.com/video/7633346756327050522
https://www.douyin.com/video/7633682062968458534
https://www.douyin.com/video/7634022529052593443
https://www.douyin.com/video/7634050693557294586
https://www.douyin.com/video/7634125301194313011
https://www.douyin.com/video/7634451794004888842
https://www.douyin.com/video/7634496875764272390
https://www.douyin.com/video/7634522194848124214
https://www.douyin.com/video/7634600021383499059
https://www.douyin.com/video/7634640655255489844
https://www.douyin.com/video/7634857312013077605
https://www.douyin.com/video/7634895725691817258
https://www.douyin.com/video/7635223823972830500
https://www.douyin.com/video/7635236908355865898
https://www.douyin.com/video/7635260096217468187
https://www.douyin.com/video/7635263813381672202
https://www.douyin.com/video/7635558808021929257
https://www.douyin.com/video/7635754732098768174
https://www.douyin.com/video/7635919581701344553
https://www.douyin.com/video/7635928586301574426
https://www.douyin.com/video/7636050065580248347
https://www.douyin.com/video/7636280897676016058
https://www.douyin.com/video/7636327999012441371
https://www.douyin.com/video/7637323468807079781
https://www.douyin.com/video/7637389704832371987
https://www.douyin.com/video/7637516612030958883
https://www.douyin.com/video/7638114648118203877
https://www.douyin.com/video/7638159617234603270
https://www.douyin.com/video/7638174898539498788
https://www.douyin.com/video/7638600102835146020
https://www.douyin.com/video/7638933360575892778
https://www.douyin.com/video/7638968360796982579
https://www.douyin.com/video/7639319248816524571
https://www.douyin.com/video/7639367842818937265
https://www.douyin.com/video/7639391448319724835
https://www.douyin.com/video/7639723207913704710
https://www.douyin.com/video/7640189751533899048
https://www.douyin.com/video/7640756535873883401
https://www.douyin.com/video/7640869909517307171
https://www.douyin.com/video/7641120721636265270
https://www.douyin.com/video/7641589532265123122
https://www.douyin.com/video/7641968344052108594
https://www.douyin.com/video/7642269444743367936
https://www.douyin.com/video/7642325261748342042
https://www.douyin.com/video/7643079455417601306
https://www.douyin.com/video/7643099561996586280
https://www.douyin.com/video/7643330715563724083
https://www.douyin.com/video/7643747314741798898
https://www.douyin.com/video/7643820601257299251
https://www.douyin.com/video/7644129791554469745
https://www.douyin.com/video/7644189304972987702
https://www.douyin.com/video/7644398135640548859
https://www.douyin.com/video/7644454594528939314
https://www.douyin.com/video/7644485500253785407
https://www.douyin.com/video/7644565490399989055
https://www.douyin.com/video/7645037263830699283
https://www.douyin.com/video/7645153715855112633
https://www.douyin.com/video/7645237321650031878
https://www.douyin.com/video/7645319910847040787
https://www.douyin.com/video/7645601447903530240
https://www.douyin.com/video/7645967951392247091
https://www.douyin.com/video/7646052528894774591
https://www.douyin.com/video/7646307038016316133
https://www.douyin.com/video/7646388274046537002
https://www.douyin.com/video/7646649948490026290
https://www.douyin.com/video/7646776690709957951
https://www.douyin.com/video/7647147109493247238
https://www.douyin.com/video/7647535640694836543
https://www.douyin.com/video/7647723480591343601
https://www.douyin.com/video/7647871582534241551
https://www.douyin.com/video/7648180273783344422
https://www.douyin.com/video/7648237241789074751
https://www.douyin.com/video/7648532239407516974
https://www.douyin.com/video/7648555100415298835
https://www.douyin.com/video/7648581346801044774
https://www.douyin.com/video/7648604334774684947
https://www.douyin.com/video/7648892407303507263
https://www.douyin.com/video/7648981178782960942
https://www.douyin.com/video/7649290429682208052
https://www.douyin.com/video/7649322286621472441
https://www.douyin.com/video/7649382734304972075
https://www.douyin.com/video/7649472549520198962
https://www.douyin.com/video/7649679394864172298
https://www.douyin.com/video/7649733463527918889
https://www.douyin.com/video/7649943483725171987
https://www.douyin.com/video/7650038680744250675
https://www.douyin.com/video/7650072092766915880
https://www.douyin.com/video/7650110217032307045
https://www.douyin.com/video/7650313961908974523
https://www.douyin.com/video/7650477456188509486
https://www.douyin.com/video/7650501431241329935
https://www.douyin.com/video/7650769199626865947
https://www.douyin.com/video/7650836608844877065
https://www.douyin.com/video/7650837877999291702
https://www.douyin.com/video/7650997338525158708
https://www.douyin.com/video/7651270613679557894
https://www.douyin.com/video/7651469973809876276
https://www.douyin.com/video/7651511047378980130
https://www.douyin.com/video/7651620162990329140
https://www.douyin.com/video/7651783213036164025
https://www.douyin.com/video/7651983818441772351
https://www.douyin.com/video/7652159495296044324
https://www.douyin.com/video/7652332368618966326
https://www.douyin.com/video/7652337263422098707
https://www.douyin.com/video/7652690556325301531
https://www.douyin.com/video/7652760586555624755
https://www.douyin.com/video/7653037217077579058
https://www.douyin.com/video/7653044535480802041
https://www.douyin.com/video/7653272029650505006
https://www.douyin.com/video/7653319960801889542
https://www.douyin.com/video/7653342880093850920
https://www.douyin.com/video/7653384213898530048
https://www.douyin.com/video/7653395254703361330
https://www.douyin.com/video/7653462607520648490
https://www.douyin.com/video/7653742612871990538
https://www.douyin.com/video/7653780737778273587
https://www.douyin.com/video/7653785256289471786
https://www.douyin.com/video/7654081151534910746
https://www.douyin.com/video/7654509285417798963
https://www.douyin.com/video/7654582738226631141
https://www.douyin.com/video/7654695117399346451
https://www.douyin.com/video/7654841525762919726
https://www.douyin.com/video/7654877200253023857
https://www.douyin.com/video/7654938141274454641
https://www.douyin.com/video/7655274807872867635
https://www.douyin.com/video/7655294658511146250
https://www.douyin.com/video/7655578760405732644
https://www.douyin.com/video/7655634255539703040
https://www.douyin.com/video/7655641314603666730
https://www.douyin.com/video/7655649609087323446
https://www.douyin.com/video/7655966421515672689
https://www.douyin.com/video/7656044126965681418
https://www.douyin.com/video/7656047318969437503
https://www.douyin.com/video/7656049583902051620
https://www.douyin.com/video/7656071302272273715
https://www.douyin.com/video/7656203379277860106
https://www.douyin.com/video/7656282438393482548
https://www.douyin.com/video/7656344529238265138
https://www.douyin.com/video/7656459414970420521
https://www.douyin.com/video/7656713475099561250
https://www.douyin.com/video/7656749023751081254
https://www.douyin.com/video/7656753234333158699
https://www.douyin.com/video/7657145292726258986
https://www.douyin.com/video/7657175721030323497
https://www.douyin.com/video/7657180863557340454
https://www.douyin.com/video/7657193988009417081
https://www.douyin.com/video/7657219879371672841
https://www.douyin.com/video/7657484474833292585
https://www.douyin.com/video/7657555170498284826
https://www.douyin.com/video/7658148334569360650
https://www.douyin.com/video/7658231417406491919
https://www.douyin.com/video/7658241711407140131
https://www.douyin.com/video/7658256433359736070
https://www.douyin.com/video/7658513811904695592
https://www.douyin.com/video/7658558423587474731
https://www.douyin.com/video/7658627840988381435
https://www.douyin.com/video/7658660005062675763
https://www.douyin.com/video/7658936566362034682
https://www.douyin.com/video/7659004095709089078
https://www.douyin.com/video/7659009919047241012
https://www.douyin.com/video/7659265009436922298
https://www.douyin.com/video/7659329841984207717
https://www.douyin.com/video/7659365936067120403
https://www.douyin.com/video/7659387012948020514
https://www.douyin.com/video/7659677959305400305
https://www.douyin.com/video/7659771186154376483
https://www.douyin.com/video/7659812552586628390
https://www.douyin.com/video/7660043887762853158
https://www.douyin.com/video/7660080456532100325
https://www.douyin.com/video/7660378622334668051
https://www.douyin.com/video/7660452842536176911
https://www.douyin.com/video/7660461857463833445
https://www.douyin.com/video/7660746995748883766
https://www.douyin.com/video/7660829348592705957
https://www.douyin.com/video/7660882419880332559
https://www.douyin.com/video/7661194952365444403
https://www.douyin.com/video/7661198419789198769
https://www.douyin.com/video/7661240245349813554
https://www.douyin.com/video/7661262754998182373
https://www.douyin.com/video/7661325177510169891
https://www.douyin.com/video/7661447694502717925
https://www.douyin.com/video/7661626304455136997
https://www.douyin.com/video/7661917157235739950
https://www.douyin.com/video/7661934792333708584
https://www.douyin.com/video/7661950772778683700
https://www.douyin.com/video/7662005958176180197
https://www.douyin.com/video/7662207301134155953
https://www.douyin.com/video/7662216114093321125
https://www.douyin.com/video/7662342717439036707
https://www.douyin.com/video/7662409809232284928
https://www.douyin.com/video/7662436209800514850
https://www.douyin.com/video/7662655730943593791
https://www.douyin.com/video/7662675520517066027
https://www.douyin.com/video/7662726086103140261
https://www.douyin.com/video/7662726933427260714
https://www.douyin.com/video/7663349110917863781
https://www.douyin.com/video/7663437082131500339
https://www.douyin.com/video/7663528696544195441
https://www.douyin.com/video/7663623587605089572
https://www.douyin.com/video/7663761563040327665
https://www.douyin.com/video/7663848518829739302
https://www.douyin.com/video/7664041284713365242
https://www.douyin.com/video/7664109347026291977
https://www.douyin.com/video/7664208543336023674
https://www.douyin.com/video/7664227832112876819
https://www.douyin.com/video/7664440455664356623
https://www.douyin.com/video/7664504179649432886
https://www.douyin.com/video/7664867676778466579
https://www.douyin.com/video/7664876816712125738
https://www.douyin.com/video/7664896249895619894
https://www.douyin.com/video/7664930885019503918
https://www.douyin.com/video/7664961390760044537
https://www.douyin.com/video/7665042774567760357
https://www.douyin.com/video/7665081523623808271
https://www.douyin.com/video/7665269536095145891
https://www.douyin.com/video/7665333213509733695
https://www.douyin.com/video/7665558591248244288
https://www.douyin.com/video/7665691979073785130
https://www.douyin.com/video/7665695224682157157
https://www.douyin.com/video/7665728516148097896
https://www.douyin.com/video/7665883741978035560
https://www.douyin.com/video/7666016387768323368
https://www.douyin.com/video/7666083728954854694
https://www.douyin.com/video/7666252572591705384
https://www.douyin.com/video/7666283773364958196
https://www.douyin.com/video/7666338979893071154
https://www.douyin.com/video/7666357810216586523
https://www.douyin.com/video/7666359051386752291
https://www.douyin.com/video/7666359674333121844
https://www.douyin.com/video/7666435655073953253
https://www.douyin.com/video/7666470100010077503
https://www.douyin.com/video/7666631806632365967
https://www.douyin.com/video/7666723775321314036
https://www.douyin.com/video/7667014549647805731
https://www.douyin.com/video/7667128120517199267
https://www.douyin.com/video/7667138218667691273
https://www.douyin.com/video/7667163827305205034
https://www.douyin.com/video/7667177971279233189
https://www.douyin.com/video/7667208626179820810
https://www.douyin.com/video/7667416018432030641
https://www.douyin.com/video/7667471259399212303
https://www.douyin.com/video/7667510510514580687
https://www.douyin.com/video/7667584140971085071
https://www.douyin.com/video/7667837546495875810
https://www.douyin.com/video/7667867655306530094
https://www.douyin.com/video/7667880938403040527
https://www.douyin.com/video/7667883685324148008
https://www.douyin.com/video/7667921594988201637
https://www.douyin.com/video/7667954880291654962
https://www.douyin.com/video/7668142595268201734
https://www.douyin.com/video/7668185235808341282
https://www.douyin.com/video/7668232734145252671
https://www.douyin.com/video/7668249316428194728
https://www.douyin.com/video/7668430591771151666
https://www.douyin.com/video/7668693467802963227
https://www.douyin.com/video/7668701871880452264
https://www.douyin.com/video/7668847824344629114
https://www.douyin.com/video/7668983792408775942
https://www.douyin.com/video/7669031795585197233
https://www.douyin.com/video/7669335701900528942
https://www.douyin.com/video/7669369157502242057
https://www.douyin.com/video/7669642580174940325
https://www.douyin.com/video/7669727308365810953
https://www.douyin.com/video/7669775057494129509
https://www.douyin.com/video/7669820347528531243
https://www.douyin.com/video/7670106766973095209
https://www.douyin.com/video/7670113416547241256
https://www.douyin.com/video/7670139266109836594
https://www.douyin.com/video/7670163128612844431
https://www.douyin.com/video/7670461512384875810
https://www.douyin.com/video/7670521663624076389
https://www.douyin.com/video/7670528676970253618
https://www.douyin.com/video/7670756266968989435
https://www.douyin.com/video/7670813228960276047
https://www.douyin.com/video/7670917275737705743
https://www.douyin.com/video/7670936864647998897
https://www.douyin.com/video/7671118098316954914
https://www.douyin.com/video/7671168257755188521
https://www.douyin.com/video/7671261026582766309
https://www.douyin.com/video/7671558411390439586
https://www.douyin.com/video/7671587635063754010
https://www.douyin.com/video/7671624629550714131
https://www.douyin.com/video/7671625098054389034
https://www.douyin.com/video/7671640325530052346
https://www.douyin.com/video/7671960967781043507
https://www.douyin.com/video/7671992164376644915
https://www.douyin.com/video/7672000964644424049
https://www.douyin.com/video/7672249622015026341
https://www.douyin.com/video/7672256384322309439
https://www.douyin.com/video/7672308964353568616
https://www.douyin.com/video/7672576055014106447
https://www.douyin.com/video/7672668778479602987
https://www.douyin.com/video/7672715650552401178
https://www.douyin.com/video/7672720290434280569
https://www.douyin.com/video/7672742807430267505
https://www.douyin.com/video/7672755197218753835
https://www.douyin.com/video/7672841488367095080
https://www.douyin.com/video/7673038887052581711
https://www.douyin.com/video/7673041515844767030
https://www.douyin.com/video/7673054215530417448
https://www.douyin.com/video/7673076486882069814
https://www.douyin.com/video/7673228409090931429
https://www.douyin.com/video/7673261493835533606
https://www.douyin.com/video/7673321465336352038
https://www.douyin.com/video/7673321926504287488
https://www.douyin.com/video/7673464113044917546
https://www.douyin.com/video/7673484982408620645
https://www.douyin.com/video/7673516260797189414
https://www.douyin.com/video/7673694364136213823
https://www.douyin.com/video/7673694818773601577
https://www.douyin.com/video/7673729957286202979
https://www.douyin.com/video/7673730543112908785
https://www.douyin.com/video/7673797066992176447
https://www.douyin.com/video/7673807082516172074
https://www.douyin.com/video/7673905956783231156
https://www.douyin.com/video/7674082733324389668
https://www.douyin.com/video/7674083224645045544
https://www.douyin.com/video/7674161654128463104
https://www.douyin.com/video/7674170244713254190
https://www.douyin.com/video/7674177167744478095
https://www.douyin.com/video/7674211166566042923
https://www.douyin.com/video/7674231512276184805
https://www.douyin.com/video/7674253499941572776
https://www.douyin.com/video/7674569226421622458
https://www.douyin.com/video/7674579131442449716
https://www.douyin.com/video/7674702704652972923
https://www.douyin.com/video/7674914537828633908
https://www.douyin.com/video/7674937074646142259
https://www.douyin.com/video/7674937637546806569
https://www.douyin.com/video/7674972508814558394
https://www.douyin.com/video/7675124829770632458
https://www.douyin.com/video/7675365522011836904
https://www.douyin.com/video/7675483265004622266
https://www.douyin.com/video/7675539470892010778
https://www.douyin.com/video/7675609427092819234
https://www.douyin.com/video/7675627341812452660
https://www.douyin.com/video/7675999529963856994
https://www.douyin.com/video/7676028608536346810
https://www.douyin.com/video/7676045799751378545
https://www.douyin.com/video/7676068186245257723
https://www.douyin.com/video/7676071873519553828
https://www.douyin.com/video/7676206699581115698
https://www.douyin.com/video/7676252942920758642
https://www.douyin.com/video/7676324659302075721
https://www.douyin.com/video/7676402775265217838
https://www.douyin.com/video/7676443672221278555
https://www.douyin.com/video/7676507753142246842
https://www.douyin.com/video/7676651392870167434
https://www.douyin.com/video/7676706546445666150
https://www.douyin.com/video/7676726807206415679
https://www.douyin.com/video/7676777703432970478
https://www.douyin.com/video/7676806593186417963
https://www.douyin.com/video/7676810949554195762
https://www.douyin.com/video/7676816025307974927
https://www.douyin.com/video/7676973090311893618
https://www.douyin.com/video/7677139895508421926
https://www.douyin.com/video/7677168765959703860
https://www.douyin.com/video/7677225783300160486
https://www.douyin.com/video/7677382859988774026
https://www.douyin.com/video/7677436645130737529
https://www.douyin.com/video/7677529104766669294
https://www.douyin.com/video/7677544232332086528
https://www.douyin.com/video/7677544710864391460
https://www.douyin.com/video/7677726494199115891
https://www.douyin.com/video/7677741684755087013
https://www.douyin.com/video/7677830429231739546
https://www.douyin.com/video/7677908207888567606
https://www.douyin.com/video/7677919976723893510
https://www.douyin.com/video/7677935094850024393
https://www.douyin.com/video/7677957315656094446
https://www.douyin.com/video/7678120340850132827
https://www.douyin.com/video/7678171186031688378
https://www.douyin.com/video/7678188866529038003
https://www.douyin.com/video/7678243344095639205
https://www.douyin.com/video/7678292689787435610
https://www.douyin.com/video/7678486832644680649
https://www.douyin.com/video/7678544297163055270
https://www.douyin.com/video/7678546421313703208
https://www.douyin.com/video/7678575927890038068
https://www.douyin.com/video/7678585542967069998
https://www.douyin.com/video/7678590557425323314
https://www.douyin.com/video/7678613511093751055
https://www.douyin.com/video/7678622962815077682
https://www.douyin.com/video/7678625502013975899
https://www.douyin.com/video/7678633564577025330
https://www.douyin.com/video/7678636864873978106
https://www.douyin.com/video/7678652628621164261
https://www.douyin.com/video/7678654371135265467
https://www.douyin.com/video/7678658151821937972
https://www.douyin.com/video/7678661269704451323
https://www.douyin.com/video/7678665392645999908
https://www.douyin.com/video/7678670285122237797
https://www.douyin.com/video/7678675913445264667
https://www.douyin.com/video/7678682245799873826
https://www.douyin.com/video/7678685367255551266
https://www.douyin.com/video/7678687122127113507
https://www.douyin.com/video/7678687138652732379
https://www.douyin.com/video/7678749541306289446
https://www.douyin.com/video/7678755783550455090
https://www.douyin.com/video/7678763820864654619
https://www.douyin.com/video/7678825975556034698
https://www.douyin.com/video/7678886371372617014
https://www.douyin.com/video/7678898058890329408
https://www.douyin.com/video/7678915532866768138
https://www.douyin.com/video/7678929655399691098
https://www.douyin.com/video/7678937719686450474
https://www.douyin.com/video/7678947950425496768
https://www.douyin.com/video/7678968987773865231
https://www.douyin.com/video/7678985176809998409
https://www.douyin.com/video/7678986769739664640
https://www.douyin.com/video/7678993425631377999
https://www.douyin.com/video/7679008022307409193
https://www.douyin.com/video/7679022154000408762
https://www.douyin.com/video/7679027352965352730
https://www.douyin.com/video/7679029813202758514
https://www.douyin.com/video/7679034815433231631
https://www.douyin.com/video/7679041467328137875
https://www.douyin.com/video/7679046934312635667
https://www.douyin.com/video/7679051682629602614
https://www.douyin.com/video/7679068471501622579
https://www.douyin.com/video/7679082616453360569
https://www.douyin.com/video/7679085904597650740
https://www.douyin.com/video/7679088520916389170
https://www.douyin.com/video/7679101989992533284
https://www.douyin.com/video/7679120738544285541
https://www.douyin.com/video/7679221372983299379
https://www.douyin.com/video/7679262208672455077
https://www.douyin.com/video/7679264479682546984
https://www.douyin.com/video/7679297146138715337
https://www.douyin.com/video/7679299506656549627
https://www.douyin.com/video/7679308946070408511
https://www.douyin.com/video/7679325505833375936
https://www.douyin.com/video/7679334312591904034
https://www.douyin.com/video/7679355638165572890
https://www.douyin.com/video/7679363844532489332
https://www.douyin.com/video/7679381683296144686
https://www.douyin.com/video/7679386621280112250
https://www.douyin.com/video/7679389104014865242
https://www.douyin.com/video/7679408597402537225
https://www.douyin.com/video/7679414313097907494
https://www.douyin.com/video/7679414794009954478
https://www.douyin.com/video/7679418736025144603
https://www.douyin.com/video/7679419565223588465
https://www.douyin.com/video/7679424155271695525
https://www.douyin.com/video/7679426058361343278
https://www.douyin.com/video/7679427553739787554
https://www.douyin.com/video/7679427741199944987
https://www.douyin.com/video/7679440707580398858
https://www.douyin.com/video/7679442397798347771
https://www.douyin.com/video/7679445657974754598
https://www.douyin.com/video/7679451408902425865
https://www.douyin.com/video/7679553393639083299
https://www.douyin.com/video/7679586455651487011
https://www.douyin.com/video/7679594027264773606
https://www.douyin.com/video/7679619228953754981
https://www.douyin.com/video/7679669997992151278
https://www.douyin.com/video/7679685319389678899
https://www.douyin.com/video/7679719810942438675
https://www.douyin.com/video/7679723554786643234
https://www.douyin.com/video/7679736105419181331
https://www.douyin.com/video/7679742979069709603
https://www.douyin.com/video/7679744717130698018
https://www.douyin.com/video/7679749023187782388
https://www.douyin.com/video/7679762551609068841
https://www.douyin.com/video/7679773030955977990
https://www.douyin.com/video/7679777205719747955
https://www.douyin.com/video/7679786760994610043
https://www.douyin.com/video/7679788645968022838
https://www.douyin.com/video/7679796367509769518
https://www.douyin.com/video/7679797414697471238
https://www.douyin.com/video/7679797886426664201
https://www.douyin.com/video/7679800245206764843
https://www.douyin.com/video/7679806984497432998
https://www.douyin.com/video/7679812964446349203
https://www.douyin.com/video/7679814965406092595
https://www.douyin.com/video/7679816146242263680
https://www.douyin.com/video/7679847926545337636
https://www.douyin.com/video/7679859240772628901
https://www.douyin.com/video/7679960541577678115
https://www.douyin.com/video/7679973657594105103
https://www.douyin.com/video/7679978692710321134
https://www.douyin.com/video/7679984198715296098
https://www.douyin.com/video/7680009929671926757
https://www.douyin.com/video/7680032605705735460
https://www.douyin.com/video/7680052463843560750
https://www.douyin.com/video/7680056996313607781
https://www.douyin.com/video/7680059155767652538
https://www.douyin.com/video/7680081065259699490
https://www.douyin.com/video/7680081287918456110
https://www.douyin.com/video/7680095194250834926
https://www.douyin.com/video/7680105715771583611
https://www.douyin.com/video/7680106103991979315
https://www.douyin.com/video/7680117761921666340
https://www.douyin.com/video/7680117927642697000
https://www.douyin.com/video/7680119645780659491
https://www.douyin.com/video/7680133706740206875
https://www.douyin.com/video/7680133802567486761
https://www.douyin.com/video/7680140246826143026
https://www.douyin.com/video/7680142839187000639
https://www.douyin.com/video/7680144311337356590
https://www.douyin.com/video/7680152788999928035
https://www.douyin.com/video/7680171091331009838
https://www.douyin.com/video/7680174467008003034
https://www.douyin.com/video/7680181136361917748
https://www.douyin.com/video/7680188118762548985
https://www.douyin.com/video/7680189239446752552
https://www.douyin.com/video/7680208337530637568
https://www.douyin.com/video/7680263311061028115
https://www.douyin.com/video/7680311538980293934
https://www.douyin.com/video/7680340917585764570
https://www.douyin.com/video/7680363025051798794
https://www.douyin.com/video/7680422505299453539

52360
lists/ai_62171.txt Normal file

File diff suppressed because it is too large Load Diff

1366
lists/ai_87110_严格.txt Normal file

File diff suppressed because it is too large Load Diff

15218
lists/ai_videos.txt Normal file

File diff suppressed because it is too large Load Diff

66411
lists/ai_合并待下载.txt Normal file

File diff suppressed because it is too large Load Diff

2611
lists/ai_待下载.txt Normal file

File diff suppressed because it is too large Load Diff

3557
lists/all_video_links(3).txt Normal file

File diff suppressed because it is too large Load Diff

4839
lists/csv_links.txt Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,776 @@
https://www.douyin.com/video/7380366513469000987
https://www.douyin.com/video/7514599593988869403
https://www.douyin.com/video/7516418196564348211
https://www.douyin.com/video/7520500385008012580
https://www.douyin.com/video/7521986436939287862
https://www.douyin.com/video/7523221568294751529
https://www.douyin.com/video/7525070625204915519
https://www.douyin.com/video/7530830172222115135
https://www.douyin.com/video/7538042966386134291
https://www.douyin.com/video/7539753234932698411
https://www.douyin.com/video/7541340766095052068
https://www.douyin.com/video/7546108774513691943
https://www.douyin.com/video/7548819336112033063
https://www.douyin.com/video/7549102755731426623
https://www.douyin.com/video/7549370086735777087
https://www.douyin.com/video/7549883472979512618
https://www.douyin.com/video/7550105813105462571
https://www.douyin.com/video/7550227999062248745
https://www.douyin.com/video/7550516151760358675
https://www.douyin.com/video/7550516954933005609
https://www.douyin.com/video/7550524160104664363
https://www.douyin.com/video/7550636242367614247
https://www.douyin.com/video/7550869239898426660
https://www.douyin.com/video/7551270473684045092
https://www.douyin.com/video/7551772650604547364
https://www.douyin.com/video/7552182479986740521
https://www.douyin.com/video/7552736371346935103
https://www.douyin.com/video/7552864085667679542
https://www.douyin.com/video/7552872941857328427
https://www.douyin.com/video/7553294319786020137
https://www.douyin.com/video/7553529503444684068
https://www.douyin.com/video/7553636686220152103
https://www.douyin.com/video/7553927366579768619
https://www.douyin.com/video/7554317903798914367
https://www.douyin.com/video/7554655790427147539
https://www.douyin.com/video/7555092541298806070
https://www.douyin.com/video/7555735902288891172
https://www.douyin.com/video/7555814062748962102
https://www.douyin.com/video/7557200184175381799
https://www.douyin.com/video/7557989911165553962
https://www.douyin.com/video/7558025332268977471
https://www.douyin.com/video/7558071415342681386
https://www.douyin.com/video/7559126788111535403
https://www.douyin.com/video/7559504316051148073
https://www.douyin.com/video/7559803627863887146
https://www.douyin.com/video/7560226318119865619
https://www.douyin.com/video/7560611991967108415
https://www.douyin.com/video/7560973202706812199
https://www.douyin.com/video/7561330641275948324
https://www.douyin.com/video/7561614819138260266
https://www.douyin.com/video/7561666192981658934
https://www.douyin.com/video/7562419181599640873
https://www.douyin.com/video/7562420662062533907
https://www.douyin.com/video/7562551310820035883
https://www.douyin.com/video/7562720574498917674
https://www.douyin.com/video/7563608198364532004
https://www.douyin.com/video/7564277422092111158
https://www.douyin.com/video/7564637942020066623
https://www.douyin.com/video/7566118848739839295
https://www.douyin.com/video/7566520703677140243
https://www.douyin.com/video/7566875725262769462
https://www.douyin.com/video/7567717713071820075
https://www.douyin.com/video/7569439640647670762
https://www.douyin.com/video/7569441572598104484
https://www.douyin.com/video/7570276930193226118
https://www.douyin.com/video/7570732614638705363
https://www.douyin.com/video/7570919864518160326
https://www.douyin.com/video/7570984598831091071
https://www.douyin.com/video/7571031688211068772
https://www.douyin.com/video/7571437498276289252
https://www.douyin.com/video/7571674082590614774
https://www.douyin.com/video/7571764351976277739
https://www.douyin.com/video/7572418606560460115
https://www.douyin.com/video/7572789807940673188
https://www.douyin.com/video/7573311861044362730
https://www.douyin.com/video/7574679807277046646
https://www.douyin.com/video/7576146247150934633
https://www.douyin.com/video/7576523801786280006
https://www.douyin.com/video/7577010388609394793
https://www.douyin.com/video/7577299367322733481
https://www.douyin.com/video/7577765758047708516
https://www.douyin.com/video/7578121891861939177
https://www.douyin.com/video/7578861158439109814
https://www.douyin.com/video/7579573049822073811
https://www.douyin.com/video/7580308431392423906
https://www.douyin.com/video/7580575269242495851
https://www.douyin.com/video/7580761527533582251
https://www.douyin.com/video/7581025934838847668
https://www.douyin.com/video/7581302935750006250
https://www.douyin.com/video/7581479376954150185
https://www.douyin.com/video/7582524757486652713
https://www.douyin.com/video/7582852963585871145
https://www.douyin.com/video/7582952109878103359
https://www.douyin.com/video/7583603476988890409
https://www.douyin.com/video/7584267094024375593
https://www.douyin.com/video/7585008821253442822
https://www.douyin.com/video/7585796917876935990
https://www.douyin.com/video/7586551044105112851
https://www.douyin.com/video/7588368448867945670
https://www.douyin.com/video/7588434109912239999
https://www.douyin.com/video/7590233221331340479
https://www.douyin.com/video/7591674198630089542
https://www.douyin.com/video/7591718737636767977
https://www.douyin.com/video/7591854025508475322
https://www.douyin.com/video/7594102940194889188
https://www.douyin.com/video/7595456098003588843
https://www.douyin.com/video/7596185690266995539
https://www.douyin.com/video/7598492128692778230
https://www.douyin.com/video/7599260304396549203
https://www.douyin.com/video/7602909894493396422
https://www.douyin.com/video/7605070496028291027
https://www.douyin.com/video/7606565420401609451
https://www.douyin.com/video/7607318439020041654
https://www.douyin.com/video/7609197330093304164
https://www.douyin.com/video/7610048707459091839
https://www.douyin.com/video/7613344864323755243
https://www.douyin.com/video/7613638328865037673
https://www.douyin.com/video/7615253818271607878
https://www.douyin.com/video/7615255911167099301
https://www.douyin.com/video/7617140307985149430
https://www.douyin.com/video/7619955405104820139
https://www.douyin.com/video/7620301079121492934
https://www.douyin.com/video/7621162869261946138
https://www.douyin.com/video/7621867088906390819
https://www.douyin.com/video/7621899968571844742
https://www.douyin.com/video/7624397028261472959
https://www.douyin.com/video/7626252792714142570
https://www.douyin.com/video/7627694322393091362
https://www.douyin.com/video/7627695778076560676
https://www.douyin.com/video/7628580359672965546
https://www.douyin.com/video/7628985709738609960
https://www.douyin.com/video/7629313385972513279
https://www.douyin.com/video/7631055424447436651
https://www.douyin.com/video/7631593191338183982
https://www.douyin.com/video/7631593218768981298
https://www.douyin.com/video/7631593230487915827
https://www.douyin.com/video/7631593260376395017
https://www.douyin.com/video/7631593296074116379
https://www.douyin.com/video/7631593320753548570
https://www.douyin.com/video/7631593348217851163
https://www.douyin.com/video/7631593380090219803
https://www.douyin.com/video/7631593407110057267
https://www.douyin.com/video/7631593428953959690
https://www.douyin.com/video/7631593453855542578
https://www.douyin.com/video/7631593482141830438
https://www.douyin.com/video/7631593510113692974
https://www.douyin.com/video/7631593532419099950
https://www.douyin.com/video/7631593558671166746
https://www.douyin.com/video/7631593583572815131
https://www.douyin.com/video/7631593600350014758
https://www.douyin.com/video/7631593624249158950
https://www.douyin.com/video/7631593919100341556
https://www.douyin.com/video/7631593927895829760
https://www.douyin.com/video/7631593973429079330
https://www.douyin.com/video/7631593995675749666
https://www.douyin.com/video/7631594029892832512
https://www.douyin.com/video/7631939367108746089
https://www.douyin.com/video/7632679042445947007
https://www.douyin.com/video/7633392733889023275
https://www.douyin.com/video/7633407841701149823
https://www.douyin.com/video/7634910834091915947
https://www.douyin.com/video/7634917476601548032
https://www.douyin.com/video/7635137905534749998
https://www.douyin.com/video/7635141415127614419
https://www.douyin.com/video/7635316122467814708
https://www.douyin.com/video/7635316191770332450
https://www.douyin.com/video/7635316204126686479
https://www.douyin.com/video/7635316223533649204
https://www.douyin.com/video/7635316263794838799
https://www.douyin.com/video/7635316290885815604
https://www.douyin.com/video/7635316298918006016
https://www.douyin.com/video/7635316356996467983
https://www.douyin.com/video/7635316366446316800
https://www.douyin.com/video/7635316380048346420
https://www.douyin.com/video/7635316424906378548
https://www.douyin.com/video/7635316445332770100
https://www.douyin.com/video/7635316494372490548
https://www.douyin.com/video/7635316504510221603
https://www.douyin.com/video/7635316517235723555
https://www.douyin.com/video/7635316535434759439
https://www.douyin.com/video/7635316579839855912
https://www.douyin.com/video/7635316599574105379
https://www.douyin.com/video/7635316640426626339
https://www.douyin.com/video/7635316664560618786
https://www.douyin.com/video/7635316678255004943
https://www.douyin.com/video/7635316695493594408
https://www.douyin.com/video/7635316741647699240
https://www.douyin.com/video/7635316765005794560
https://www.douyin.com/video/7635316798203759887
https://www.douyin.com/video/7636339035187956607
https://www.douyin.com/video/7636679442659691828
https://www.douyin.com/video/7637061523759795498
https://www.douyin.com/video/7637133310971497780
https://www.douyin.com/video/7637842351964048666
https://www.douyin.com/video/7638181849688182016
https://www.douyin.com/video/7638869949355817833
https://www.douyin.com/video/7639958454355292644
https://www.douyin.com/video/7639984787955977512
https://www.douyin.com/video/7640498341511482633
https://www.douyin.com/video/7641193525614349577
https://www.douyin.com/video/7641933242391711017
https://www.douyin.com/video/7642704719329425829
https://www.douyin.com/video/7642718672406023475
https://www.douyin.com/video/7642942635859514283
https://www.douyin.com/video/7643399569413311794
https://www.douyin.com/video/7643399583900519707
https://www.douyin.com/video/7643399621305273646
https://www.douyin.com/video/7643399652410232114
https://www.douyin.com/video/7643399679262149915
https://www.douyin.com/video/7643399706835520818
https://www.douyin.com/video/7643399731854511370
https://www.douyin.com/video/7643399762783292699
https://www.douyin.com/video/7643836448696749321
https://www.douyin.com/video/7643836482079165742
https://www.douyin.com/video/7643836507152731430
https://www.douyin.com/video/7643836537339202867
https://www.douyin.com/video/7643836556603690267
https://www.douyin.com/video/7643836587238853926
https://www.douyin.com/video/7643836628036783411
https://www.douyin.com/video/7643836645401300250
https://www.douyin.com/video/7644524372230142578
https://www.douyin.com/video/7644942938593627455
https://www.douyin.com/video/7644964061846850868
https://www.douyin.com/video/7645680643090746678
https://www.douyin.com/video/7645885150408294633
https://www.douyin.com/video/7646438552036805951
https://www.douyin.com/video/7646438584496442660
https://www.douyin.com/video/7646438637361564947
https://www.douyin.com/video/7646438674032282922
https://www.douyin.com/video/7646438713542659382
https://www.douyin.com/video/7646438745310268735
https://www.douyin.com/video/7646438761672363318
https://www.douyin.com/video/7646438789157588287
https://www.douyin.com/video/7646438828449795371
https://www.douyin.com/video/7646438847320050950
https://www.douyin.com/video/7646438887044189459
https://www.douyin.com/video/7646438946464992553
https://www.douyin.com/video/7648511196286795689
https://www.douyin.com/video/7648947051494154687
https://www.douyin.com/video/7649956286361405062
https://www.douyin.com/video/7651929550519012642
https://www.douyin.com/video/7652243566080658723
https://www.douyin.com/video/7652347898604672932
https://www.douyin.com/video/7653300362463546624
https://www.douyin.com/video/7653356746279597750
https://www.douyin.com/video/7654943277090721059
https://www.douyin.com/video/7655139806577511720
https://www.douyin.com/video/7655552438715439103
https://www.douyin.com/video/7655888850384297471
https://www.douyin.com/video/7656070078584065318
https://www.douyin.com/video/7656070122267839753
https://www.douyin.com/video/7657735435922148585
https://www.douyin.com/video/7658255875513077169
https://www.douyin.com/video/7658256251562776933
https://www.douyin.com/video/7658320102064339633
https://www.douyin.com/video/7658516450584239798
https://www.douyin.com/video/7660041691113458987
https://www.douyin.com/video/7661469312670209515
https://www.douyin.com/video/7661931085793676544
https://www.douyin.com/video/7661931107813838114
https://www.douyin.com/video/7661931138289503522
https://www.douyin.com/video/7661931167666507042
https://www.douyin.com/video/7661931190332542242
https://www.douyin.com/video/7661931197441821952
https://www.douyin.com/video/7661931283458575650
https://www.douyin.com/video/7661931342665354496
https://www.douyin.com/video/7661931357584624948
https://www.douyin.com/video/7661931395362639104
https://www.douyin.com/video/7661931415226928419
https://www.douyin.com/video/7661931424836095267
https://www.douyin.com/video/7661931451125992744
https://www.douyin.com/video/7661931507992268072
https://www.douyin.com/video/7661931551202102563
https://www.douyin.com/video/7661931569287892264
https://www.douyin.com/video/7661931630545636608
https://www.douyin.com/video/7661931643493436706
https://www.douyin.com/video/7661931691321068835
https://www.douyin.com/video/7661931718554766626
https://www.douyin.com/video/7661931741958900992
https://www.douyin.com/video/7661931776390040867
https://www.douyin.com/video/7661931836888681780
https://www.douyin.com/video/7661931862633286946
https://www.douyin.com/video/7662218035515641140
https://www.douyin.com/video/7662218053077208360
https://www.douyin.com/video/7662218088900840704
https://www.douyin.com/video/7662218102171634979
https://www.douyin.com/video/7662218132945259811
https://www.douyin.com/video/7662218165438516515
https://www.douyin.com/video/7662218196191137059
https://www.douyin.com/video/7662218232337616180
https://www.douyin.com/video/7662218266663718144
https://www.douyin.com/video/7662218286230244642
https://www.douyin.com/video/7662218312989855016
https://www.douyin.com/video/7662218347819306280
https://www.douyin.com/video/7662218380635540776
https://www.douyin.com/video/7662218399677680911
https://www.douyin.com/video/7662218434167540992
https://www.douyin.com/video/7662218452119178511
https://www.douyin.com/video/7662218489884675368
https://www.douyin.com/video/7662218514773691663
https://www.douyin.com/video/7662218545916284212
https://www.douyin.com/video/7662218571778395392
https://www.douyin.com/video/7662218600618429731
https://www.douyin.com/video/7662218640300756259
https://www.douyin.com/video/7662218664111820066
https://www.douyin.com/video/7662218697624309007
https://www.douyin.com/video/7662218710391770420
https://www.douyin.com/video/7662218752864947508
https://www.douyin.com/video/7662218774661106978
https://www.douyin.com/video/7662218802544839936
https://www.douyin.com/video/7662218830264962304
https://www.douyin.com/video/7662218864784067874
https://www.douyin.com/video/7662218886854561039
https://www.douyin.com/video/7662218915753381172
https://www.douyin.com/video/7662218948678651151
https://www.douyin.com/video/7662218971994803491
https://www.douyin.com/video/7662219008879463714
https://www.douyin.com/video/7662219029083360546
https://www.douyin.com/video/7662219060616170792
https://www.douyin.com/video/7662219091423366415
https://www.douyin.com/video/7662219114114551055
https://www.douyin.com/video/7662219151301152000
https://www.douyin.com/video/7662219174516854016
https://www.douyin.com/video/7662219199598710051
https://www.douyin.com/video/7662219244079287604
https://www.douyin.com/video/7662697422921646565
https://www.douyin.com/video/7663057070836501796
https://www.douyin.com/video/7663381590835285275
https://www.douyin.com/video/7663415295486250275
https://www.douyin.com/video/7664341370543694565
https://www.douyin.com/video/7665209859366236342
https://www.douyin.com/video/7665546702552794212
https://www.douyin.com/video/7666446552147315983
https://www.douyin.com/video/7666446614600404224
https://www.douyin.com/video/7667226159591970277
https://www.douyin.com/video/7667888097522158884
https://www.douyin.com/video/7668949964085438890
https://www.douyin.com/video/7668970566061165864
https://www.douyin.com/video/7668970593844235535
https://www.douyin.com/video/7668970615088467240
https://www.douyin.com/video/7668970621757426995
https://www.douyin.com/video/7668970652136721715
https://www.douyin.com/video/7668970688992087342
https://www.douyin.com/video/7668970709305101577
https://www.douyin.com/video/7668970732763876634
https://www.douyin.com/video/7668970755870248202
https://www.douyin.com/video/7668970793174355241
https://www.douyin.com/video/7668970808127016235
https://www.douyin.com/video/7668970834920213779
https://www.douyin.com/video/7668970849373900073
https://www.douyin.com/video/7668970879883201811
https://www.douyin.com/video/7668970897864265002
https://www.douyin.com/video/7668970920089898283
https://www.douyin.com/video/7668970947268955432
https://www.douyin.com/video/7668970963693882639
https://www.douyin.com/video/7668970995868290338
https://www.douyin.com/video/7668971017531870498
https://www.douyin.com/video/7668971039824612660
https://www.douyin.com/video/7668971073014189321
https://www.douyin.com/video/7668971097697570100
https://www.douyin.com/video/7668971113472347443
https://www.douyin.com/video/7668971142786338074
https://www.douyin.com/video/7668971155079941403
https://www.douyin.com/video/7668971180715560202
https://www.douyin.com/video/7668971212848123177
https://www.douyin.com/video/7668971226374720787
https://www.douyin.com/video/7668971259891354923
https://www.douyin.com/video/7669621272674099627
https://www.douyin.com/video/7670203373631586993
https://www.douyin.com/video/7670746061603404266
https://www.douyin.com/video/7671489185173820406
https://www.douyin.com/video/7671561244180057387
https://www.douyin.com/video/7671561244997979398
https://www.douyin.com/video/7671875606372099769
https://www.douyin.com/video/7672299769552702756
https://www.douyin.com/video/7672449961476295945
https://www.douyin.com/video/7673097678699400458
https://www.douyin.com/video/7673855632486840959
https://www.douyin.com/video/7673962055442976019
https://www.douyin.com/video/7674961051557993983
https://www.douyin.com/video/7675723718471269247
https://www.douyin.com/video/7676226287258187941
https://www.douyin.com/video/7676750698175860008
https://www.douyin.com/video/7677128559848299434
https://www.douyin.com/video/7677634241332530478
https://www.douyin.com/video/7678205849635159695
https://www.douyin.com/video/7678623873885130019
https://www.douyin.com/video/7678638721272433929
https://www.douyin.com/video/7678703788907171072
https://www.douyin.com/video/7678758443209035058
https://www.douyin.com/video/7678940960503434542
https://www.douyin.com/video/7678984837763598582
https://www.douyin.com/video/7678993228523228416
https://www.douyin.com/video/7679067350875901230
https://www.douyin.com/video/7679105824676990235
https://www.douyin.com/video/7679335586229996800
https://www.douyin.com/video/7679492601623416127
https://www.douyin.com/video/7679619730159766822
https://www.douyin.com/video/7679741025519654803
https://www.douyin.com/video/7680045498736201003
https://www.douyin.com/video/7680046931812505770
https://www.douyin.com/video/7680189142793227572
https://www.douyin.com/video/7680392554445032744
https://www.douyin.com/video/7433698967734734120
https://www.douyin.com/video/7464796192308858163
https://www.douyin.com/video/7495303857429204283
https://www.douyin.com/video/7506844801183091980
https://www.douyin.com/video/7507459959304637750
https://www.douyin.com/video/7512465191726468411
https://www.douyin.com/video/7538701345206832438
https://www.douyin.com/video/7540828775166545212
https://www.douyin.com/video/7541675694007438650
https://www.douyin.com/video/7542040788307283257
https://www.douyin.com/video/7542509036920311097
https://www.douyin.com/video/7542561195904224572
https://www.douyin.com/video/7542768053986135356
https://www.douyin.com/video/7543312766431481148
https://www.douyin.com/video/7543656576851561787
https://www.douyin.com/video/7543881121785105724
https://www.douyin.com/video/7544777829592010027
https://www.douyin.com/video/7550593764830579995
https://www.douyin.com/video/7551253123344846121
https://www.douyin.com/video/7552099919394721074
https://www.douyin.com/video/7553721758868819241
https://www.douyin.com/video/7553725286257872170
https://www.douyin.com/video/7553726079598218515
https://www.douyin.com/video/7553949930626125107
https://www.douyin.com/video/7556127462901583143
https://www.douyin.com/video/7557016051545427260
https://www.douyin.com/video/7557304971768991003
https://www.douyin.com/video/7557309880038640954
https://www.douyin.com/video/7557342898887937337
https://www.douyin.com/video/7560698483473911078
https://www.douyin.com/video/7564713147735526662
https://www.douyin.com/video/7565140842861284667
https://www.douyin.com/video/7565678143320689977
https://www.douyin.com/video/7568105965745702372
https://www.douyin.com/video/7568853059900654195
https://www.douyin.com/video/7570274551029674889
https://www.douyin.com/video/7570321736815552137
https://www.douyin.com/video/7573132562995864486
https://www.douyin.com/video/7574377445916175019
https://www.douyin.com/video/7574382031670187219
https://www.douyin.com/video/7575960113161613402
https://www.douyin.com/video/7577037635287703411
https://www.douyin.com/video/7577550226790787570
https://www.douyin.com/video/7577628611085599859
https://www.douyin.com/video/7577635516536109750
https://www.douyin.com/video/7577641560546191979
https://www.douyin.com/video/7577963828598006498
https://www.douyin.com/video/7579019462911000202
https://www.douyin.com/video/7579859975944071026
https://www.douyin.com/video/7579924192284720870
https://www.douyin.com/video/7580151398587922344
https://www.douyin.com/video/7580157386016814671
https://www.douyin.com/video/7580159693399831412
https://www.douyin.com/video/7580377323942954085
https://www.douyin.com/video/7580388092725515368
https://www.douyin.com/video/7580394956884948864
https://www.douyin.com/video/7580620107187210875
https://www.douyin.com/video/7580626018387171176
https://www.douyin.com/video/7580662055020317300
https://www.douyin.com/video/7581070847160370533
https://www.douyin.com/video/7581077985031479675
https://www.douyin.com/video/7581087921794090634
https://www.douyin.com/video/7581489020892286258
https://www.douyin.com/video/7581489540586818825
https://www.douyin.com/video/7582035082827926835
https://www.douyin.com/video/7584341688141090088
https://www.douyin.com/video/7585496206969818427
https://www.douyin.com/video/7586118751117921595
https://www.douyin.com/video/7586265564667071781
https://www.douyin.com/video/7586454001065200953
https://www.douyin.com/video/7587052387870444827
https://www.douyin.com/video/7587196537789304091
https://www.douyin.com/video/7587446797670452532
https://www.douyin.com/video/7587448044849728783
https://www.douyin.com/video/7588194471104632762
https://www.douyin.com/video/7588487482376931493
https://www.douyin.com/video/7588791631819731658
https://www.douyin.com/video/7588908317163379571
https://www.douyin.com/video/7588919048840399033
https://www.douyin.com/video/7589127362404041627
https://www.douyin.com/video/7590096280598188523
https://www.douyin.com/video/7590207026211343626
https://www.douyin.com/video/7590449998632179112
https://www.douyin.com/video/7590453788919998312
https://www.douyin.com/video/7590806848073780968
https://www.douyin.com/video/7591110669455099546
https://www.douyin.com/video/7591292418127230619
https://www.douyin.com/video/7591883506797366646
https://www.douyin.com/video/7592023205097192794
https://www.douyin.com/video/7592099294716042418
https://www.douyin.com/video/7592390534959593454
https://www.douyin.com/video/7592809534818733806
https://www.douyin.com/video/7593354379336934577
https://www.douyin.com/video/7593356172943964773
https://www.douyin.com/video/7593369478470715535
https://www.douyin.com/video/7593390272953164392
https://www.douyin.com/video/7593564907442805102
https://www.douyin.com/video/7593638829124440307
https://www.douyin.com/video/7593653145739701998
https://www.douyin.com/video/7593656029210500467
https://www.douyin.com/video/7593663933921047690
https://www.douyin.com/video/7593967261745701987
https://www.douyin.com/video/7593968011066922164
https://www.douyin.com/video/7594314269124242874
https://www.douyin.com/video/7594481307385064010
https://www.douyin.com/video/7594617234997279386
https://www.douyin.com/video/7594619977484269258
https://www.douyin.com/video/7594668942623730035
https://www.douyin.com/video/7595063318445789670
https://www.douyin.com/video/7595212613442312655
https://www.douyin.com/video/7595924519417910099
https://www.douyin.com/video/7595926644872445859
https://www.douyin.com/video/7596185230475142754
https://www.douyin.com/video/7596290025680229028
https://www.douyin.com/video/7596323573515555963
https://www.douyin.com/video/7596649064205540721
https://www.douyin.com/video/7596679623200868709
https://www.douyin.com/video/7596706946221486243
https://www.douyin.com/video/7596707276998066895
https://www.douyin.com/video/7597075505905363941
https://www.douyin.com/video/7597206852879821811
https://www.douyin.com/video/7597293058317401707
https://www.douyin.com/video/7597668967721416768
https://www.douyin.com/video/7597786037558180586
https://www.douyin.com/video/7597801533107941609
https://www.douyin.com/video/7597805982861915813
https://www.douyin.com/video/7597812557995213434
https://www.douyin.com/video/7598004652042482600
https://www.douyin.com/video/7598037325616334299
https://www.douyin.com/video/7598326102309996526
https://www.douyin.com/video/7599084045054519369
https://www.douyin.com/video/7599815578216540134
https://www.douyin.com/video/7599973946096934393
https://www.douyin.com/video/7600566663646500774
https://www.douyin.com/video/7600569180350231667
https://www.douyin.com/video/7600927418229450842
https://www.douyin.com/video/7601308862616807654
https://www.douyin.com/video/7601352429963895473
https://www.douyin.com/video/7601696173858583662
https://www.douyin.com/video/7602663496509695461
https://www.douyin.com/video/7603265820214512946
https://www.douyin.com/video/7603705839341461594
https://www.douyin.com/video/7604096102266559334
https://www.douyin.com/video/7604121099990831305
https://www.douyin.com/video/7604260701607805322
https://www.douyin.com/video/7604266795591364682
https://www.douyin.com/video/7604390284369827249
https://www.douyin.com/video/7604501352417079291
https://www.douyin.com/video/7604637395715431817
https://www.douyin.com/video/7605005197412160754
https://www.douyin.com/video/7605012997388896626
https://www.douyin.com/video/7605379095523094258
https://www.douyin.com/video/7605771935038288101
https://www.douyin.com/video/7605966531276770534
https://www.douyin.com/video/7606778214602262080
https://www.douyin.com/video/7606862005216141705
https://www.douyin.com/video/7607346629067314170
https://www.douyin.com/video/7607422200546731379
https://www.douyin.com/video/7607430271919286437
https://www.douyin.com/video/7607499082919925755
https://www.douyin.com/video/7608129969706005938
https://www.douyin.com/video/7608133336553143731
https://www.douyin.com/video/7608823449670244325
https://www.douyin.com/video/7609165080932928627
https://www.douyin.com/video/7609178002768865393
https://www.douyin.com/video/7609243114637537774
https://www.douyin.com/video/7610216873716747502
https://www.douyin.com/video/7610375575123831525
https://www.douyin.com/video/7610379408163698043
https://www.douyin.com/video/7610420931296014218
https://www.douyin.com/video/7610788481493047497
https://www.douyin.com/video/7610790050036896201
https://www.douyin.com/video/7610799622252426149
https://www.douyin.com/video/7611054411524655609
https://www.douyin.com/video/7611143485625996773
https://www.douyin.com/video/7611912599945016243
https://www.douyin.com/video/7612272421220315739
https://www.douyin.com/video/7612283093549871689
https://www.douyin.com/video/7613443721098954122
https://www.douyin.com/video/7613788616028458163
https://www.douyin.com/video/7614154312163584714
https://www.douyin.com/video/7614769013458184858
https://www.douyin.com/video/7618369488867068594
https://www.douyin.com/video/7618621798645655194
https://www.douyin.com/video/7619763243272191409
https://www.douyin.com/video/7619880183122074862
https://www.douyin.com/video/7620968566594347110
https://www.douyin.com/video/7621331487908878194
https://www.douyin.com/video/7621974163138711214
https://www.douyin.com/video/7622574727391029178
https://www.douyin.com/video/7622575767101211569
https://www.douyin.com/video/7622576693249580219
https://www.douyin.com/video/7622577382738844603
https://www.douyin.com/video/7623193019621576347
https://www.douyin.com/video/7623391518167367526
https://www.douyin.com/video/7623549232067271654
https://www.douyin.com/video/7624509633576101499
https://www.douyin.com/video/7624814177739787889
https://www.douyin.com/video/7624841196913755121
https://www.douyin.com/video/7625300200428922266
https://www.douyin.com/video/7625668208544558523
https://www.douyin.com/video/7625787550838330250
https://www.douyin.com/video/7625996989730245283
https://www.douyin.com/video/7626179454759549882
https://www.douyin.com/video/7626306252448540131
https://www.douyin.com/video/7626317662599703110
https://www.douyin.com/video/7626351694943828883
https://www.douyin.com/video/7626373431710268322
https://www.douyin.com/video/7626514382880668262
https://www.douyin.com/video/7626525300621251392
https://www.douyin.com/video/7626898975647879014
https://www.douyin.com/video/7626904838442180431
https://www.douyin.com/video/7627269341940254435
https://www.douyin.com/video/7627273762317137728
https://www.douyin.com/video/7627452491014400434
https://www.douyin.com/video/7627640946021222258
https://www.douyin.com/video/7627791599888606452
https://www.douyin.com/video/7628382270520418930
https://www.douyin.com/video/7628499181401977253
https://www.douyin.com/video/7628998055552151232
https://www.douyin.com/video/7629134053551417512
https://www.douyin.com/video/7629337784075471203
https://www.douyin.com/video/7629377455199190671
https://www.douyin.com/video/7629667721244818895
https://www.douyin.com/video/7630238186673142504
https://www.douyin.com/video/7630481013077415272
https://www.douyin.com/video/7630482000382375104
https://www.douyin.com/video/7630610031935653007
https://www.douyin.com/video/7630760366989272936
https://www.douyin.com/video/7630983513302965851
https://www.douyin.com/video/7631145987604583921
https://www.douyin.com/video/7631365186067522383
https://www.douyin.com/video/7631411443455487099
https://www.douyin.com/video/7631870088674057320
https://www.douyin.com/video/7631952471640582321
https://www.douyin.com/video/7632091005480074432
https://www.douyin.com/video/7632100847442779535
https://www.douyin.com/video/7632176798050410850
https://www.douyin.com/video/7632619088523977690
https://www.douyin.com/video/7632838030857000026
https://www.douyin.com/video/7632839301714046115
https://www.douyin.com/video/7633197171035519075
https://www.douyin.com/video/7633468311833374950
https://www.douyin.com/video/7633499322625701358
https://www.douyin.com/video/7633580630672337097
https://www.douyin.com/video/7633679557077273910
https://www.douyin.com/video/7633949502793552820
https://www.douyin.com/video/7634174075011095770
https://www.douyin.com/video/7634192569665203363
https://www.douyin.com/video/7634326396873748559
https://www.douyin.com/video/7634490747660657059
https://www.douyin.com/video/7634801908238843791
https://www.douyin.com/video/7635301535589435107
https://www.douyin.com/video/7635518586224318195
https://www.douyin.com/video/7635819894857410688
https://www.douyin.com/video/7635892928008165050
https://www.douyin.com/video/7635895818876490664
https://www.douyin.com/video/7636542453062950336
https://www.douyin.com/video/7636546948492899712
https://www.douyin.com/video/7636660856373876901
https://www.douyin.com/video/7636919603901669108
https://www.douyin.com/video/7637109323545959284
https://www.douyin.com/video/7637288453793885858
https://www.douyin.com/video/7637475460754188648
https://www.douyin.com/video/7637515496460672627
https://www.douyin.com/video/7637665267543820968
https://www.douyin.com/video/7638032134292445800
https://www.douyin.com/video/7638200653047250815
https://www.douyin.com/video/7638388752579505512
https://www.douyin.com/video/7639369373060554356
https://www.douyin.com/video/7639369904672932579
https://www.douyin.com/video/7639989670817942820
https://www.douyin.com/video/7640850746828780724
https://www.douyin.com/video/7641440697664556351
https://www.douyin.com/video/7641566174120398504
https://www.douyin.com/video/7641638687881716082
https://www.douyin.com/video/7641639195727950811
https://www.douyin.com/video/7642113955414440692
https://www.douyin.com/video/7642498563649423796
https://www.douyin.com/video/7642534545985170651
https://www.douyin.com/video/7642562788644212011
https://www.douyin.com/video/7642864713538415055
https://www.douyin.com/video/7642866734747867727
https://www.douyin.com/video/7642934586229476649
https://www.douyin.com/video/7643233091314462927
https://www.douyin.com/video/7643800575389737359
https://www.douyin.com/video/7644085432163211235
https://www.douyin.com/video/7644181694957866280
https://www.douyin.com/video/7644192133059029193
https://www.douyin.com/video/7644339751276866255
https://www.douyin.com/video/7644520394418114767
https://www.douyin.com/video/7644825112927552168
https://www.douyin.com/video/7645622929387965737
https://www.douyin.com/video/7647391912759987492
https://www.douyin.com/video/7648296162151783119
https://www.douyin.com/video/7648427494836522722
https://www.douyin.com/video/7650876507203865582
https://www.douyin.com/video/7651250186241233059
https://www.douyin.com/video/7651625585542638991
https://www.douyin.com/video/7652645553058088787
https://www.douyin.com/video/7653434228675987065
https://www.douyin.com/video/7653995176151290217
https://www.douyin.com/video/7654557816787303827
https://www.douyin.com/video/7654939453601182443
https://www.douyin.com/video/7655183708684787691
https://www.douyin.com/video/7656221180017303039
https://www.douyin.com/video/7656310984231825050
https://www.douyin.com/video/7657729145178568851
https://www.douyin.com/video/7658597149303152997
https://www.douyin.com/video/7658888903580400869
https://www.douyin.com/video/7659048357873365321
https://www.douyin.com/video/7659051992136263653
https://www.douyin.com/video/7659928756119325155
https://www.douyin.com/video/7660018534835047726
https://www.douyin.com/video/7660368742513639162
https://www.douyin.com/video/7660442087179422705
https://www.douyin.com/video/7660753830233050368
https://www.douyin.com/video/7660886310495916198
https://www.douyin.com/video/7661054141707764465
https://www.douyin.com/video/7661304061936922993
https://www.douyin.com/video/7661556062293790073
https://www.douyin.com/video/7661903439354258043
https://www.douyin.com/video/7661921217062696441
https://www.douyin.com/video/7661927054447957169
https://www.douyin.com/video/7661927362422435321
https://www.douyin.com/video/7661938193302450929
https://www.douyin.com/video/7661943639765449137
https://www.douyin.com/video/7662650281771930865
https://www.douyin.com/video/7663001683631615409
https://www.douyin.com/video/7663003105881069169
https://www.douyin.com/video/7663068486411357873
https://www.douyin.com/video/7663424935708572785
https://www.douyin.com/video/7663805570860004810
https://www.douyin.com/video/7664442574513639269
https://www.douyin.com/video/7664482373987529137
https://www.douyin.com/video/7664505249533845617
https://www.douyin.com/video/7664582094164712433
https://www.douyin.com/video/7664582866961897210
https://www.douyin.com/video/7664622902432573809
https://www.douyin.com/video/7664655382844902682
https://www.douyin.com/video/7664783867907499240
https://www.douyin.com/video/7664794487819001841
https://www.douyin.com/video/7664795428417196529
https://www.douyin.com/video/7664886189099108473
https://www.douyin.com/video/7664893250041720058
https://www.douyin.com/video/7665331346969312881
https://www.douyin.com/video/7665353074157146545
https://www.douyin.com/video/7665544450933043377
https://www.douyin.com/video/7665551203161884529
https://www.douyin.com/video/7665985902460201329
https://www.douyin.com/video/7666148481166800049
https://www.douyin.com/video/7666471527055626932
https://www.douyin.com/video/7667231182241755825
https://www.douyin.com/video/7667257077496603109
https://www.douyin.com/video/7667425025976313445
https://www.douyin.com/video/7670588545283971441
https://www.douyin.com/video/7671910059202350370
https://www.douyin.com/video/7672039534930308785
https://www.douyin.com/video/7672307700373921956
https://www.douyin.com/video/7672323816546818481
https://www.douyin.com/video/7672556460832535034
https://www.douyin.com/video/7673479545232496219
https://www.douyin.com/video/7674268500702031990
https://www.douyin.com/video/7674550014517696586
https://www.douyin.com/video/7674784828718340863
https://www.douyin.com/video/7675052407059298408
https://www.douyin.com/video/7675174050017436136
https://www.douyin.com/video/7675196882150542001
https://www.douyin.com/video/7675275660490924724
https://www.douyin.com/video/7675615053123912438
https://www.douyin.com/video/7676685798481319016
https://www.douyin.com/video/7678101190555682530
https://www.douyin.com/video/7678639376749940014

533
pipeline.py Normal file
View File

@@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""抖音采集一体化流水线:下载 -> 抽 mp3 -> 传 COS -> 记元信息 -> 删本地。
跟原来分阶段跑(全下完再全抽完再全传完)最大的区别是磁盘占用:
分阶段必须把所有 mp4 摊在盘上(上一批是 53 GB),这里每条走完就删,
峰值只有 workers x (mp4+mp3),约 100 MB,且与总量无关。
每条视频端到端走 9 步:
1. fetch_detail 拿元信息 + raw
2. 下载 mp4
3. ffmpeg 抽 mp3 [信号量限流,见下]
4. ffprobe 探两个的时长
5. 校验 mp3 时长 ~= mp4 时长 <- 抽取闸门
6. 上传 mp4(覆盖同名)
7. 上传 mp3(覆盖同名)
8. head_object 核对大小 <- 删除闸门(在 CosUploader.put 里)
9. 写 record,删本地两个文件
任何一步失败:保留本地文件不删,写进 failed 文件,继续下一条。
并发:下载和上传是网络密集,按 --workers 并发;ffmpeg 是 CPU 密集,
单独用信号量限到 CPU 核数,避免 8 个 worker 同时撞进第 3 步把 CPU 打满。
用法:
$env:COS_SECRET_ID="xxx"; $env:COS_SECRET_KEY="yyy"
python pipeline.py --from-file links.txt --region ap-chongqing --limit 20 --keep-local
python pipeline.py --from-file links.txt --region ap-chongqing --workers 8
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
import requests
from douyin import (
AppClient,
DeviceRegisterError,
DouyinError,
NotAVideo,
ParseError,
download,
extract_aweme_id,
)
from douyin.audio import build_cmd, ensure_ffmpeg, probe
from douyin.batch import DevicePool
from douyin.cos import CosError, CosUploader, DEFAULT_BUCKET, DEFAULT_PREFIX
from douyin.meta import RecordLog, build_record
class StageError(RuntimeError):
"""带阶段名的失败,便于 failed 文件里归因。"""
def __init__(self, stage: str, msg: str) -> None:
super().__init__(msg)
self.stage = stage
@dataclass
class Stats:
total: int = 0
done: int = 0
skipped: int = 0
failed: int = 0
bytes_up: int = 0
lock: threading.Lock = field(default_factory=threading.Lock)
_tls = threading.local()
def session() -> requests.Session:
"""每个线程一个下载用 session。CDN 直链不需要设备头,裸 session 即可。"""
s = getattr(_tls, "dl", None)
if s is None:
s = requests.Session()
s.headers.update({"User-Agent": "okhttp/3.12.13"})
_tls.dl = s
return s
def read_targets(args) -> List[str]:
raw: List[str] = list(args.target or [])
if args.from_file:
raw += [ln.strip() for ln in
Path(args.from_file).read_text(encoding="utf-8").splitlines()
if ln.strip() and not ln.strip().startswith("#")]
return raw
def next_abort_wait(streak: int, base: int, cap: int) -> int:
"""撞 IP 级注册限流后等多久再续跑。streak = 连着撞了几次(不含这次)。
抖音封的是 IP,不是设备指纹——DevicePool 换 UA 没用,只能等。实测一轮
能跑 5~6 小时、44 次设备轮换才触顶,冷却大概几小时。连撞就翻倍,说明
还没解封;有一段跑成了就把 streak 清零、等待打回底价。
"""
if base <= 0:
return 0
return min(base * (2 ** streak), cap)
def plan_batch(pending, max_items: int):
"""把待处理队列切一段出来跑,返回 (这一段, 还有没有剩下的)。
为什么要切段:单个 python 进程跑到 1.5 万条上下,Windows 会话的桌面堆
就被几万个 ffmpeg/ffprobe 子进程耗光,之后 CreateProcess 一律失败
(rc=3221225794 / STATUS_DLL_INIT_FAILED)。表现很好认:完成数冻结、
失败数猛涨、上传量不动、work/ 暴涨(下载成功但抽音失败的 mp4 按设计
留着)。实测撞过两次 —— 三进程并跑时一次,单进程跑到 16399 条又一次,
所以是**累积量**的问题,不是并发数。
段跑完 os.execv 重启自己,桌面堆跟着重置。断点续跑本来就靠
records.jsonl,重启不会重下。max_items=0 表示不切。
"""
if max_items and len(pending) > max_items:
return pending[:max_items], True
return pending, False
def sweep_work_dir(work: Path, records: Path) -> int:
"""清掉 work/ 里已经没用的残留,返回删了几个文件。
删两类:
* aweme_id 已经在 records.jsonl 里的 —— 早传上 COS 了,留着纯属占地方;
* `.part` 半截下载 —— 断点续传的 Range 起点,进程被杀后就是垃圾。
**不碰失败项的文件**:上传失败、抽音截断这些是故意留在本地的,重跑时
省一次下载,见 test_upload_failure_keeps_local_and_writes_no_record。
为什么要有这个函数:正常路径删文件(见下面 unlink 那两行),失败路径
故意不删,可失败项的文件从此再没人回收 —— 66411 条那批跑到 2.7% 就在
work/ 里攒了 11 GB,其中 414 个的 aweme_id 已经在 records.jsonl 里。
(那 414 个的 mp4 修改时间比记录时间还晚 17~166 秒,说明是记录写完之后
才落地的,具体怎么产生的没查清;这个清扫无论如何都能兜住。)
"""
if not work.is_dir():
return 0
done = set()
if records.exists():
# aweme_id 是 RecordLog 写出的第一个字段,按行前缀扫,不解析整行 JSON
# (records.jsonl 带 raw,1.8 GB,逐行 json.loads 要几分钟)。
# 容忍冒号后的空格:紧凑写法没有,但别让格式差异把清扫悄悄变成空操作。
pat = re.compile(rb'^\{\s*"aweme_id"\s*:\s*"(\d+)"')
with open(records, "rb") as f:
for line in f:
m = pat.match(line)
if m:
done.add(m.group(1).decode())
freed = 0
for p in work.iterdir():
if not p.is_file():
continue
if p.name.endswith(".part") or p.stem in done:
try:
p.unlink()
freed += 1
except OSError:
pass # 还被占着,下一轮再说
return freed
def extract_mp3(ffmpeg: str, src: Path, dst: Path, bitrate: str,
timeout: float = 600.0) -> None:
cmd = build_cmd(ffmpeg, src, dst, "mp3", bitrate)
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise StageError("audio", f"ffmpeg 超时 >{timeout}s")
if p.returncode != 0 or not dst.exists() or dst.stat().st_size == 0:
err = (p.stderr or "").strip().splitlines()
raise StageError("audio", err[-1][:150] if err else f"rc={p.returncode}")
def decoded_seconds(path: Path, ffprobe: str, timeout: float = 300.0) -> Optional[float]:
"""实际解码算出的音频时长。
不能用 `format=duration`:LAME 写的 Xing 头声明了原始总帧数,ffprobe
直接信它而不解码。实测把 mp3 截断到 20%,format=duration 报的还是
10.0078s,和完整文件一模一样——拿它做截断校验等于没做。
`-count_frames` 会真解码。代价很小:120s 的 mp3 是 0.109s 对 0.053s。
"""
cmd = [ffprobe, "-v", "error", "-select_streams", "a:0", "-count_frames",
"-show_entries", "stream=nb_read_frames,sample_rate",
"-of", "default=noprint_wrappers=1"] # 保留 key= 前缀
try:
p = subprocess.run(cmd + [str(path)], capture_output=True, text=True,
timeout=timeout)
except subprocess.TimeoutExpired:
return None
# 按 key 解析,不靠位置:ffprobe 是按流内字段顺序输出的,不是按请求顺序
kv = {}
for line in (p.stdout or "").splitlines():
if "=" in line:
k, _, v = line.partition("=")
kv[k.strip()] = v.strip()
try:
frames = int(kv["nb_read_frames"])
rate = int(kv["sample_rate"])
except (KeyError, ValueError):
return None
if frames <= 0 or rate <= 0:
return None
# MPEG1 Layer III 每帧 1152 采样;MPEG2/2.5(<32kHz)是 576
per_frame = 1152 if rate >= 32000 else 576
return frames * per_frame / rate
def check_duration(mp4: Path, mp3: Path, ffprobe: str) -> float:
"""返回 mp4 的精确时长;顺便校验 mp3 没被截断。
ffmpeg 返回 0 但产出截断文件是见过的,光看 returncode 不够。mp4 传上去
之后本地就删了,所以这道闸门必须在删除之前把话说死。
"""
d4 = probe(mp4, ffprobe)
if not d4:
raise StageError("probe", "ffprobe 读不出 mp4 时长")
d3 = decoded_seconds(mp3, ffprobe)
if not d3:
raise StageError("probe", "mp3 解不出有效音频帧")
tol = max(1.0, d4 * 0.02)
if abs(d3 - d4) > tol:
raise StageError("probe",
f"mp3 实解 {d3:.3f}s 与 mp4 {d4:.3f}s 相差超过 {tol:.3f}s,"
"疑似抽取被截断")
return d4
def run_one(aweme_id: str, source_url: str, *, pool: DevicePool, up: CosUploader,
log: RecordLog, args, ffmpeg: str, ff_sem: threading.Semaphore,
stats: Stats, fail, abort: threading.Event) -> None:
# 注册配额是 IP 级的,耗尽后换设备也没用。这时候必须整批停下来等,
# 而不是让剩下几千条一条条去撞同一堵墙、全标成失败——上一批就是这么
# 烧掉 678 条的。abort 一旦拉起,后续任务直接返回,不计失败。
if abort.is_set():
with stats.lock:
stats.skipped += 1
return
work = Path(args.work_dir)
mp4 = work / f"{aweme_id}.mp4"
mp3 = work / f"{aweme_id}.mp3"
try:
# 1. 元信息
try:
device, gen = pool.current()
except DeviceRegisterError as exc:
abort.set()
with stats.lock:
stats.failed += 1
fail(aweme_id, "abort", f"IP 级注册配额耗尽,已中止整批:{exc}")
return
try:
aweme = AppClient(device).fetch_detail(aweme_id)
except NotAVideo as exc:
raise StageError("detail", str(exc))
except DouyinError as exc:
# 空 body 多半是设备配额到了,换设备重来一次
if "空 body" in str(exc):
pool.report_dead(gen)
device, gen = pool.current()
aweme = AppClient(device).fetch_detail(aweme_id)
else:
raise StageError("detail", str(exc))
if not aweme.video_urls and not aweme.uri:
raise StageError("detail", "没有可用的视频直链")
# 2. 下载
try:
res = download(session(), aweme.video_urls or [aweme.play_url()],
mp4, progress=False)
except Exception as exc:
raise StageError("download", f"{type(exc).__name__}: {exc}")
if res.size <= 0:
raise StageError("download", "下载产物是空文件")
# 3. 抽 mp3(CPU 密集,限流)
with ff_sem:
extract_mp3(ffmpeg, mp4, mp3, args.bitrate)
# 4~5. 探时长 + 校验没被截断
duration = check_duration(mp4, mp3, args.ffprobe)
# 6~8. 上传,put() 内部做 head_object 大小校验
try:
url4, size4 = up.put(mp4, up.key_for(mp4.name))
url3, size3 = up.put(mp3, up.key_for(mp3.name))
except CosError as exc:
raise StageError("upload", str(exc))
# 9. 记录,然后才敢删
log.write(build_record(aweme, duration_sec=duration,
cos_mp4=url4, cos_mp3=url3,
size_mp4=size4, size_mp3=size3,
source_url=source_url))
if not args.keep_local:
mp4.unlink(missing_ok=True)
mp3.unlink(missing_ok=True)
with stats.lock:
stats.done += 1
stats.bytes_up += size4 + size3
except DeviceRegisterError as exc:
abort.set()
with stats.lock:
stats.failed += 1
fail(aweme_id, "abort", f"IP 级注册配额耗尽,已中止整批:{exc}")
except StageError as exc:
with stats.lock:
stats.failed += 1
fail(aweme_id, exc.stage, str(exc))
except Exception as exc: # 兜底,别让一条炸掉整批
with stats.lock:
stats.failed += 1
fail(aweme_id, "unknown", f"{type(exc).__name__}: {exc}")
def main(argv: Optional[List[str]] = None) -> int:
ap = argparse.ArgumentParser(description="抖音采集一体化流水线")
ap.add_argument("target", nargs="*", help="链接或 aweme_id")
ap.add_argument("--from-file", default="", help="从文件读链接,一行一个")
ap.add_argument("--records", default="records/records.jsonl", help="元信息落点")
ap.add_argument("--failed", default="records/pipeline_failed.txt")
ap.add_argument("--work-dir", default="work", help="mp4/mp3 临时目录,传完即删")
ap.add_argument("--region", required=True)
ap.add_argument("--bucket", default=DEFAULT_BUCKET)
ap.add_argument("--prefix", default=DEFAULT_PREFIX)
ap.add_argument("--url-style", choices=["cos", "https", "path"], default="cos")
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--bitrate", default="192k")
ap.add_argument("--ffmpeg", default="ffmpeg")
ap.add_argument("--ffprobe", default="ffprobe")
ap.add_argument("--ffmpeg-workers", type=int, default=0,
help="并发 ffmpeg 数,0=CPU 核数")
ap.add_argument("--device-quota", type=int, default=550,
help="单设备 detail 调用上限。实测约 575 次后被限流,"
"留一点余量;调高可减少注册次数")
ap.add_argument("--limit", type=int, default=0, help="只跑前 N 条,用于试跑")
ap.add_argument("--max-items", type=int, default=12000,
help="单个进程最多跑多少条,跑满就重启自己接着跑。"
"防 Windows 桌面堆耗尽,见 plan_batch。0=不切段")
ap.add_argument("--abort-wait", type=int, default=1800,
help="撞上 IP 级注册限流后等多少秒再自动续跑。"
"连撞会翻倍,见 next_abort_wait。0=不等,直接退出")
ap.add_argument("--abort-wait-cap", type=int, default=14400,
help="上面那个等待的上限秒数,默认 4 小时")
ap.add_argument("--keep-local", action="store_true",
help="上传后不删本地文件(试跑时用)")
args = ap.parse_args(argv)
targets = read_targets(args)
if not targets:
ap.error("没有目标。给链接,或用 --from-file。")
try:
ffmpeg = ensure_ffmpeg(args.ffmpeg)
except Exception as exc:
print(f"[错误] {exc}", file=sys.stderr)
return 2
try:
up = CosUploader(region=args.region, bucket=args.bucket, prefix=args.prefix,
url_style=args.url_style)
except CosError as exc:
print(f"[错误] {exc}", file=sys.stderr)
return 2
log = RecordLog(Path(args.records))
print(f"已完成 {len(log)} 条(从 {args.records} 读出),本次输入 {len(targets)} 条")
# 解析 id,顺便去重和跳过已完成的
pending = []
seen = set()
bad = 0
for raw in targets:
try:
aid = extract_aweme_id(raw, session())
except ParseError:
bad += 1
continue
if aid in seen:
continue
seen.add(aid)
if aid in log:
continue
pending.append((aid, raw))
if args.limit:
pending = pending[: args.limit]
skipped = len(seen) - len(pending) if not args.limit else 0
print(f"待处理 {len(pending)} 条"
+ (f",跳过已完成 {skipped} 条" if skipped else "")
+ (f",无法解析 {bad} 条" if bad else ""))
if not pending:
return 0
pending, more = plan_batch(pending, args.max_items)
if more:
print(f"本段只跑 {len(pending)} 条,跑完自动重启进程接着来"
f"(见 plan_batch 的注释:桌面堆)")
Path(args.work_dir).mkdir(parents=True, exist_ok=True)
if not args.keep_local:
n = sweep_work_dir(Path(args.work_dir), Path(args.records))
if n:
print(f"清掉 work/ 里 {n} 个已入库或半截的残留文件")
stats = Stats(total=len(pending))
fail_lock = threading.Lock()
fail_path = Path(args.failed)
def fail(aweme_id: str, stage: str, msg: str) -> None:
with fail_lock:
with open(fail_path, "a", encoding="utf-8") as f:
f.write(f"{aweme_id}\t{stage}\t{msg}\n")
pool = DevicePool(max_calls=args.device_quota)
# DevicePool 是惰性注册的。先主动要一次,注册不了就当场退出,
# 而不是让每个 worker 各自撞一遍、把整批标成失败。
try:
pool.current()
except DeviceRegisterError as exc:
# 开跑就注册不上 = IP 还在冷却期。这条路以前直接退出,等于
# "撞限流后自动续跑"在冷却没结束时会白白放弃,所以这里也要等。
streak = int(os.environ.get("_DY_ABORT_STREAK", "0"))
wait = next_abort_wait(streak, args.abort_wait, args.abort_wait_cap)
if wait:
os.environ["_DY_ABORT_STREAK"] = str(streak + 1)
until = time.strftime("%H:%M:%S", time.localtime(time.time() + wait))
print(f"[限流] 设备注册失败:{exc}", file=sys.stderr)
print(f"IP 还在冷却,等 {wait // 60} 分钟到 {until} 再试…", flush=True)
sys.stdout.flush()
sys.stderr.flush()
time.sleep(wait)
os.execv(sys.executable, [sys.executable] + sys.argv)
print(f"[错误] 设备注册失败:{exc}", file=sys.stderr)
return 3
n_ff = args.ffmpeg_workers or (os.cpu_count() or 4)
ff_sem = threading.Semaphore(n_ff)
print(f"并发 {args.workers},其中 ffmpeg 限 {n_ff}"
+ (",本地文件保留不删" if args.keep_local else ""))
t0 = time.time()
stop = threading.Event()
abort = threading.Event()
def report() -> None:
ticks = 0
while not stop.wait(15):
ticks += 1
with stats.lock:
d, f_, tot = stats.done, stats.failed, stats.total
gb = stats.bytes_up / 1024 ** 3
el = time.time() - t0
rate = d / el * 60 if el else 0
left = (tot - d - f_) / rate if rate else 0
print(f" [{d + f_}/{tot}] 完成{d} 失败{f_} | {gb:.2f}GB 上传 | "
f"{rate:.0f}条/分 | 剩约 {left:.0f}分", flush=True)
# 每 5 分钟回收一次:光靠开跑前扫一次,长批跑到一半磁盘就满了
if ticks % 20 == 0 and not args.keep_local:
n = sweep_work_dir(Path(args.work_dir), Path(args.records))
if n:
print(f" [清扫] 回收 {n} 个残留文件", flush=True)
threading.Thread(target=report, daemon=True).start()
try:
with ThreadPoolExecutor(max_workers=args.workers) as ex:
for aid, raw in pending:
ex.submit(run_one, aid, raw, pool=pool, up=up, log=log, args=args,
ffmpeg=ffmpeg, ff_sem=ff_sem, stats=stats, fail=fail,
abort=abort)
finally:
stop.set()
el = time.time() - t0
print(f"\n完成 {stats.done},失败 {stats.failed},"
f"上传 {stats.bytes_up / 1024 ** 3:.2f} GB,耗时 {el / 60:.1f} 分")
print(f"元信息 -> {args.records}(累计 {len(log)} 条)")
if stats.failed:
print(f"失败明细 -> {fail_path}(本地文件已保留,可直接重跑本脚本续传)")
def _restart(why: str, wait: int = 0) -> None:
"""重启自己接着跑。断点续跑靠 records.jsonl,重启不会重下。"""
if wait:
until = time.strftime("%H:%M:%S", time.localtime(time.time() + wait))
print(f"{why},等 {wait // 60} 分钟到 {until} 再续…", flush=True)
else:
print(f"{why},重启进程接着跑…", flush=True)
sys.stdout.flush()
sys.stderr.flush()
if wait:
time.sleep(wait)
os.execv(sys.executable, [sys.executable] + sys.argv)
# 连撞计数走环境变量,execv 换掉进程映像后靠它记住上一轮的状态
streak = int(os.environ.get("_DY_ABORT_STREAK", "0"))
if abort.is_set():
# IP 级注册配额耗尽。抖音封的是 IP,换设备没用,只能等冷却。
wait = next_abort_wait(streak, args.abort_wait, args.abort_wait_cap)
if wait:
# 这一轮但凡出了活就说明限流是跑着跑着才触发的,下次从底价重等
os.environ["_DY_ABORT_STREAK"] = "0" if stats.done else str(streak + 1)
_restart("撞上 IP 级注册限流", wait)
print("撞上 IP 级注册限流,已停下。等几小时 IP 冷却后重跑本命令即可续传。")
return 5
os.environ["_DY_ABORT_STREAK"] = "0"
if more:
_restart("本段跑完")
return 0 if stats.failed == 0 else 4
if __name__ == "__main__":
raise SystemExit(main())

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
# 下载 / 接口调用(douyin/app.py、download.py、urls.py、pipeline.py)
requests>=2.32
# COS 上传(douyin/cos.py,延迟导入,不传 COS 就不需要)
cos-python-sdk-v5>=1.9.41
# xlsx 导出(jsonl_to_xlsx.py,延迟导入,不出 xlsx 就不需要)
openpyxl>=3.1
# 另需系统里有 ffmpeg / ffprobe(pipeline.py 抽音频、探时长用)
# 实测环境:Python 3.11.0 + ffmpeg 2025-07-31 gyan.dev full build

174
scripts/clean_ai.py Normal file
View File

@@ -0,0 +1,174 @@
"""从抖音链接 jsonl 里筛出 AI 相关的作品。
判定逻辑在 douyin/aiclass.py,分 platform / strong / topic / author 四档,
这里只负责流式读写、统计和落盘。
输出:
*.txt 纯链接一行一个,直接喂给 pipeline.py --from-file
*.jsonl 原记录 + ai_tier + ai_signals,字段全留着,方便复核和再切。
这份体积接近源文件(实测 13967 条就有 169MB),而且随时能重跑
生成,所以默认**不写**,要复核再加 --with-jsonl。
用法:
python clean_ai.py -i 抖音链接20260827_40087.jsonl --stats
python clean_ai.py -i 抖音链接20260827_40087.jsonl --tier platform,strong -o ai
python clean_ai.py -i 抖音链接20260827_40087.jsonl --tier platform,strong -o ai --with-jsonl
python clean_ai.py -i xxx.jsonl --tier platform,strong --exclude-forbidden -o ai
"""
from __future__ import annotations
# 移到 scripts/ tests/ 后,直接运行时脚本目录成了 sys.path[0],
# 把项目根加回来,import douyin / pipeline 才找得到。
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
import argparse
import json
import sys
from collections import Counter
from pathlib import Path
from douyin.aiclass import TIERS, classify
def iter_jsonl(path: Path):
with open(path, "r", encoding="utf-8", errors="replace") as f:
for n, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
yield n, json.loads(line)
except ValueError:
print(f"[跳过] 第 {n} 行不是合法 JSON", file=sys.stderr)
def main() -> int:
ap = argparse.ArgumentParser(description="筛出 AI 相关作品")
ap.add_argument("-i", "--input", required=True)
ap.add_argument("-o", "--out", default="", help="输出前缀,生成 <前缀>.jsonl 和 <前缀>.txt")
ap.add_argument("--tier", default="platform,strong",
help=f"保留哪些档,逗号分隔。可选 {'/'.join(TIERS)}")
ap.add_argument("--exclude-forbidden", action="store_true",
help="剔掉 allow_download=false 的(抖音对 AIGC 常给 aigc_forbidden)")
ap.add_argument("--with-jsonl", action="store_true",
help="连带写出 <前缀>.jsonl(体积接近源文件,默认不写)")
ap.add_argument("--platform-needs-text", action="store_true",
help="platform 档要求文案/标签也有 AI 信号才算。抖音给 AI 配音、"
"AI 封面的普通带货视频也打 AIGC 标,露营那批 87110 条里"
"有 1288 条是这种,占 platform 档的一半")
ap.add_argument("--stats", action="store_true", help="只统计不落盘")
ap.add_argument("--samples", type=int, default=0, help="每档打印几条样例到 stderr")
args = ap.parse_args()
keep = {t.strip() for t in args.tier.split(",") if t.strip()}
bad = keep - set(TIERS)
if bad:
ap.error(f"未知档位 {sorted(bad)},可选 {TIERS}")
src = Path(args.input)
if not src.exists():
ap.error(f"找不到 {src}")
tiers = Counter()
reasons = Counter()
sigs = Counter()
dl = Counter()
dur_ms = 0
kept = 0
seen = set()
dup = 0
samples = {t: [] for t in TIERS}
out_j = out_t = None
if not args.stats:
if not args.out:
ap.error("要落盘就给 -o 前缀,或者加 --stats 只看统计")
out_t = open(f"{args.out}.txt", "w", encoding="utf-8")
out_j = open(f"{args.out}.jsonl", "w", encoding="utf-8") if args.with_jsonl else None
total = 0
for _, r in iter_jsonl(src):
total += 1
aid = str(r.get("aweme_id") or "")
if aid in seen:
dup += 1
continue
seen.add(aid)
v = classify(r)
t = v["tier"]
if t == "platform" and args.platform_needs_text:
# 平台标了 AIGC,但作品自己一个 AI 信号都没有 -> 多半是 AI 配音/封面。
# 作者昵称带 AI(@可灵AI、@松饼大人Ai剧)也算佐证 —— 是本人在做 AI,
# 不像 music 那条路会被 `@X创作的原声` 和 studio/Fluxxwave 污染。
if not any(s.startswith(("desc:", "author:")) for s in v["signals"]):
t = "none"
reasons["platform 但无文案/作者佐证,被 --platform-needs-text 剔除"] += 1
tiers[t] += 1
if args.samples and len(samples[t]) < args.samples:
samples[t].append((aid, (r.get("desc") or "").replace("\n", " ")[:90],
"|".join(v["signals"][:5])))
if t == "none":
continue
for s in v["signals"]:
sigs[s.split(":", 1)[0] + ":" + s.split(":")[1] if ":" in s else s] += 1
if t not in keep:
continue
ai = r.get("ai") or {}
if not ai.get("allow_download", True):
dl[ai.get("download_fail_reason") or "unknown"] += 1
if args.exclude_forbidden:
reasons["被 allow_download=false 剔除"] += 1
continue
kept += 1
dur_ms += int(r.get("duration_ms") or 0)
if out_t:
out_t.write((r.get("url") or f"https://www.douyin.com/video/{aid}") + "\n")
if out_j:
rec = {"aweme_id": aid, "ai_tier": t, "ai_signals": v["signals"]}
rec.update({k: val for k, val in r.items() if k != "aweme_id"})
out_j.write(json.dumps(rec, ensure_ascii=False, separators=(",", ":")) + "\n")
if out_t:
out_t.close()
if out_j:
out_j.close()
w = sys.stderr
print(f"读入 {total} 条,去重后 {len(seen)} 条(重复 {dup})", file=w)
print("\n档位分布:", file=w)
for t in TIERS:
mark = " <- 保留" if t in keep else ""
print(f" {t:9s} {tiers[t]:7d}{mark}", file=w)
ai_any = sum(tiers[t] for t in TIERS if t != "none")
print(f" {'AI 相关合计':9s} {ai_any:7d} ({ai_any / max(total,1):.1%})", file=w)
if dl:
print("\n保留项里 allow_download=false 的原因:", file=w)
for k, v2 in dl.most_common():
print(f" {k:24s} {v2:7d}", file=w)
for k, v2 in reasons.items():
print(f" {k}: {v2}", file=w)
print(f"\n最终保留 {kept} 条,总时长 {dur_ms/3600000:.2f} 小时", file=w)
if out_t:
print(f"写出 {args.out}.txt" + (f" 和 {args.out}.jsonl" if out_j else ""), file=w)
if args.samples:
for t in TIERS:
if not samples[t]:
continue
print(f"\n----- {t} 样例 -----", file=w)
for aid, d, s in samples[t]:
print(f" {aid} {d}\n 信号: {s}", file=w)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,228 @@
#!/usr/bin/env python
"""把 records.jsonl 导成交付用的样本 xlsx。
列是甲方给的字段表(序号跟着他们的编号走,中间 4/5/9 他们没要):
1 样本 ID 必填 aweme_id
2 视频标题 必填 title,为空时退回 desc 首行
3 平台 必填 固定「抖音」
6 生成方式 AI 生成 / AI 辅助 / 疑似 AI / 未标注
7 生成工具 / 模型 从文案标签里认出来的工具名,逗号分隔
8 题材 / 内容类别 按标签归类
10 分辨率 / 清晰度 必填 1024x576(540p)
11 发布时间 必填 2026-08-06 14:12:47
12 是否有 AI 标识/水印 必填 抖音 risk_infos 打的标,原文照抄
用法:
python export_sample_xlsx.py # 全量
python export_sample_xlsx.py --ai-only # 只要判定为 AI 的
python export_sample_xlsx.py --limit 500 -o 样本.xlsx
"""
from __future__ import annotations
# 移到 scripts/ tests/ 后,直接运行时脚本目录成了 sys.path[0],
# 把项目根加回来,import douyin / pipeline 才找得到。
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
import argparse
import json
import re
import sys
from pathlib import Path
from douyin.aiclass import _fields, _scan, classify
# risk_infos.type 里属于 AIGC 的那几个码,别的都是跟 AI 无关的免责声明
# (45=虚构演绎、336/351=个人观点、54=来源于网络…)。见 README §4.5。
_AIGC_TYPES = {44, 47, 76, 328}
# 生成方式:抖音标签的措辞本身就分了轻重,直接用它,别自己发明
_HOW_BY_TYPE = {
44: "AI 生成(作者声明)",
76: "AI 生成(平台判定)",
328: "AI 生成(平台判定)",
47: "疑似 AI 生成(平台检测)",
}
# 题材归类。**顺序即优先级**,第一个命中的赢 —— 一条片子既是「漫剧」又带
# 「剧情」很常见,先匹配到的那个更具体。
#
# 词要挑「只在这个题材里出现」的。踩过的坑:「古风/国风/画质」放进
# 写真/摄影,结果把一整个仙侠短片系列(169/300 条)全吸过去了 ——
# 那些是叙事片,只是标签带古风。宽词一律不要,宁可落进「其他」。
_GENRES = [
("漫剧 / 动画", "漫剧|动漫|动画|二次元|国漫|番剧|漫画"),
("短剧 / 剧情", "短剧|剧情|微短剧|电影|影视|短片|反转|结局|"
"第[一二三四五六七八九十\\d]+集|合集"),
("教程 / 科普", "教程|教学|技巧|入门|保姆级|科普|干货|工作流|提示词"),
("资讯 / 新闻", "新闻|资讯|报道|快讯|媒体精选|发布会|财经|股市"),
("带货 / 电商", "好物|同款|下单|优惠|源头工厂|旗舰店|带货|种草|清仓"),
("户外 / 露营", "露营|帐篷|户外|野营|钓鱼|自驾|徒步|登山"),
("音乐 / 舞蹈", "音乐|歌曲|翻唱|舞蹈|跳舞"),
("写真 / 摄影", "写真|摄影|人像|壁纸|头像"),
("游戏", "手游|端游|原神|王者荣耀|电竞|无畏契约|崩坏"),
("生活 / 日常", "日常|vlog|美食|旅行|萌宠|搞笑"),
]
_GENRE_RE = [(name, re.compile(pat, re.I)) for name, pat in _GENRES]
def _risk(rec: dict) -> tuple:
"""(type, content)。抖音把 AI 标识放在 raw.risk_infos 里。"""
ri = (rec.get("raw") or {}).get("risk_infos") or {}
if not isinstance(ri, dict):
return None, ""
return ri.get("type"), (ri.get("content") or "").strip()
def how_generated(rec: dict, verdict: dict) -> str:
t, _ = _risk(rec)
if t in _HOW_BY_TYPE:
return _HOW_BY_TYPE[t]
if verdict["tier"] == "strong":
return "AI 生成(作品自述)"
if verdict["tier"] in ("topic", "author"):
return "疑似 AI 相关"
return "未标注"
def tools_used(verdict: dict) -> str:
"""从判定信号里把工具名摘出来。信号形如 `desc:工具:即梦`。"""
names = []
for s in verdict["signals"]:
parts = s.split(":")
if len(parts) >= 3 and parts[1] in ("工具", "漫剧"):
n = parts[2]
if n not in names:
names.append(n)
return "、".join(names)
def genre(rec: dict) -> str:
text = " ".join([rec.get("title") or "", rec.get("desc") or ""]
+ list(rec.get("tags") or []))
for name, rx in _GENRE_RE:
if rx.search(text):
return name
return "其他"
def resolution(rec: dict) -> str:
v = rec.get("video") or {}
w, h = v.get("width"), v.get("height")
ratio = ((rec.get("raw") or {}).get("video") or {}).get("ratio") or ""
if w and h:
return f"{w}x{h}" + (f"({ratio})" if ratio else "")
return ratio or ""
def ai_mark(rec: dict) -> str:
"""是否有 AI 标识/水印 —— 抖音的原文照抄,没有就写「无」。
只认 _AIGC_TYPES;45/336/351 那些是虚构演绎、个人观点,跟 AI 无关。"""
t, content = _risk(rec)
if t in _AIGC_TYPES and content:
return f"有:{content}"
return "无"
def title_of(rec: dict) -> str:
"""标题 + 标签拼一格。
records.jsonl 里的 title 是 split_desc 把标签摘掉之后的正文,标签单独放
在 tags 里。甲方只要一列,所以拼回去 —— 顺序按原文案的习惯:正文在前,
`#标签` 跟在后面。
"""
t = (rec.get("title") or "").strip()
if not t:
d = (rec.get("desc") or "").strip().splitlines()
t = d[0][:120] if d else ""
tags = " ".join(f"#{x}" for x in (rec.get("tags") or []) if x)
return (t + " " + tags).strip() if tags else t
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="records.jsonl -> 交付样本 xlsx")
ap.add_argument("-i", "--input", default="records/records.jsonl")
ap.add_argument("-o", "--out", default="exports/AI视频样本.xlsx")
ap.add_argument("--ai-only", action="store_true",
help="只导出判定为 AI 的(platform/strong 档)")
ap.add_argument("--limit", type=int, default=0)
args = ap.parse_args(argv)
try:
from openpyxl import Workbook
except ImportError:
print("需要 openpyxl:pip install openpyxl", file=sys.stderr)
return 2
src = Path(args.input)
if not src.exists():
ap.error(f"找不到 {src}")
# write_only:11 万行常规模式会把内存吃光
wb = Workbook(write_only=True)
ws = wb.create_sheet("样本")
# 前 9 列是甲方字段表要的,后面几列是核对/取数用的:题材判得对不对,
# 光看标题看不出来,得有标签和链接才能点开验;COS 路径是拿原始文件的地址。
# 作者插在「平台」后面 —— 甲方编号 1/2/3 的三列保持原位原序,
# 作者正好落进他们跳过的 4/5 那两个空号里。
ws.append(["样本 ID", "视频标题", "平台", "作者",
"生成方式", "生成工具 / 模型",
"题材 / 内容类别", "分辨率 / 清晰度", "发布时间",
"是否有 AI 标识 / 水印",
"时长(秒)", "视频链接",
"COS 视频路径", "COS 音频路径"])
n = written = bad = 0
with open(src, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
n += 1
try:
rec = json.loads(line)
except ValueError:
bad += 1
continue
v = classify(rec)
if args.ai_only and v["tier"] not in ("platform", "strong"):
continue
# 每格都过一遍 str():openpyxl 会把 None 原样写成 "None"
ws.append([
str(rec.get("aweme_id") or ""),
title_of(rec),
"抖音",
(rec.get("author") or {}).get("nickname") or "",
# 生成方式 / 生成工具:留空,人工填。
# 自动推的那版(how_generated / tools_used)只能看抖音的标和
# 文案里写没写工具名,作者不写就抓不到 —— 实测 44.8% 的工具
# 列是空的,而"生成方式"里 81.8% 全都压在"作者声明"一个值上,
# 区分度太低,不如空着让人填。两个函数留着,要用加回来即可。
"",
"",
genre(rec),
resolution(rec),
rec.get("create_time_str") or "",
ai_mark(rec),
round(float(rec.get("duration_sec") or 0), 1),
rec.get("source_url")
or f"https://www.douyin.com/video/{rec.get('aweme_id')}",
rec.get("cos_mp4") or "",
rec.get("cos_mp3") or "",
])
written += 1
if args.limit and written >= args.limit:
break
if written % 20000 == 0:
print(f" 已写 {written} 行…", flush=True)
wb.save(args.out)
print(f"读入 {n} 行(坏行 {bad}),写出 {written} 行 -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

222
scripts/jsonl_to_xlsx.py Normal file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""把 jsonl 清洗并导出为 xlsx。
同时认两种输入:
records.jsonl 流水线产物,字段全(标题/作者/标签/时长/COS 地址/raw)
manifest.jsonl 老格式,只有 cos_mp4 / cos_mp3 / duration
records.jsonl 内嵌了 raw,单行可能几十 KB、整个文件几百 MB,所以这里
一律逐行流式读,任何时候都不把整个文件读进内存。选中的列也只留需要的
那几个字段,raw 不会进内存驻留。
清洗动作(每一项都会报告剔除了多少):
1. 丢弃无法解析的行(被 Ctrl-C 打断可能留下半行)
2. 丢弃缺字段 / 字段类型不对的行
3. 丢弃时长非正数的行
4. 丢弃 URL 前缀不符合预期的行
5. 按 mp4 地址去重,保留首次出现
用法:
python jsonl_to_xlsx.py
python jsonl_to_xlsx.py -i records.jsonl -o "抖音-四川话.xlsx"
python jsonl_to_xlsx.py -i records.jsonl --columns cos_mp4,cos_mp3,duration_sec
python jsonl_to_xlsx.py --list-columns
"""
from __future__ import annotations
# 移到 scripts/ tests/ 后,直接运行时脚本目录成了 sys.path[0],
# 把项目根加回来,import douyin / pipeline 才找得到。
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, Iterator, List
EXPECTED_PREFIX = "cos://asr-dig-data-1253181912/0005/zh/"
# 列名 -> (表头, 取值函数)。取值函数只碰需要的字段,raw 不会被带进来。
COLUMNS: Dict[str, tuple] = {
"aweme_id": ("视频ID", lambda r: str(r.get("aweme_id") or "")),
"title": ("标题", lambda r: r.get("title") or ""),
"desc": ("原始描述", lambda r: r.get("desc") or ""),
"tags": ("标签", lambda r: " ".join(r.get("tags") or [])),
"author": ("作者", lambda r: (r.get("author") or {}).get("nickname", "")),
"author_uid": ("作者UID", lambda r: (r.get("author") or {}).get("uid", "")),
"create_time": ("发布时间", lambda r: r.get("create_time_str") or ""),
"duration_sec": ("时长(秒)", _dur := (lambda r: r.get("duration_sec",
r.get("duration")))),
"duration": ("时长(秒)", _dur),
"cos_mp4": ("cosmp4地址", lambda r: r.get("cos_mp4") or ""),
"cos_mp3": ("cosmp3地址", lambda r: r.get("cos_mp3") or ""),
"size_mp4": ("mp4字节数", lambda r: r.get("size_mp4") or 0),
"size_mp3": ("mp3字节数", lambda r: r.get("size_mp3") or 0),
"play": ("播放数", lambda r: (r.get("stats") or {}).get("play", 0)),
"digg": ("点赞数", lambda r: (r.get("stats") or {}).get("digg", 0)),
"comment": ("评论数", lambda r: (r.get("stats") or {}).get("comment", 0)),
"source_url": ("原始链接", lambda r: r.get("source_url") or ""),
}
DEFAULT_COLUMNS = ["cos_mp4", "cos_mp3", "duration_sec"]
FULL_COLUMNS = ["aweme_id", "title", "author", "create_time", "tags",
"duration_sec", "cos_mp4", "cos_mp3"]
NUMERIC = {"duration_sec", "duration", "size_mp4", "size_mp3",
"play", "digg", "comment"}
def iter_lines(path: Path) -> Iterator[str]:
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if line:
yield line
def clean(path: Path, prefix: str, columns: List[str]):
"""流式清洗,只保留选中的列。"""
rows: List[List[Any]] = []
seen: set = set()
dropped = {"解析失败": 0, "字段缺失": 0, "时长非法": 0, "地址异常": 0, "重复": 0}
total = 0
dur_idx = next((i for i, c in enumerate(columns)
if c in ("duration_sec", "duration")), None)
for line in iter_lines(path):
total += 1
try:
r = json.loads(line)
except ValueError:
dropped["解析失败"] += 1
continue
mp4, mp3 = r.get("cos_mp4"), r.get("cos_mp3")
dur = r.get("duration_sec", r.get("duration"))
if not isinstance(mp4, str) or not isinstance(mp3, str) or dur is None:
dropped["字段缺失"] += 1
continue
if not isinstance(dur, (int, float)) or isinstance(dur, bool) or dur <= 0:
dropped["时长非法"] += 1
continue
if not mp4.startswith(prefix) or not mp3.startswith(prefix):
dropped["地址异常"] += 1
continue
if mp4 in seen:
dropped["重复"] += 1
continue
seen.add(mp4)
row = [COLUMNS[c][1](r) for c in columns]
if dur_idx is not None:
row[dur_idx] = float(row[dur_idx])
rows.append(row)
return rows, dropped, total, dur_idx
def write_xlsx(rows, headers, out: Path, sheet: str, dur_idx) -> None:
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
wb = Workbook()
ws = wb.active
ws.title = sheet
ws.append(headers)
head_font = Font(bold=True, color="FFFFFF")
head_fill = PatternFill("solid", fgColor="4472C4")
for c in range(1, len(headers) + 1):
cell = ws.cell(row=1, column=c)
cell.font = head_font
cell.fill = head_fill
cell.alignment = Alignment(horizontal="center", vertical="center")
for r in rows:
ws.append(r)
# 时长保留 3 位小数,与 ffprobe 输出精度一致
if dur_idx is not None:
for row in ws.iter_rows(min_row=2, min_col=dur_idx + 1, max_col=dur_idx + 1):
for cell in row:
cell.number_format = "0.000"
for i, head in enumerate(headers):
widest = max((len(str(r[i])) for r in rows), default=10)
width = min(max(len(head) + 4, widest + 2), 60)
ws.column_dimensions[get_column_letter(i + 1)].width = width
ws.freeze_panes = "A2" # 表头固定
ws.auto_filter.ref = ws.dimensions # 开筛选
wb.save(out)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="jsonl -> xlsx")
ap.add_argument("-i", "--input", default="records/records.jsonl")
ap.add_argument("-o", "--output", default="exports/抖音-四川话.xlsx")
ap.add_argument("--sheet", default="抖音-四川话")
ap.add_argument("--prefix", default=EXPECTED_PREFIX)
ap.add_argument("--columns", default="",
help=f"逗号分隔的列,默认 {','.join(DEFAULT_COLUMNS)};"
"用 full 展开为常用全字段")
ap.add_argument("--list-columns", action="store_true", help="列出可用列名后退出")
args = ap.parse_args(argv)
if args.list_columns:
print("可用列:")
for k, (head, _) in COLUMNS.items():
print(f" {k:<14} {head}")
return 0
if not args.columns:
columns = list(DEFAULT_COLUMNS)
elif args.columns.strip() == "full":
columns = list(FULL_COLUMNS)
else:
columns = [c.strip() for c in args.columns.split(",") if c.strip()]
bad = [c for c in columns if c not in COLUMNS]
if bad:
print(f"[错误] 未知列名 {bad},用 --list-columns 看可选项", file=sys.stderr)
return 2
src = Path(args.input)
if not src.exists():
print(f"[错误] 找不到 {src}", file=sys.stderr)
return 2
rows, dropped, total, dur_idx = clean(src, args.prefix, columns)
print(f"读入 {total} 行")
if sum(dropped.values()):
for k, v in dropped.items():
if v:
print(f" 剔除 {k}: {v}")
else:
print(" 清洗检查全部通过,无需剔除")
print(f"保留 {len(rows)} 行")
if not rows:
print("[错误] 清洗后没有数据", file=sys.stderr)
return 3
headers = [COLUMNS[c][0] for c in columns]
out = Path(args.output)
write_xlsx(rows, headers, out, args.sheet, dur_idx)
print(f"\n已写入 {out}")
print(f" {len(rows)} 行 × {len(headers)} 列:{' | '.join(headers)}")
if dur_idx is not None:
ds = [r[dur_idx] for r in rows]
print(f" 总时长 {sum(ds) / 3600:.2f} 小时,平均 {sum(ds) / len(ds):.1f} 秒,"
f"最短 {min(ds):.3f},最长 {max(ds):.3f}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

276
tests/test_aiclass.py Normal file
View File

@@ -0,0 +1,276 @@
"""douyin.aiclass 的离线测试。用例全部取自 40087 条真实数据里的真实文案。"""
from __future__ import annotations
# 移到 scripts/ tests/ 后,直接运行时脚本目录成了 sys.path[0],
# 把项目根加回来,import douyin / pipeline 才找得到。
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
from douyin.aiclass import classify
def rec(desc="", tags=(), author="", music="", is_aigc=False, label=""):
return {
"desc": desc,
"hashtags": [{"name": t} for t in tags],
"author": {"nickname": author},
"music": {"title": music},
"ai": {"is_aigc": is_aigc, "label": label},
}
def tier(**kw):
return classify(rec(**kw))["tier"]
# ---------- platform:唯一能抓到"作者没标但确实是 AI"的信号 ----------
def test_platform_label_wins_even_with_no_ai_text():
"""实测 1744 条:平台标了 AIGC,文案标签里一个 ai 字都没有。"""
assert tier(desc="#趣味脑筋急转弯 #创作者中心 #创作灵感",
tags=["趣味脑筋急转弯", "创作者中心", "创作灵感"],
author="申哥爱剪辑", is_aigc=True,
label="作者声明:内容由 AI 生成") == "platform"
assert tier(desc="澳洲的野狗 #中华田园犬 #历史", tags=["中华田园犬", "历史"],
author="胖嘟嘟历史", is_aigc=True) == "platform"
# ---------- strong:作品自己声明是 AI 做的 ----------
def test_strong_ai_compound():
assert tier(desc="AI花絮:我很快就会驯服AI演员啦 #AI短剧#AI电影",
tags=["AI短剧", "AI电影"]) == "strong"
def test_strong_tool_name_without_ai_letters():
"""即梦 / seedance / libtv 这些名字里没有 ai,只能靠名单。"""
assert tier(desc="干啥呀即梦,这是掀桌子不玩了吗?", tags=["即梦"]) == "strong"
assert tier(desc="", tags=["seedance2.0"]) == "strong"
assert tier(desc="本视频由libtv制作生成", tags=["Libtv"]) == "strong"
assert tier(desc="", tags=["runninghub"]) == "strong"
def test_strong_aigc_token():
assert tier(desc="自制AIGC《银骸》第0集!") == "strong"
def test_strong_handles_capital_A_lowercase_L():
"""`抖音al` 在这批数据里出现 129 次,`#Al创作` 也有。不归一化就整片漏掉。"""
assert tier(desc="他种下几千棵橡树 #Al创作 #爱情", tags=["Al创作", "爱情"]) == "strong"
assert tier(desc="", tags=["抖音al"]) == "topic" # 没有创作语境词,降到 topic
# ---------- topic:在讲 AI,不一定是 AI 做的 ----------
def test_topic_pure_ai_mention():
assert tier(desc="布布讲ai之如何写提示词 #ai", tags=["ai"]) == "strong" # 提示词是创作语境
assert tier(desc="今天聊聊 AI 会不会取代人类 #ai", tags=["ai"]) == "topic"
def test_topic_chat_models():
assert tier(desc="deepseek 又更新了") == "topic"
assert tier(desc="用 ChatGPT 写周报") == "topic"
assert tier(desc="#人工智能 的一点思考", tags=["人工智能"]) == "topic"
# ---------- author:最弱,作品本身没信号 ----------
def test_author_only():
r = classify(rec(desc="研究生偷吃外卖被毒S #湘潭大学 #普法教育",
tags=["湘潭大学", "普法教育"], author="牛牛Ai"))
assert r["tier"] == "author", r
def test_author_never_upgrades_a_scored_item():
"""作者昵称只能给 none 兜底,不能把 topic 顶成 strong。"""
r = classify(rec(desc="聊聊 AI 的伦理问题", author="星幕AIGC"))
assert r["tier"] == "topic", r
def test_default_original_sound_is_not_a_work_signal():
"""音乐标题默认是 `@<昵称>创作的原声`,40087 条里有 32555 条是这个壳。
昵称带 AI 会连"创作"一起命中,曾让 771 条从 music 这条路混进 strong
(@冠宇Aigc 的猴子炒菜、@牛牛Ai 的大案纪实)。只能落到 author 档。"""
r = classify(rec(desc="#猴子炒菜#猴子成精#原创视频",
tags=["猴子炒菜", "猴子成精"],
author="冠宇Aigc(AI剧场)",
music="@冠宇Aigc(AI剧场)创作的原声"))
assert r["tier"] == "author", r
# 作者昵称也不带 AI 时,默认原声不能凭空造出信号
assert tier(desc="今天天气不错", author="老王",
music="@老王创作的原声") == "none"
def test_real_track_name_still_counts_but_only_weakly():
assert tier(desc="随便发发", music="即梦AI生成的配乐") == "author"
# ---------- 误报:这些绝不能收 ----------
def test_english_words_containing_ai_are_not_signals():
for d in ["email me for detail", "again and again", "hair salon",
"wait a minute", "chairman", "trained model"]:
assert tier(desc=d) == "none", d
def test_editing_jargon_is_not_ai():
"""剪映 375 / 关键帧 314 / 蒙版 203 —— 剪辑术语,不是 AI。"""
assert tier(desc="导入一张照片即可制作 #剪映", tags=["剪映"]) == "none"
assert tier(desc="#关键帧 #蒙版 #转场 #剪辑教程",
tags=["关键帧", "蒙版", "转场", "剪辑教程"]) == "none"
def test_generic_chinese_words_alone_are_not_ai():
"""动画/绘画/分身/动漫 单独出现不算 —— 否则 830 条 #原创动画 全被误收。"""
assert tier(desc="第33 #原创动画", tags=["原创动画"]) == "none"
assert tier(desc="跟小同桌回老家 #二次元 #原创动画 #恋爱番",
tags=["二次元", "原创动画", "恋爱番"]) == "none"
assert tier(desc="他的分身术真牛") == "none"
def test_ordinary_content_is_none():
assert tier(desc="户外露营过夜用的这款帐篷也太实用啦! #露营装备",
tags=["露营装备"], author="蚁巢户外直营店") == "none"
assert tier(desc="4月16日,重庆,张雪陪同合作伙伴参观工厂 #张雪机车",
tags=["张雪机车"], author="第一现场") == "none"
def test_signals_are_recorded_for_audit():
r = classify(rec(desc="#即梦AI #AI短剧", tags=["即梦ai", "ai短剧"]))
assert r["tier"] == "strong"
assert any("工具:即梦" in s for s in r["signals"]), r["signals"]
def test_empty_record():
assert classify(rec())["tier"] == "none"
# ---------- 常用词工具名:必须跟 ai 同现,见 aiclass 模块开头第 6 条 ----------
def test_ambiguous_tool_alone_is_not_ai():
"""87110 条里实测的误判,全部取自真实文案。"""
# 三峰的"海螺"帐篷,撞上 MiniMax 的海螺 AI
assert tier(desc="三峰出-海螺。重量超级轻的一款帐篷,有tpu,有烟囱口。",
tags=["户外装备", "帐篷", "露营"]) == "none"
# 元宝勺,撞上腾讯元宝
assert tier(desc="边缘光滑才是好勺子!316不锈钢元宝勺。",
tags=["辅食勺", "勺子", "元宝勺"]) == "none"
# 豆包是只狗 / 一种包子,不是那个大模型
assert tier(desc="豆包监督学生自习这一块 豆包:交给我你就闹心吧!",
tags=["豆包", "搞笑", "晚自习"]) == "none"
# 《龙神万相》,撞上阿里万相
assert tier(desc="龙神万相之龙魂定档 迫不及待想沉浸式感受龙族的热血与传奇了!",
tags=["龙神万相之龙魂", "西行纪"]) == "none"
# "明星流量"里含"星流";"起舞弄清影"里含"清影"
assert tier(desc="商家别乱蹭明星流量!4 名被告擅自使用张雨绮肖像及姓名商用",
tags=["张雨绮", "侵权"]) == "none"
assert tier(desc="柳下闻瑶琴起舞弄清影 ~好久没跳了") == "none"
assert tier(desc="做白日梦从拥有弹丝云床开始",
tags=["户外露营", "自动充气床"]) == "none"
def test_ambiguous_tool_counts_when_ai_present():
"""真命中的无一例外都同时带 AI 字样,所以这道门槛不掉召回。"""
r = classify(rec(desc="海螺AI生成的短片 #ai视频", tags=["ai视频"]))
assert r["tier"] == "strong"
assert any("工具:海螺" in s for s in r["signals"]), r["signals"]
assert tier(desc="用豆包写的脚本,AI 生成 #ai", tags=["ai"]) == "strong"
def test_digital_rmb_is_not_a_digital_human():
"""数字人民币含"数字人",微信分身含"分身",穿搭也说"换脸"。
实测放开这三个词只进误判,一条真的都捞不到。"""
assert tier(desc="一地宣布:5月起,公务员工资用数字人民币发放",
tags=["公务员", "工资", "数字人民币"]) == "none"
assert tier(desc="抖音双开 小米华为苹果手机 微信怎么多开",
tags=["微信分身双开", "抖音多开分身"]) == "none"
assert tier(desc="救命!这套穿搭真的能换脸!",
tags=["今天穿什么", "冬季穿搭"]) == "none"
def test_sound_wrapper_csv_form_is_stripped():
"""csv 导出的原声标题多一截 ` - 昵称`,也得剥掉,
否则作者昵称又会从 music 这条路偷偷升档。"""
assert tier(desc="今天去钓鱼", music="@牛牛Ai创作的原声 - 牛牛Ai") == "none"
assert tier(desc="今天去钓鱼", music="@牛牛Ai创作的原声") == "none"
def test_minimax_is_a_tool():
assert tier(desc="minimax 生成的片子", tags=["minimax"]) == "strong"
def test_roman_numeral_impersonates_ai():
"""`AⅠ漫剧` 用罗马数字 Ⅰ(U+2160) 冒充字母 I,跟 `抖音al` 同一类花招。"""
r = classify(rec(desc="AⅠ漫剧你就这样崛起吧!", tags=["AⅠ漫剧", "漫剧推荐"]))
assert r["tier"] == "strong", r
def test_voice_cloning_is_generative():
assert tier(desc="克隆音色怎么弄 声音可以克隆吗",
tags=["克隆音色", "声音克隆", "剪映教程"]) == "strong"
def test_veo3_beats_a_weak_author_signal():
"""原先这批只靠作者昵称算 author 档,认出 veo3 后该升到 strong。"""
assert tier(desc="#猴子成精 #猴子炒菜 #veo3", tags=["猴子炒菜", "veo3"],
author="冠宇Aigc") == "strong"
def test_rejected_candidates_stay_none():
"""这些词看着像 AI,实测全是子串陷阱,加进词表会污染结果。"""
assert tier(desc="全国生猪价格已连续16周下跌,行业亏损幅度加大") == "none" # 度加
assert tier(desc="女生相亲要求要30万彩礼,听到要求男士都拒绝") == "none" # 万彩
assert tier(desc="中国男篮硬刚美国梦八队,诞生数副名场面!") == "none" # 生数
assert tier(desc="决定全年经济走势关键节点上,国务院召开重要会议") == "none" # 节点
assert tier(desc="卡池都快结束了,肝了个大保底怎么还不出货",
tags=["抽卡日常", "崩坏星穹铁道"]) == "none" # 抽卡
assert tier(desc="圈外人不语,只是一味求捏脸", tags=["燕云十六声捏脸"]) == "none" # 捏脸
assert tier(desc="杀神降临!82-36!全力帕到底有多恐怖?",
tags=["无畏契约", "valorant"]) == "none" # lora
assert tier(desc="剑来!!!", tags=["国产动漫", "国漫超燃", "剑来动画"]) == "none" # 国漫
def test_manju_names_stand_alone():
"""漫剧专名单独成立,不要求跟 ai 同现(用户要求:出现关键词就算)。"""
r = classify(rec(desc="究竟是什么样的小说仅是漫改,就能瞬间火爆全网破30亿的热度",
tags=["万妖图录传", "二次元", "充能计划"]))
assert r["tier"] == "strong", r
assert any("漫剧:万妖图录传" in x for x in r["signals"]), r["signals"]
assert tier(desc="晚夏小梦1-27:128分钟大合集看爽",
tags=["二次元", "原创动画", "晚夏小梦"]) == "strong"
assert tier(desc="小溪废片合集~", tags=["二次元", "月落小溪"]) == "strong"
def test_hongguo_platform_name_is_not_an_ai_signal():
"""红果主体是真人短剧,AI 漫剧只是其中一个赛道,平台名不能当信号。"""
assert tier(desc="把生活的爽感拉满!看《家里家外2》要备好纸巾",
tags=["红果短剧", "短剧推荐", "家里家外2", "治愈系短剧"]) == "none"
assert tier(desc="《冷御失守》后续", tags=["红果短剧"]) == "none"
def test_manju_topic_words_are_not_ai_markers():
"""漫剧圈里 100% 是 AI 的题材词,放全库就炸 —— 它们是题材不是 AI 标记。"""
assert tier(desc="玄奘故里,也就是西游记里唐僧的出生地,位于偃师缑氏镇") == "none"
assert tier(desc="宫崎骏的夏天大概就是这个样子吧!", tags=["宫崎骏的夏天", "龙猫"]) == "none"
assert tier(desc="《狼来了》第一集:还记得小时候听的《狼来了》吗?",
tags=["狼来了", "民间故事", "原创"]) == "none"
def main() -> int:
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
failed = 0
for t in tests:
try:
t()
print(f" ok {t.__name__}")
except AssertionError as exc:
failed += 1
print(f" FAIL {t.__name__}: {exc}")
print(f"\n{len(tests) - failed}/{len(tests)} 通过")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())

217
tests/test_meta.py Normal file
View File

@@ -0,0 +1,217 @@
"""douyin.meta 的离线测试:标题/标签拆分、记录追加与幂等。不联网。"""
from __future__ import annotations
# 移到 scripts/ tests/ 后,直接运行时脚本目录成了 sys.path[0],
# 把项目根加回来,import douyin / pipeline 才找得到。
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
import json
import tempfile
from pathlib import Path
from douyin.meta import RecordLog, split_desc
def te(desc, *names):
"""按真实位置构造 text_extra —— 偏移量算出来,别手数。"""
out = []
for n in names:
i = desc.index("#" + n)
out.append({"type": 1, "hashtag_name": n, "hashtag_id": "1",
"start": i, "end": i + len(n) + 1})
return out
def te_broken(*names):
"""偏移量是垃圾(越界)的 text_extra。"""
return [{"type": 1, "hashtag_name": n, "hashtag_id": "1",
"start": 999, "end": 1200} for n in names]
def test_split_basic():
desc = "是谁教他这样平账的??? #沙雕动画 #原创动画"
title, tags = split_desc(desc, te(desc, "沙雕动画", "原创动画"))
assert title == "是谁教他这样平账的???", repr(title)
assert tags == ["沙雕动画", "原创动画"], tags
def test_split_adjacent_no_space():
# 实测抖音有 "#四川话#方言" 这种连着写的
title, tags = split_desc("老板说话#四川话#方言", None)
assert title == "老板说话", repr(title)
assert tags == ["四川话", "方言"], tags
def test_split_tag_missing_from_text_extra():
"""text_extra 会漏标签(实测 12 条里有 2 条),tags 必须以 desc 为准。"""
desc = "太搞笑了 #搞笑配音 #专治不开心 #非洲搞笑视频"
title, tags = split_desc(desc, te(desc, "搞笑配音", "专治不开心"))
assert tags == ["搞笑配音", "专治不开心", "非洲搞笑视频"], tags
assert title == "太搞笑了", repr(title)
def test_split_tag_only_in_text_extra():
"""反过来,desc 里没写但 text_extra 有的也不能丢。"""
_, tags = split_desc("光秃秃的标题", te_broken("隐藏标签"))
assert tags == ["隐藏标签"], tags
def test_split_tag_in_middle():
title, tags = split_desc("前面 #中间 后面", None)
assert title == "前面 后面", repr(title)
assert tags == ["中间"], tags
def test_split_no_tags():
title, tags = split_desc(" 纯标题没有标签 ", None)
assert title == "纯标题没有标签", repr(title)
assert tags == [], tags
def test_split_empty():
assert split_desc("", None) == ("", [])
assert split_desc(None, None) == ("", [])
def test_split_dedupes_tags():
_, tags = split_desc("标题 #方言 #方言 #四川话", None)
assert tags == ["方言", "四川话"], tags
def test_split_bad_offsets_dont_corrupt():
"""偏移量对不上时必须退回正则,绝不能把标题切坏。"""
title, tags = split_desc("标题正文 #标签", te_broken("标签"))
assert title == "标题正文", repr(title)
assert tags == ["标签"], tags
def test_split_keeps_hash_inside_word():
"""偏移量可信时不该误伤正文里的 #(如 C# 教程)——这正是用偏移的理由。"""
desc = "C# 入门教程 #编程"
title, tags = split_desc(desc, te(desc, "编程"))
assert title == "C# 入门教程", repr(title)
assert tags == ["编程"], tags
def test_split_lone_hash_is_not_a_tag():
"""# 后面必须跟非空白才算标签,所以 "C# 教程" 里的 # 不会被当标签。"""
title, tags = split_desc("C# 入门教程 #编程", None)
assert tags == ["编程"], tags
assert title == "C# 入门教程", repr(title)
def test_split_repeated_tagname_in_body_is_lossy():
"""没有可信偏移、且标签名以 #名字 的形式也出现在正文里时,兜底会连正文
一起删。这是正则兜底的已知代价,钉住它免得以后误以为无损。"""
title, tags = split_desc("聊聊#教程 这件事 #教程", None)
assert tags == ["教程"], tags
assert title == "聊聊 这件事", repr(title) # 正文里那个 #教程 也没了
# 有可信偏移时就不会误伤——只挖服务端标注的那一处
desc = "聊聊#教程 这件事 #教程"
i = desc.rindex("#教程")
ok = [{"type": 1, "hashtag_name": "教程", "start": i, "end": i + 3}]
title2, _ = split_desc(desc, ok)
assert title2 == "聊聊#教程 这件事", repr(title2)
def test_split_case_insensitive_dedupe():
"""抖音把 text_extra 里的标签归一化成小写,desc 里却是原始大小写。
并集必须按大小写不敏感去重,否则同一个标签会存两份(实测踩到)。"""
desc = "教学 #一站式AI创作工作台 #国产AI"
te = [{"type": 1, "hashtag_name": "一站式ai创作工作台", "start": -1, "end": -1},
{"type": 1, "hashtag_name": "国产ai", "start": -1, "end": -1}]
title, tags = split_desc(desc, te)
# 保留 desc 里的原始大小写,不是 text_extra 的小写版
assert tags == ["一站式AI创作工作台", "国产AI"], tags
assert title == "教学", repr(title)
def test_split_drops_at_mention_pseudo_tag():
"""正文里手打的 # 会被正则捕到,但带 @ 的明显是提及不是标签。
实测:desc 里的 "#(提示词出自@青木",抖音自己也没把它当标签。"""
desc = "示例#正经标签 #(提示词出自@青木 文本#另一个"
title, tags = split_desc(desc, None)
assert tags == ["正经标签", "另一个"], tags
# 它不进 tags,但仍然从标题里剥掉——作者本来就是当标签打的
assert title == "示例 文本", repr(title)
def test_split_strips_edge_punctuation():
title, tags = split_desc("标题 #(括号标签) #正常", None)
assert tags == ["括号标签", "正常"], tags
# 标题里那对括号也得跟着标签一起剥掉——剥标题用的是 desc 原文,
# 不是清洗过的标签名,否则会匹配不上而留在标题里
assert title == "标题", repr(title)
def test_recordlog_roundtrip_and_resume():
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "records.jsonl"
log = RecordLog(p)
assert len(log) == 0
assert log.write({"aweme_id": "111", "title": "甲", "raw": {"x": 1}}) is True
assert log.write({"aweme_id": "222", "title": "乙", "raw": {"x": 2}}) is True
assert "111" in log and "222" in log and "333" not in log
# 重开:应该扫出已有的两条
log2 = RecordLog(p)
assert len(log2) == 2, len(log2)
assert "111" in log2 and "222" in log2
# 重复写入被幂等挡掉
assert log2.write({"aweme_id": "111", "title": "改过的"}) is False
assert len(p.read_text(encoding="utf-8").strip().splitlines()) == 2
# aweme_id 必须是每行第一个字段(续跑扫描的契约)
for line in p.read_text(encoding="utf-8").splitlines():
assert line.startswith('{"aweme_id":'), line[:40]
rows = [json.loads(x) for x in p.read_text(encoding="utf-8").splitlines()]
assert rows[0]["title"] == "甲" and rows[0]["raw"] == {"x": 1}
def test_recordlog_survives_broken_tail():
"""进程被 Ctrl-C 打断可能留下半行,扫描不能因此崩掉。"""
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "records.jsonl"
p.write_text('{"aweme_id":"111","title":"完整"}\n{"aweme_id":"222","tit',
encoding="utf-8")
log = RecordLog(p)
assert "111" in log and "222" in log # 半行的 id 也认,照样跳过
log.write({"aweme_id": "333", "title": "新的"})
lines = p.read_text(encoding="utf-8").splitlines()
# 补写前必须先断行,不能跟半行黏成一行
assert lines[-1].startswith('{"aweme_id":"333"'), lines[-1][:40]
assert json.loads(lines[-1])["title"] == "新的"
def test_recordlog_no_ascii_escaping():
"""中文必须原样存,不能转成 \\uXXXX——否则文件体积翻几倍且不可读。"""
with tempfile.TemporaryDirectory() as d:
p = Path(d) / "r.jsonl"
RecordLog(p).write({"aweme_id": "1", "title": "四川方言"})
assert "四川方言" in p.read_text(encoding="utf-8")
def main() -> int:
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
failed = 0
for t in tests:
try:
t()
print(f" ok {t.__name__}")
except AssertionError as exc:
failed += 1
print(f" FAIL {t.__name__}: {exc}")
print(f"\n{len(tests) - failed}/{len(tests)} 通过")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())

335
tests/test_pipeline.py Normal file
View File

@@ -0,0 +1,335 @@
"""pipeline.run_one 的端到端测试。不联网、不碰 COS。
网络(fetch_detail / download)和 COS(CosUploader)都打桩,但 ffmpeg 抽取、
ffprobe 时长闸门、记录写入、本地删除全都真跑——这几步才是会悄悄丢数据的地方。
测试用的 mp4 由 ffmpeg 现造。
"""
from __future__ import annotations
# 移到 scripts/ tests/ 后,直接运行时脚本目录成了 sys.path[0],
# 把项目根加回来,import douyin / pipeline 才找得到。
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
import argparse
import json
import shutil
import subprocess
import tempfile
import threading
from pathlib import Path
import pipeline
from douyin.client import Aweme
from douyin.app import DeviceRegisterError
from douyin.cos import SizeMismatch
from douyin.download import DownloadResult
from douyin.meta import RecordLog
DUR = 3.0
def make_mp4(path: Path, seconds: float = DUR) -> None:
subprocess.run(
["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", f"testsrc=duration={seconds}:size=320x240:rate=10",
"-f", "lavfi", "-i", f"sine=frequency=440:duration={seconds}",
"-c:v", "libx264", "-c:a", "aac", "-shortest", str(path)],
check=True, capture_output=True)
def fake_aweme(aweme_id="7000000000000000001"):
raw = {
"aweme_id": aweme_id,
"desc": "测试标题 #方言 #四川话",
"text_extra": [{"type": 1, "hashtag_name": "方言", "hashtag_id": "9",
"start": 4, "end": 7}],
"author": {"nickname": "某作者", "uid": "123", "sec_uid": "MS4w"},
"statistics": {"digg_count": 10, "comment_count": 2, "collect_count": 1,
"share_count": 3, "play_count": 999},
"music": {"title": "原声", "author": "某作者"},
"video": {"duration": 3050, "width": 320, "height": 240},
}
return Aweme(aweme_id=aweme_id, desc=raw["desc"], author="某作者",
create_time=1582635538, video_urls=["http://x/v.mp4"],
cover_urls=["http://x/c.jpg"], music_url="http://x/m.mp3",
uri="v0200fg10000test", raw=raw)
class StubPool:
def current(self):
return object(), 0
def report_dead(self, gen):
pass
class StubUploader:
"""记下传了什么,不真传。"""
def __init__(self, fail_on=None):
self.put_calls = []
self.fail_on = fail_on
def key_for(self, name):
return f"0005/zh/{name}"
def put(self, local, key):
if self.fail_on and self.fail_on in key:
raise SizeMismatch(f"{key} 大小不符:COS=1 本地=2")
size = Path(local).stat().st_size
self.put_calls.append((key, size))
return f"cos://bucket/{key}", size
def make_args(work, keep_local=False):
return argparse.Namespace(work_dir=str(work), bitrate="96k", ffprobe="ffprobe",
keep_local=keep_local)
def run(tmp, *, src_mp4, keep_local=False, uploader=None, aweme=None,
patch_download=None):
"""跑一次 run_one,返回 (records, failures, uploader, work_dir)。"""
work = Path(tmp) / "work"
work.mkdir(exist_ok=True)
aweme = aweme or fake_aweme()
up = uploader or StubUploader()
log = RecordLog(Path(tmp) / "records.jsonl")
fails = []
def dl(session, urls, dest, **kw):
shutil.copy(src_mp4, dest)
return DownloadResult(dest, dest.stat().st_size, resumed=False)
orig_dl, orig_fetch = pipeline.download, pipeline.AppClient
pipeline.download = patch_download or dl
pipeline.AppClient = lambda dev: type("C", (), {"fetch_detail": lambda s, i: aweme})()
try:
pipeline.run_one(
aweme.aweme_id, "http://src", pool=StubPool(), up=up, log=log,
args=make_args(work, keep_local), ffmpeg="ffmpeg",
ff_sem=threading.Semaphore(2), stats=pipeline.Stats(),
fail=lambda i, s, m: fails.append((i, s, m)),
abort=threading.Event())
finally:
pipeline.download, pipeline.AppClient = orig_dl, orig_fetch
recs = [json.loads(l) for l in
(Path(tmp) / "records.jsonl").read_text(encoding="utf-8").splitlines() if l.strip()] \
if (Path(tmp) / "records.jsonl").exists() else []
return recs, fails, up, work
def test_happy_path_writes_record_and_deletes_local():
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "src.mp4"
make_mp4(src)
recs, fails, up, work = run(tmp, src_mp4=src)
assert not fails, fails
assert len(recs) == 1, recs
r = recs[0]
assert r["aweme_id"] == "7000000000000000001"
assert r["title"] == "测试标题", repr(r["title"])
assert r["tags"] == ["方言", "四川话"], r["tags"]
assert r["author"]["uid"] == "123"
assert r["stats"]["play"] == 999
assert abs(r["duration_sec"] - DUR) < 0.5, r["duration_sec"]
assert r["duration_ms_api"] == 3050 # API 声明值原样留着
assert r["cos_mp4"].endswith(".mp4") and r["cos_mp3"].endswith(".mp3")
assert r["raw"]["author"]["sec_uid"] == "MS4w"
# 两个都传了
assert len(up.put_calls) == 2, up.put_calls
# 本地已删干净
assert list(work.iterdir()) == [], list(work.iterdir())
def test_keep_local_keeps_files():
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "src.mp4"
make_mp4(src)
recs, fails, up, work = run(tmp, src_mp4=src, keep_local=True)
assert not fails, fails
names = sorted(p.suffix for p in work.iterdir())
assert names == [".mp3", ".mp4"], names
def test_upload_failure_keeps_local_and_writes_no_record():
"""上传失败必须保留本地文件——不然重跑就得重新下载。"""
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "src.mp4"
make_mp4(src)
recs, fails, up, work = run(tmp, src_mp4=src,
uploader=StubUploader(fail_on=".mp3"))
assert recs == [], recs
assert len(fails) == 1 and fails[0][1] == "upload", fails
kept = sorted(p.suffix for p in work.iterdir())
assert kept == [".mp3", ".mp4"], kept
def test_truncated_mp3_is_caught_before_delete():
"""时长闸门:mp3 被截断时必须拦下,且不删本地、不写记录。"""
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "src.mp4"
make_mp4(src, seconds=10.0)
orig = pipeline.extract_mp3
def truncating(ffmpeg, s, dst, bitrate, timeout=600.0):
orig(ffmpeg, s, dst, bitrate, timeout)
data = dst.read_bytes()
dst.write_bytes(data[: len(data) // 5]) # 砍掉 80%
pipeline.extract_mp3 = truncating
try:
recs, fails, up, work = run(tmp, src_mp4=src)
finally:
pipeline.extract_mp3 = orig
assert recs == [], recs
assert len(fails) == 1 and fails[0][1] == "probe", fails
assert "截断" in fails[0][2], fails[0][2]
assert up.put_calls == [], "截断的文件绝不该被上传"
assert len(list(work.iterdir())) == 2 # 本地保留待查
def test_register_exhausted_aborts_batch():
"""IP 级注册配额耗尽时必须中止整批,不能把剩余任务逐条烧成失败。
上一批就是这么烧掉 678 条的。"""
class DeadPool:
def current(self):
raise DeviceRegisterError("所有 host 都返回 device_id=0")
def report_dead(self, gen):
pass
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) / "work"
work.mkdir()
log = RecordLog(Path(tmp) / "r.jsonl")
fails = []
abort = threading.Event()
for i in range(3):
pipeline.run_one(f"700000000000000000{i}", "u", pool=DeadPool(),
up=StubUploader(), log=log, args=make_args(work),
ffmpeg="ffmpeg", ff_sem=threading.Semaphore(2),
stats=pipeline.Stats(), fail=lambda a, b, c: fails.append((a, b, c)),
abort=abort)
assert abort.is_set(), "第一条就该拉起中止信号"
# 只有触发中止的那条记一次失败,后两条直接返回,不重复撞注册
assert len(fails) == 1, fails
assert fails[0][1] == "abort", fails
def test_download_failure_is_reported():
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "src.mp4"
make_mp4(src)
def boom(session, urls, dest, **kw):
raise OSError("连接被重置")
recs, fails, up, work = run(tmp, src_mp4=src, patch_download=boom)
assert recs == [] and len(fails) == 1
assert fails[0][1] == "download" and "连接被重置" in fails[0][2], fails
def test_resume_skips_completed():
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "src.mp4"
make_mp4(src)
run(tmp, src_mp4=src)
recs2, fails2, up2, _ = run(tmp, src_mp4=src) # 再跑一次同一条
assert len(recs2) == 1, "重复跑不该写出第二行"
assert not fails2, fails2
def test_sweep_removes_files_already_in_records():
"""work/ 里凡是已经在 records.jsonl 里的文件都是废物:早就传上去了,
留着只会撑爆磁盘。实测一次 66411 条的批跑到 2.7% 就攒了 11 GB。
但**失败项的文件必须留下**——那是重跑续传的本钱,见
test_upload_failure_keeps_local_and_writes_no_record。"""
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) / "work"
work.mkdir()
recs = Path(tmp) / "records.jsonl"
recs.write_text(
json.dumps({"aweme_id": "111", "cos_mp4": "cos://x/111.mp4"}) + chr(10)
+ json.dumps({"aweme_id": "222", "cos_mp4": "cos://x/222.mp4"}) + chr(10),
encoding="utf-8")
for name in ("111.mp4", "111.mp3", "222.mp4", "333.mp4", "333.mp3"):
(work / name).write_bytes(b"x" * 10)
(work / "444.mp4.part").write_bytes(b"x" * 10)
freed = pipeline.sweep_work_dir(work, recs)
left = sorted(p.name for p in work.iterdir())
assert left == ["333.mp3", "333.mp4"], left # 333 没入库,留着重跑
# 111.mp4 + 111.mp3 + 222.mp4 已入库,加上 444.mp4.part
assert freed == 4, freed
def test_sweep_tolerates_missing_paths():
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) / "work"
assert pipeline.sweep_work_dir(work, Path(tmp) / "nope.jsonl") == 0
work.mkdir()
assert pipeline.sweep_work_dir(work, Path(tmp) / "nope.jsonl") == 0
def test_plan_batch_splits_long_runs():
"""长跑必须切段:单个进程跑到 1.5 万条上下,Windows 会话的桌面堆就被
几万个 ffmpeg/ffprobe 子进程耗光,之后 CreateProcess 一律失败
(rc=3221225794 / STATUS_DLL_INIT_FAILED)。表现是完成数冻结、失败数
猛涨、work/ 暴涨。实测两次:三进程并跑时撞过一次,单进程跑到 16399 条
又撞一次 —— 所以是累积量的问题,不是并发数。切段后每段跑完重启进程,
桌面堆跟着重置。"""
pending = [(str(i), f"u{i}") for i in range(100)]
batch, more = pipeline.plan_batch(pending, 30)
assert len(batch) == 30 and more is True
batch, more = pipeline.plan_batch(pending, 100)
assert len(batch) == 100 and more is False, "刚好跑完不该再重启"
batch, more = pipeline.plan_batch(pending, 500)
assert len(batch) == 100 and more is False
batch, more = pipeline.plan_batch(pending, 0)
assert len(batch) == 100 and more is False, "0 = 不切段"
def test_next_abort_wait_backs_off():
"""撞 IP 级注册限流后要等冷却再续跑。连着撞说明还没解封,等得越来越久;
一旦有一段跑成了就把等待打回底价。上限别太长,不然白白空转。"""
assert pipeline.next_abort_wait(0, base=1800, cap=14400) == 1800
assert pipeline.next_abort_wait(1, base=1800, cap=14400) == 3600
assert pipeline.next_abort_wait(2, base=1800, cap=14400) == 7200
assert pipeline.next_abort_wait(3, base=1800, cap=14400) == 14400
assert pipeline.next_abort_wait(9, base=1800, cap=14400) == 14400, "要封顶"
assert pipeline.next_abort_wait(0, base=0, cap=14400) == 0, "0=不等,直接退出"
def main() -> int:
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
print("跳过:需要 ffmpeg/ffprobe")
return 0
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
failed = 0
for t in tests:
try:
t()
print(f" ok {t.__name__}")
except AssertionError as exc:
failed += 1
print(f" FAIL {t.__name__}: {exc}")
except Exception as exc:
failed += 1
print(f" ERR {t.__name__}: {type(exc).__name__}: {exc}")
print(f"\n{len(tests) - failed}/{len(tests)} 通过")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())

77
tests/test_smoke.py Normal file
View File

@@ -0,0 +1,77 @@
"""不联网的冒烟测试:验证 crypto 正确性、链接解析、文件名清洗、去重记录。"""
from __future__ import annotations
# 移到 scripts/ tests/ 后,直接运行时脚本目录成了 sys.path[0],
# 把项目根加回来,import douyin / pipeline 才找得到。
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from douyin import Registry, extract_aweme_id, safe_filename
from douyin.crypto import self_test
from douyin.urls import ParseError
def test_crypto():
self_test()
def test_extract_id():
cases = {
"7123456789012345678": "7123456789012345678",
"https://www.douyin.com/video/7123456789012345678": "7123456789012345678",
"https://www.douyin.com/note/7123456789012345678": "7123456789012345678",
"https://www.douyin.com/?modal_id=7123456789012345678": "7123456789012345678",
"https://www.iesdouyin.com/share/video/7123456789012345678/": "7123456789012345678",
"看看这个 https://www.douyin.com/video/7123456789012345678?a=1 好玩":
"7123456789012345678",
}
for raw, want in cases.items():
got = extract_aweme_id(raw)
assert got == want, f"{raw!r} -> {got}, 期望 {want}"
for bad in ["随便一段没有链接的文字", "https://example.com/foo"]:
try:
extract_aweme_id(bad)
except ParseError:
pass
else:
raise AssertionError(f"{bad!r} 本应报 ParseError")
print("extract_aweme_id OK")
def test_safe_filename():
assert safe_filename('a/b\\c:d*e?f"g<h>i|j') == "a_b_c_d_e_f_g_h_i_j"
assert safe_filename(" ") == "untitled"
assert len(safe_filename("x" * 200)) == 80
assert safe_filename("多个 空格") == "多个 空格"
print("safe_filename OK")
def test_registry():
with tempfile.TemporaryDirectory() as d:
root = Path(d)
reg = Registry(root / ".downloaded.json")
f = root / "v.mp4"
f.write_bytes(b"x" * 10)
assert "111" not in reg
reg.add("111", f, 10, "标题")
assert "111" in reg
# 重新加载后仍在
assert "111" in Registry(root / ".downloaded.json")
# 文件被删则视为未下载
f.unlink()
assert "111" not in Registry(root / ".downloaded.json")
print("Registry OK")
if __name__ == "__main__":
test_crypto()
test_extract_id()
test_safe_filename()
test_registry()
print("\n全部冒烟测试通过")