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

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

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全部冒烟测试通过")