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

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,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())