App 端免签名接口(设备注册 + detail + CDN 直链),一体化流水线 下载→抽音→传COS→记录→删本地,AI 作品四档判定,四份文档: README(使用指南)、PROGRESS(批次进度)、RE(逆向全过程)、AI(判定规则)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
259 lines
14 KiB
Python
259 lines
14 KiB
Python
"""判定一条抖音作品是不是 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}
|