初始提交:抖音视频批量采集流水线
App 端免签名接口(设备注册 + detail + CDN 直链),一体化流水线 下载→抽音→传COS→记录→删本地,AI 作品四档判定,四份文档: README(使用指南)、PROGRESS(批次进度)、RE(逆向全过程)、AI(判定规则)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
228
scripts/export_sample_xlsx.py
Normal file
228
scripts/export_sample_xlsx.py
Normal 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())
|
||||
Reference in New Issue
Block a user