"""判定一条抖音作品是不是 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'(? 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"(? 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}