Files
douyin_video_download/docs/superpowers/specs/2026-08-25-download-pipeline-design.md
whale-fall-code 2bb83f3685 初始提交:抖音视频批量采集流水线
App 端免签名接口(设备注册 + detail + CDN 直链),一体化流水线
下载→抽音→传COS→记录→删本地,AI 作品四档判定,四份文档:
README(使用指南)、PROGRESS(批次进度)、RE(逆向全过程)、AI(判定规则)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:31:10 +08:00

5.7 KiB
Raw Blame History

抖音采集流水线一体化 —— 设计

日期: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 在最前:

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