10.1 讲的是「主体有没有资格上线」,本节讲**「内容能不能过审」。游戏名、买量文案、活动标题、关卡名、玩家可自定义的昵称与聊天——每一处文本都可能踩到平台红线。本节只做一件事:把「什么算红线、命中后怎么办」变成一张可配置的规则表加一段可跑的扫描脚本**,让处置结果可复现、可断言。真实平台的审核红线、真实审核结果与最终合规判定需真实业务环境与专业法务判断,命令行不可复现(X)。
先说分工。站内那篇小游戏开发综述 里有审核合规口径的综述段,列出「哪些内容平台不让过」;本节不重复它,而是把同一类口径拆成类别 + 等级 + 词库三件套,并用脚本把「扫描 → 命中 → 分级处置」跑成一条流水线。规则表是容器,词库是内容,脚本负责让两者一致。
红线不是「敏感词表」,而是分级处置
最常见的错误做法是维护一张巨大的敏感词表,命中就拦。这有两个问题。第一,红线是分等级的:涉政、涉黄、涉暴、赌博、侵权属于硬红线,命中即拦(block);迷信、诱导分享、虚假宣传属于灰线,命中应降级为人工复核(review),而不是直接拦——把「分享给好友领皮肤」这种常见运营话术一刀切拦掉,会误伤正常活动。第二,处置要与来源挂钩:同样的词出现在玩家昵称里和出现在官方活动标题里,风险不同,处理节奏也不同。所以本节的规则不是「一张词表」,而是「每条规则带类别与等级」,再由内核把一组命中归并成一个处置档位。
处置档位只有三档,与 10.1 的清单一样追求可判定:pass(可发布)、review(需人工复核)、block(拦截)。归并规则很硬:任一 block 命中即 block,否则任一 review 命中即 review,都没有才是 pass。这条「取最高」的规则让结果与命中顺序无关,可复现。
规则表与待检文案(D 层)
下面这段是 D 层静态配置:八类红线规则(每类带等级与示例词库)与六段待检文案。类别、等级与词库都是示例归纳;真实平台审核红线与最终判定需专业法务判断,命令行不可复现(X)。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "内容安全红线规则表(D 层)。类别、处置等级与词库均为示例归纳,用于离线可复现扫描;真实平台审核红线、真实审核结果与最终合规判定需真实业务环境与专业法务判断,命令行不可复现(X)。命中判定为子串匹配(大小写归一),仅演示分级处置口径。",
"severityLevels": ["pass", "review", "block"],
"rules": [
{"id": "r-political", "category": "涉政", "severity": "block", "patterns": ["领导人", "敏感事件", "政治口号"]},
{"id": "r-porn", "category": "涉黄", "severity": "block", "patterns": ["色情", "裸聊", "成人视频"]},
{"id": "r-violence", "category": "涉暴", "severity": "block", "patterns": ["血腥", "砍杀真人", "恐怖袭击"]},
{"id": "r-gambling", "category": "赌博", "severity": "block", "patterns": ["现金红包", "押注", "赌局", "真钱"]},
{"id": "r-ip", "category": "侵权", "severity": "block", "patterns": ["宝可梦", "马里奥", "迪士尼"]},
{"id": "r-superstition", "category": "迷信", "severity": "review", "patterns": ["转运", "开光", "算命"]},
{"id": "r-induce-share", "category": "诱导分享", "severity": "review", "patterns": ["分享给", "转发到", "邀请好友才能", "关注公众号"]},
{"id": "r-false-ad", "category": "虚假宣传", "severity": "review", "patterns": ["稳赚不赔", "一键暴富", "百分百中奖", "全网最低"]}
],
"texts": [
{"id": "t-name", "kind": "游戏名", "text": "合成小岛:一键暴富,稳赚不赔"},
{"id": "t-adcopy", "kind": "买量文案", "text": "点击分享给3位好友,即可领取限定皮肤"},
{"id": "t-level", "kind": "关卡名", "text": "珊瑚礁合成挑战 · 第 12 关"},
{"id": "t-ip", "kind": "素材标题", "text": "官方授权宝可梦合成玩法"},
{"id": "t-event", "kind": "活动文案", "text": "幸运大转盘,赢取现金红包和转运符"},
{"id": "t-clean", "kind": "新手引导", "text": "拖动两个相同岛屿即可合成更大的岛屿"}
]
}
用 S 层脚本把六段文案扫成处置分布
下面这段是 S 层仿真脚本,读上面那份配置,对每段文案做大小写归一的子串匹配,记录命中项与首次出现位置,再按「取最高」归并出处置档位,汇总成「按档位 / 按类别」的分布,产出 JSON + CSV + SVG。它离线跑,不依赖任何平台接口,因此同一份输入永远得到同一份输出——这正是把审核判断变成工程流程的前提。
#!/usr/bin/env python3
# file: sim/l10_redline.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l10_redline.py --config config/ --out out/
"""内容安全红线扫描:把规则表应用到一批文案,给出分级处置(整数、可复算)。
读 config/l10_redline.json:规则(类别 / 等级 / 词库)+ 待检文案。
1) 命中:对每条规则的每个词做大小写归一子串匹配,记录首次出现位置;
2) 处置:任一 block 命中 -> block;否则任一 review 命中 -> review;否则 pass;
3) 汇总:按类别、按处置、按文案类型统计命中与拦截率。
规则与词库均为示例;真实平台审核红线与最终合规判定需真实业务环境与专业法务判断,
命令行不可复现(X)。产出 out/l10_redline.json + out/l10_redline.csv + out/l10_redline.svg。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
RANK = {"pass": 0, "review": 1, "block": 2}
DECISION_COLOR = {"pass": "#10b981", "review": "#f59e0b", "block": "#dc2626"}
def load(cfg_dir: str, name: str) -> dict:
with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
return json.load(f)
def scan_text(text: str, rules: list[dict]) -> list[dict]:
hay = text.lower()
hits = []
for rule in rules:
for p in rule["patterns"]:
idx = hay.find(p.lower())
if idx >= 0:
hits.append({"ruleId": rule["id"], "category": rule["category"],
"severity": rule["severity"], "pattern": p, "index": idx})
hits.sort(key=lambda h: (h["index"], h["ruleId"]))
return hits
def decide(hits: list[dict]) -> str:
top = "pass"
for h in hits:
if RANK[h["severity"]] > RANK[top]:
top = h["severity"]
return top
def categories(hits: list[dict]) -> list[str]:
seen: list[str] = []
for h in hits:
if h["category"] not in seen:
seen.append(h["category"])
return seen
def txt(parent: ET.Element, x: float, y: float, s: str, size: int = 12,
fill: str = "#374151", anchor: str | None = None) -> None:
a = {"x": str(x), "y": str(y), "font-size": str(size), "fill": fill}
if anchor:
a["text-anchor"] = anchor
ET.SubElement(parent, "text", a).text = s
def svg(path: str, rows: list[dict]) -> int:
"""纯 Python SVG:每段文案一根横条,长度 = 命中数,颜色 = 处置。"""
W, PAD, ROW = 720, 210, 40
H = 70 + ROW * len(rows)
r = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(r, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
txt(r, 16, 26, "《合成小岛》文案红线扫描(命中数 / 处置,示例词库)", 15, "#111827")
bmax = W - PAD - 110
hi = max((len(x["hits"]) for x in rows), default=1) or 1
for i, row in enumerate(rows):
y = 70 + i * ROW
txt(r, 16, y + 20, f'{row["kind"]}')
w = bmax * len(row["hits"]) // hi
ET.SubElement(r, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w),
"height": "20", "rx": "4",
"fill": DECISION_COLOR[row["decision"]]})
txt(r, PAD + w + 8, y + 21, f'{len(row["hits"])} · {row["decision"]}', 12, "#6b7280")
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(r).write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
cfg = load(a.config, "l10_redline")
rules = cfg["rules"]
rows = []
for t in cfg["texts"]:
hits = scan_text(t["text"], rules)
rows.append({"id": t["id"], "kind": t["kind"], "text": t["text"], "hits": hits,
"hitCount": len(hits), "decision": decide(hits),
"categories": categories(hits)})
by_decision = {d: sum(1 for r in rows if r["decision"] == d) for d in RANK}
by_category: dict[str, int] = {}
for r in rows:
for c in r["categories"]:
by_category[c] = by_category.get(c, 0) + 1
blocked = by_decision["block"]
review = by_decision["review"]
os.makedirs(a.out, exist_ok=True)
sb = svg(os.path.join(a.out, "l10_redline.svg"), rows)
report = {"source": "sim/l10_redline.py", "tier": "S", "game": cfg["game"],
"observedAt": cfg["observedAt"], "note": cfg["note"],
"ruleCount": len(rules), "textCount": len(rows), "byDecision": by_decision,
"blockRatePermille": blocked * 1000 // len(rows),
"reviewRatePermille": review * 1000 // len(rows),
"byCategory": by_category, "rows": rows, "svgBytes": sb}
with open(os.path.join(a.out, "l10_redline.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(a.out, "l10_redline.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "kind", "hitCount", "decision", "categories", "patterns"])
for r in rows:
w.writerow([r["id"], r["kind"], r["hitCount"], r["decision"],
"|".join(r["categories"]), "|".join(h["pattern"] for h in r["hits"])])
for r in rows:
pats = ",".join(h["pattern"] for h in r["hits"]) or "-"
print(f'{r["kind"]:<6} {r["decision"]:<6} 命中{r["hitCount"]} [{pats}]')
print(f'byDecision={by_decision} blockRate={report["blockRatePermille"]}permille '
f'reviewRate={report["reviewRatePermille"]}permille')
return 0
if __name__ == "__main__":
sys.exit(main())
本机跑出来的输出(同机相对量级,绝对数值不可移植):
游戏名 review 命中2 [一键暴富,稳赚不赔]
买量文案 review 命中1 [分享给]
关卡名 pass 命中0 [-]
素材标题 block 命中1 [宝可梦]
活动文案 block 命中2 [现金红包,转运]
新手引导 pass 命中0 [-]
byDecision={'pass': 2, 'review': 2, 'block': 2} blockRate=333permille reviewRate=333permille
六段文案的处置分布是 pass 2 / review 2 / block 2。素材标题与活动文案因涉侵权词与赌博词被判 block,游戏名与买量文案因虚假宣传词与诱导分享词被判 review。注意活动文案里「转运」单独看只是迷信(review),但和「现金红包」同段出现时被 block 覆盖——取最高的归并规则让两个风险叠加成一个更严的处置,符合直觉。
P 层:与运行时同款的过滤内核
扫描脚本服务的是离线批检(发布前扫一遍所有文案);玩家实时输入(昵称、聊天)则要一段能嵌进运行时的内核。下面这段是 P 层过滤内核,与 S 层脚本用同一套「类别 + 等级 + 取最高」语义,但只做纯函数:给定文本与规则配置,返回命中项、处置档位与去重类别。一份规则,两处消费——离线批检与运行时拦截不会跑出两套口径。
// file: core/src/l10/filter.ts
// tier: P
// verify: node --test 'core/test/l10_*.test.ts'
//
// 内容安全过滤内核:把一批「红线规则」应用到一段文本,产出命中项并给出分级处置。
// 处置等级:任一 block 命中 -> block;否则任一 review 命中 -> review;否则 pass。
// 规则可配置(类别 / 等级 / 子串词库),内核不碰任何平台 API,可离线、可单测、可播种。
// 约束:只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器),无 Math.random。
export type Severity = "pass" | "review" | "block";
export interface RedlineRule {
readonly id: string;
readonly category: string;
readonly severity: "review" | "block";
readonly patterns: readonly string[];
}
export interface RedlineConfig {
readonly rules: readonly RedlineRule[];
}
export interface Hit {
readonly ruleId: string;
readonly category: string;
readonly severity: "review" | "block";
readonly pattern: string;
readonly index: number;
}
export interface ScanResult {
readonly text: string;
readonly hits: readonly Hit[];
readonly decision: Severity;
readonly categories: readonly string[];
}
const RANK: Record<Severity, number> = { pass: 0, review: 1, block: 2 };
/** 大小写归一的子串扫描:对每条规则的每个词,找首次出现位置(找不到为 -1)。 */
export function scanText(text: string, cfg: RedlineConfig): Hit[] {
const hay = text.toLowerCase();
const hits: Hit[] = [];
for (const rule of cfg.rules) {
for (const p of rule.patterns) {
const idx = hay.indexOf(p.toLowerCase());
if (idx >= 0) {
hits.push({ ruleId: rule.id, category: rule.category, severity: rule.severity,
pattern: p, index: idx });
}
}
}
hits.sort((a, b) => (a.index - b.index) || (a.ruleId < b.ruleId ? -1 : 1));
return hits;
}
/** 取最高处置等级:block > review > pass。 */
export function decide(hits: readonly Hit[]): Severity {
let top: Severity = "pass";
for (const h of hits) if (RANK[h.severity] > RANK[top]) top = h.severity;
return top;
}
/** 去重后的命中类别,按首次出现顺序。 */
export function hitCategories(hits: readonly Hit[]): string[] {
const seen: string[] = [];
for (const h of hits) if (!seen.includes(h.category)) seen.push(h.category);
return seen;
}
/** 完整扫描:命中 + 处置 + 类别。 */
export function scan(text: string, cfg: RedlineConfig): ScanResult {
const hits = scanText(text, cfg);
return { text, hits, decision: decide(hits), categories: hitCategories(hits) };
}
/** 批量扫描,返回每段文本的结果。 */
export function scanAll(texts: readonly string[], cfg: RedlineConfig): ScanResult[] {
return texts.map((t) => scan(t, cfg));
}
/** 是否放行:只有 pass 才允许直接发布;review 需人工复核;block 直接拦截。 */
export function isPublishable(decision: Severity): boolean {
return decision === "pass";
}
配套的 P 层测试覆盖六条边界——干净文本 pass、block 优先于 review、仅 review 降级复核、多类别去重且按出现顺序、空命中为 pass 且命中位置升序、批量扫描处置分布与 S 层一致。node --test 'core/test/l10_*.test.ts' 六条全部通过,证明运行时内核与离线脚本口径一致。
真实审核红线不可复现(X)
上面八类规则是公开可观察机制的归纳,观察时点 2026-10。真实平台上,某个词到底算不算违规、审核结果是过还是拒、申诉能不能翻案,都属于不可复现的真实业务过程,命令行跑不出来,标 X。本节给的是一套分级处置的骨架:真实法务或平台方给出的红线填进 rules,severity 决定拦截还是复核,内核保证「取最高」的归并可复现。任何具体审核结论都必须以平台实际反馈与专业法务判断为准,脚本只保证处置逻辑自洽、离线可断言。
阅读导航:上一节:10.1 资质、备案与平台规则 · 下一节:10.3 未成年人保护与防沉迷 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。