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

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

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` 既有格式
- 不支持图文作品