Files
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

259 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""判定一条抖音作品是不是 AI 相关,以及是哪一类相关。
分四档,档位之间不重叠,取最强的那一档:
platform 抖音自己打了 AIGC 标(ai.is_aigc)。最可信,而且是唯一能抓到
"作者什么都没标、但确实是 AI 生成"的信号 —— 实测 1744 条属于
这种,文案标签里一个 ai 字都没有(脑筋急转弯、历史解说、原创动画)。
strong 文案/标签里有明确的"AI 生成作品"信号:AIGC、AI 生成类工具名、
ai+创作类词、平台的 AI 赛事标签。
topic 只提到 AI 本身(ai 教程、人工智能、大模型、ChatGPT 之类),
片子讲的是 AI,但不一定是 AI 做的。
author 作品本身没信号,只有作者昵称里带 AI/AIGC。最弱,会误收
(实测 @牛牛Ai 发的普法解说、@你好,我是大福 的真人游泳视频)。
坑(都是从这批 40087 条真实数据里挖出来的,别顺手删):
1. "Al"(大写 A + 小写 L)被当成 AI 用,标签 `抖音al` 出现 129 次,
文案里也有 `#Al创作`。必须归一化,否则整片漏掉。
2. 反过来,`ai` 是英文单词的常见片段(email / detail / again / hair),
所以只认前后不接字母数字的独立 ai,不能用 in 判断。
3. `剪映`/`关键帧`/`蒙版`/`转场`/`剪辑教程` 是剪辑术语,不是 AI。
它们在这批数据里量很大(剪映 375、关键帧 314、蒙版 203),
早期版本把"动画/绘画/分身"这类词单独当信号,误收了几千条。
所以中文泛词一律要求跟 AI 词同现,不单独成立。
4. 工具名不含 ai 字样的一大票:即梦 / seedance / libtv / flovaai /
小云雀 / 随变 / runninghub / oiioii / shotlab,只能靠名单。
5. 音乐标题默认是 `@<作者昵称>创作的原声`,40087 条里有 32555 条是这个壳。
昵称带 AI 就会连"创作"一起命中,等于让作者昵称从 music 这条路
偷偷升到 strong —— 实测 771 条这样混进来(@冠宇Aigc 的猴子炒菜、
@牛牛Ai 的大案纪实)。所以先剥壳,剥完只当弱信号:全库剥完只剩
16 条音乐标题还有命中,这条路本来就没什么价值。
注意壳有两种写法:jsonl 里是 `@昵称创作的原声`,csv 导出的是
`@昵称创作的原声 - 昵称`,后缀那半截也要一起剥。
6. 一批工具名本身就是常用词,单独出现几乎全是误判,必须要求跟 ai/aigc
同现(见 _AMBIG_*)。实测:豆包 224 条误判(狗名、豆沙包、"反强包
之神")对 145 条真命中;海螺 13 条是三峰的"海螺"帐篷;元宝 11 条是
元宝勺辅食餐具;万相 17 条是《龙神万相》和万相集;星流 1 条来自
"明星流量"(子串);清影 1 条来自"起舞弄清影"。而真命中的那些**无一
例外**都同时带 AI 字样,所以加这道门槛不掉召回。
7. 别把"数字人""换脸""分身"放开成单独信号:数字人民币含"数字人"、
微信分身/五五分身材含"分身"、穿搭视频也说"这套穿搭能换脸"。
实测放开只进误判,一条真的都捞不到。
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Tuple
# 独立的 ai —— 前后不能接字母数字,挡掉 email、detail、hair。
# 第二个字符收了三种冒充写法:i、小写 L、罗马数字 Ⅰ/ⅰ(U+2160/U+2170)。
# 实测标签 `抖音al` 129 次、`#AⅠ漫剧` 1 条。别改成简单的 in 判断。
_I = r'[ilⅠⅰ]'
_AI = re.compile(r'(?<![a-z0-9])a' + _I + r'(?![a-z0-9])', re.I)
_AIGC = re.compile(r'(?<![a-z0-9])a' + _I + r'gc(?![a-z0-9])', re.I)
# 生成类工具 / 平台。命中即 strong —— 用了这些东西,片子就是 AI 做的。
_GEN_TOOLS = [
"即梦", "dreamina", "jimeng", "seedance", "seedream", "可灵", "kling",
"hailuo", "vidu", "pixverse", "pika", "luma", "runway", "sora",
"viggle", "heygen", "midjourney", "comfyui", "runninghub", "flux",
"nanobanana", "nano banana", "stable diffusion", "liblib",
"krea", "ideogram", "recraft", "leonardo",
"higgsfield", "suno", "udio", "libtv", "flovaai", "海艺", "小云雀",
"shotlab", "sekoai", "movieflow", "updream", "cineart", "oiioii",
"minimax", "妙鸭", "一帧秒创", "触手ai", "拍我ai", "美图ai",
"文生视频", "图生视频", "文生图", "图生图", "生成式",
# --- 下面这批是 2026-08 补的。全部在 12.7 万条真实数据上验过:
# 要么零命中(纯属给以后的文件备着),要么命中的全是真 AI,
# 没有一个引入新误判。判断标准是"这串字在中文短视频文案里
# 不可能有别的意思",所以像 tripo(三脚架 tripod)、seaweed
# (海藻)、lora(撞 valorant)、captions、playground 这些
# 一律没收 —— 实测 lora 命中 3 条全是无畏契约电竞。
"veo3", "wan2.1", "wanx", "通义万相", "hunyuanvideo", "混元视频",
"cogvideo", "open-sora", "animatediff", "controlnet", "智谱清影",
"step-video", "hedra", "kaiber", "haiper", "moonvalley", "genmo",
"domoai", "synthesia", "akool", "deepbrain", "opusclip", "argil",
"kreadoai", "腾讯智影", "讯飞智作", "硅基智能", "魔音工坊", "元气口播",
"万兴播爆", "闪剪", "声音克隆", "语音克隆", "克隆音色", "音色克隆",
]
# AI 漫剧圈的专有名词:**单独出现即 strong**,不要求跟 ai 同现。
# 抖音的 AI 漫剧已经形成固定圈子,这些词在 18.4 万条真实数据里几乎只出现在
# AI 作品上(括号=全库命中/其中现在还漏掉的)。选词标准是"这串字在中文短视频
# 文案里不可能有别的意思":
# 漫剧(1568/64) 治愈系漫画(80/0) 渡灵书(62/0) 超级裂变(46/0) 漫剧推荐(38/2)
# 二次元动漫(33/0) 月落小溪(25/4) 七罪创世(21/0) 晚夏小梦(67/4)
# 灵绘漫客(12/0) neowow(12/0) 造梦国度(2/0) 猫箱跨次元相遇(114/12)
# 没收的(实测污染严重,它们是题材不是 AI 标记):
# 西游记(+26 真·玄奘故里游记) 孙悟空(+16) 宫崎骏(+10 龙猫童年) 民间故事(+7《狼来了》)
# 睡前故事(+5 爷俩演西游) 神话故事(+2) 国漫推荐(+4《剑来》真人制作)
# 原创动漫(+18 手绘二次元) 创作者激励计划(+12 原神/卡西尼号) 未来导演扶持计划(+4)
# 开放赛道(+2) —— 后两个是抖音通用活动,不专属 AI
_MANJU = [
"漫剧", "治愈系漫画", "渡灵书", "超级裂变", "二次元动漫", "月落小溪",
"七罪创世", "晚夏小梦", "灵绘漫客", "neowow", "造梦国度", "猫箱跨次元相遇",
# 红果(字节的免费短剧 App)上的 AI 漫剧片名。来源是新闻报道的热榜,
# **不是红果的完整目录** —— 红果不公开片库,AI 漫剧实际有成百上千部,
# 这里只有见诸报道的头部作品,以后遇到新的往这加。
# 在 18.4 万条上验过:16/18 零命中(纯属备着),万妖图录传 1 条且正是
# 现在漏掉的真 AI 漫剧,零污染。
"万妖图录传", "发配边关", "罪妻开荒养出战神", "儿媳的重生棋局",
"斩仙台", "菩提临世", "聚宝仙盆", "杂灵根", "渔乡守真心", "金钱照冷暖",
"杀猪辣媳", "夫人一个过肩摔", "瘫子总裁站起来", "凡人百世书",
"都重生了谁还装富二代",
# **"红果"/"红果短剧" 不能进这个表**:红果主体是真人短剧,AI 漫剧只是
# 其中一个赛道。实测 3 条命中里 2 条是真人短剧(《家里家外2》《冷御失守》),
# 1 条是短剧演员面试。要抓红果的 AI 漫剧只能靠具体片名。
]
# 名字本身是常用词的生成类工具:只有跟 ai/aigc 同现才算 strong。
# 见模块开头第 6 条 —— 单独出现基本全是误判,真命中都带 AI 字样。
_AMBIG_GEN = [
"海螺", "白日梦", "万相", "可图", "清影", "星流", "秒画",
"造梦日记", "随变",
]
# 对话/通用大模型。这些更像是"在聊 AI",归 topic。
_CHAT_TOOLS = [
"chatgpt", "deepseek", "kimi", "通义", "千问", "qwen", "文心",
"ernie", "智谱", "glm", "copilot", "gemini", "grok",
"人工智能", "大模型", "神经网络", "机器学习", "深度学习",
]
# 同上,但归 topic 档的那批常用词模型名
_AMBIG_CHAT = ["豆包", "元宝", "混元"]
# 单独成词才算,避免 gpt 撞上别的串
_CHAT_RE = re.compile(r'(?<![a-z0-9])(?:gpt|claude|llm|agi|ai\s*agent)(?![a-z0-9])')
# 跟 ai 同现才成立的"创作/生成"语境词。单独出现一律不算。
_GEN_CTX = [
"短剧", "漫剧", "动画", "动漫", "视频", "短片", "电影", "影视", "绘画",
"绘图", "创作", "制作", "生成", "分身", "剧情", "作画", "配音", "换脸",
"特效", "导演", "演员", "数字人", "虚拟人", "成片", "新影像", "影像",
"浪潮", "大赛", "赛道", "大赏", "工作台", "写真", "画师", "作品", "片子",
"微短剧", "番剧", "剪辑", "转绘", "扩图", "修图", "提示词",
]
_GEN_CTX_RE = re.compile("|".join(map(re.escape, _GEN_CTX)))
def _alt(words: List[str]) -> re.Pattern:
"""把词表编成一个正则。纯 ASCII 的词加字母边界:前后不能紧接字母,
但**允许接数字和中文** —— 数据里满地都是 `Seedance2.0`、`sora2`、
`runninghub0.21`、`libtv创作者`、`flovaai超创`,一刀切加 \\b 会全丢。
只挡真正的词中词:`udio` 藏在 `theonestudio`/`Grass Studio` 里 17 次,
`flux` 藏在 `Fluxxwave` 曲名里 1 次,两个都是从 music 混进 author 档的。
"""
parts = []
for w in words:
e = re.escape(w)
if re.fullmatch(r"[a-z0-9 .\-]+", w):
e = r"(?<![a-z])" + e + r"(?![a-z])"
parts.append(e)
return re.compile("|".join(parts))
_TOOL_RE = _alt(_GEN_TOOLS)
_CHATW_RE = _alt(_CHAT_TOOLS)
_MANJU_RE = _alt(_MANJU)
_AMBIG_GEN_RE = _alt(_AMBIG_GEN)
_AMBIG_CHAT_RE = _alt(_AMBIG_CHAT)
# 抖音默认原声的标题壳,里面就是作者昵称,不含额外信息。
# jsonl 是 `@昵称创作的原声`,csv 导出多一截 ` - 昵称`,见模块开头第 5 条。
_SOUND_WRAP = re.compile(r'^@.*?(?:创作的原声|的原声)(?:\s*-\s*.*)?$')
TIERS = ("platform", "strong", "topic", "author", "none")
_RANK = {t: i for i, t in enumerate(TIERS)}
def _fields(rec: Dict[str, Any]) -> Tuple[str, str, str]:
"""(作品文本, 作者昵称, 音乐标题)。作品文本 = desc + 所有标签名。"""
tags = [t.get("name", "") if isinstance(t, dict) else str(t)
for t in (rec.get("hashtags") or [])]
body = " ".join([rec.get("desc") or ""] + tags)
author = (rec.get("author") or {}).get("nickname") or ""
music = ((rec.get("music") or {}).get("title") or "").strip()
if _SOUND_WRAP.match(music):
music = "" # 只是作者昵称的复读,见模块开头第 5 条
return body, author, music
def _scan(text: str, where: str) -> Tuple[str, List[str]]:
"""扫一段文本,返回 (档位, 命中的信号)。档位只可能是 strong/topic/none。"""
low = text.lower()
sig: List[str] = []
tier = "none"
has_aigc = bool(_AIGC.search(low))
has_ai = bool(_AI.search(low))
# 歧义词表的开关:文本里得先有 ai/aigc 字样,那些常用词才作数
ai_present = has_ai or has_aigc
if has_aigc:
sig.append(f"{where}:aigc")
tier = "strong"
for m in sorted(set(_TOOL_RE.findall(low))):
sig.append(f"{where}:工具:{m}")
tier = "strong"
# 漫剧专名单独成立,不要求 ai 同现
for m in sorted(set(_MANJU_RE.findall(low))):
sig.append(f"{where}:漫剧:{m}")
tier = "strong"
if ai_present:
for m in sorted(set(_AMBIG_GEN_RE.findall(low))):
sig.append(f"{where}:工具:{m}")
tier = "strong"
if has_ai:
ctx = sorted(set(_GEN_CTX_RE.findall(text)))
if ctx:
sig.append(f"{where}:ai+{'/'.join(ctx[:4])}")
tier = "strong"
else:
sig.append(f"{where}:ai")
if tier == "none":
tier = "topic"
chat = set(_CHATW_RE.findall(low)) | set(_CHAT_RE.findall(low))
if ai_present:
chat |= set(_AMBIG_CHAT_RE.findall(low))
chat = sorted(chat)
if chat:
sig.append(f"{where}:模型:{'/'.join(chat[:4])}")
if tier == "none":
tier = "topic"
return tier, sig
def classify(rec: Dict[str, Any]) -> Dict[str, Any]:
"""判一条记录。返回 {tier, signals, is_aigc}。"""
body, author, music = _fields(rec)
signals: List[str] = []
tier = "none"
is_aigc = bool((rec.get("ai") or {}).get("is_aigc"))
label = ((rec.get("ai") or {}).get("label") or "").strip()
if is_aigc:
tier = "platform"
signals.append(f"platform:{label or 'is_aigc'}")
t, s = _scan(body, "desc")
signals.extend(s)
if _RANK[t] < _RANK[tier]:
tier = t
# 作者昵称和音乐标题都只是"谁发的",不是"作品是什么"。
# 只在作品自己毫无信号时兜底成 author 档,绝不往上顶。
for where, text in (("author", author), ("music", music)):
wt, wsig = _scan(text, where)
if wt != "none":
signals.extend(wsig)
if tier == "none":
tier = "author"
return {"tier": tier, "signals": signals, "is_aigc": is_aigc}