站内《上线变现与长线运营》 第 10 章把资质备案、内容安全红线与防沉迷讲过了,站内广告变现设计清单 则从形态与收益角度谈广告。本节与两者分工不同:它不重写备案流程,也不重做收益模型,更不提供具体法律意见,只做一件每天都会撞上的事——一条买量素材、一句落地页文案、一个活动名,能不能上?要不要改? 给它一台可跑、可复算的审核器。
广告内容合规管的是「文案与素材」
前面七章处理的是「钱怎么花、怎么收」。这一章换一条线:对外说出去的话。广告素材是整条商业链里唯一「公开可见、可被截图、可被举报」的产出——游戏本身不好玩没人告你,但一句「稳赚不赔」就可能让渠道下架整个广告计划,甚至牵连包体。所以广告内容合规不是法务的收尾动作,而是投放前的第一道闸门。
规则维度:类别 × 等级 × 词库
一条广告文案的合规风险,可以拆成「类别(哪类风险)× 等级(处置多狠)× 词库(哪些词触发)」三维。类别决定找谁复核,等级决定拦不拦,词库决定命中与否:
| 类别 | 等级 | 示例词 | 处置动作 |
|---|---|---|---|
| 涉政 / 涉黄 | block | 领导人 / 裸聊 | 直接拦截,人工必审 |
| 赌博 / 博彩 | block | 押注 / 现金红包 | 直接拦截 |
| 侵权 IP | block | 宝可梦 / 马里奥 | 直接拦截 |
| 医疗保健 | block | 包治百病 / 根治 | 直接拦截 |
| 虚假宣传 | block | 稳赚不赔 / 一键暴富 | 直接拦截 |
| 绝对化用语 | review | 全网最低 / 顶级 | 转人工复核 |
| 诱导行为 | review | 分享给 / 关注公众号 | 转人工复核 |
| 未成年人不宜 | review | 血腥 / 暴力 | 转人工复核 |
分级处置与脱敏建议
判定规则用三行伪代码说清,全部是确定性整数逻辑:
命中 = 大小写归一子串匹配(记录首次出现位置)
处置 = max(命中等级);block > review > pass
脱敏 = replacements[命中词](未收录则留空)
S 层:广告文案合规扫描
下面这段是 S 层仿真脚本 sim/o08_adcopy_scan.py。它读 config/o08_adcopy.json 的规则与文案,逐条扫描、分级处置、给出脱敏建议,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o08_adcopy_scan.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o08_adcopy_scan.py --config config/ --out out/
"""广告素材内容合规扫描:规则表 × 一批广告文案 -> 分级处置 + 脱敏建议。
读 config/o08_adcopy.json:规则(类别 / 等级 / 词库)+ 替换建议 + 待检文案。
命中 = 大小写归一子串匹配;处置 = 命中里的最高等级(block > review > pass);
脱敏 = replacements[命中词]。规则、词库与替换建议均为示例归纳;真实平台广告审核红线
与最终合规判定需真实业务环境与专业法务判断,命令行不可复现(X)。
产出 out/o08_adcopy_scan.json + .csv + .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}
COLOR = {"pass": "#10b981", "review": "#f59e0b", "block": "#dc2626"}
def scan_text(text: str, rules: list[dict]) -> list[dict]:
"""大小写归一子串匹配;命中按(首次出现位置, 规则 id)排序。"""
hay, hits = text.lower(), []
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_of(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 svg(path: str, rows: list[dict]) -> int:
"""纯 Python SVG:每段文案一根横条,长度 = 命中数,颜色 = 处置。"""
W, PAD, ROW = 720, 210, 42
H = 74 + 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"})
ET.SubElement(r, "text", {"x": "16", "y": "28", "font-size": "15",
"fill": "#111827"}).text = "《合成小岛》广告文案合规扫描(命中数 / 处置,示例词库)"
bmax = W - PAD - 150
hi = max((len(x["hits"]) for x in rows), default=1) or 1
for i, row in enumerate(rows):
y = 74 + i * ROW
ET.SubElement(r, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
"fill": "#374151"}).text = 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": COLOR[row["decision"]]})
ET.SubElement(r, "text", {"x": str(PAD + w + 8), "y": str(y + 21), "font-size": "12",
"fill": "#6b7280"}).text = f'{len(row["hits"])} · {row["decision"]}'
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()
with open(os.path.join(a.config, "o08_adcopy.json"), encoding="utf-8") as f:
cfg = json.load(f)
rules, repl = cfg["rules"], cfg["replacements"]
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_of(hits),
"suggestions": [{"pattern": h["pattern"], "replacement": repl.get(h["pattern"], "")}
for h in hits]})
hist = {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
digest = ",".join(f'{r["id"]}:{r["decision"]}:{r["hitCount"]}' for r in rows) + ";" + \
",".join(f"{d}={hist[d]}" for d in ("pass", "review", "block"))
os.makedirs(a.out, exist_ok=True)
sb = svg(os.path.join(a.out, "o08_adcopy_scan.svg"), rows)
report = {"source": "sim/o08_adcopy_scan.py", "tier": "S", "game": cfg["game"],
"observedAt": cfg["observedAt"], "note": cfg["note"], "ruleCount": len(rules),
"textCount": len(rows), "byDecision": hist,
"blockRatePermille": hist["block"] * 1000 // len(rows),
"reviewRatePermille": hist["review"] * 1000 // len(rows),
"byCategory": by_category, "digest": digest, "rows": rows, "svgBytes": sb}
with open(os.path.join(a.out, "o08_adcopy_scan.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(a.out, "o08_adcopy_scan.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "kind", "hitCount", "decision", "categories", "patterns", "suggestions"])
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"]),
"|".join(f'{s["pattern"]}=>{s["replacement"]}' for s in r["suggestions"])])
for r in rows:
print(f'{r["kind"]:<6} {r["decision"]:<6} 命中{r["hitCount"]} '
f'[{",".join(h["pattern"] for h in r["hits"]) or "-"}]')
print(f'byDecision={hist} blockRate={report["blockRatePermille"]}permille '
f'reviewRate={report["reviewRatePermille"]}permille')
print(f"digest={digest}")
return 0
if __name__ == "__main__":
sys.exit(main())
参数放在 config/o08_adcopy.json。规则、词库与替换建议都是「公开可观察机制」口径的示例归纳,不是任何平台审核后台的实测:
{
"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-gamble", "category": "赌博", "severity": "block", "patterns": ["押注", "赌局", "真钱提现", "现金红包"]},
{"id": "r-ip", "category": "侵权IP", "severity": "block", "patterns": ["宝可梦", "马里奥", "迪士尼"]},
{"id": "r-medical", "category": "医疗保健", "severity": "block", "patterns": ["包治百病", "根治", "疗效显著"]},
{"id": "r-false", "category": "虚假宣传", "severity": "block", "patterns": ["稳赚不赔", "一键暴富", "百分百中奖", "躺赚"]},
{"id": "r-superlative", "category": "绝对化用语", "severity": "review", "patterns": ["全网最低", "最便宜", "第一品牌", "顶级"]},
{"id": "r-induce", "category": "诱导行为", "severity": "review", "patterns": ["分享给", "转发到", "邀请好友才能", "关注公众号"]},
{"id": "r-minor", "category": "未成年人不宜", "severity": "review", "patterns": ["血腥", "暴力"]}
],
"replacements": {
"全网最低": "限时优惠", "顶级": "稀有", "稳赚不赔": "轻松上手", "一键暴富": "轻松成长",
"分享给": "邀请", "现金红包": "游戏道具", "宝可梦": "原创形象"
},
"texts": [
{"id": "c-name", "kind": "游戏名", "text": "合成小岛:一键暴富,稳赚不赔"},
{"id": "c-creative", "kind": "买量素材", "text": "官方授权宝可梦合成玩法,全网最低门槛"},
{"id": "c-cta", "kind": "落地页CTA", "text": "点击分享给3位好友,即可领取限定皮肤"},
{"id": "c-level", "kind": "关卡名", "text": "珊瑚礁合成挑战 · 第 12 关"},
{"id": "c-event", "kind": "活动文案", "text": "幸运大转盘,赢取现金红包和顶级皮肤"},
{"id": "c-guide", "kind": "新手引导", "text": "拖动两个相同岛屿即可合成更大的岛屿"}
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o08_adcopy_scan.py --config config/ --out out/,结果是(同机相对量级,绝对数值不可移植):
| 文案 | 命中数 | 处置 | 命中词 |
|---|---|---|---|
| 游戏名 | 2 | block | 一键暴富、稳赚不赔 |
| 买量素材 | 2 | block | 宝可梦、全网最低 |
| 落地页CTA | 1 | review | 分享给 |
| 关卡名 | 0 | pass | — |
| 活动文案 | 2 | block | 现金红包、顶级 |
| 新手引导 | 0 | pass | — |
六条文案覆盖三种处置:3 条 block、1 条 review、2 条 pass,拦截率 500‰、复核率 166‰。注意「买量素材」同时命中 侵权IP(block)与 绝对化用语(review),处置取最高等级 block——这正是「max 等级」规则的价值:一条文案同时踩多条线,只要有一条是 block,整条就 block。
P 层内核:跨语言对拍,证明匹配器没写错
光看一张表,你没法确认「是规则对」还是「匹配器碰巧算对」。本套用一份 P 层 TypeScript 内核 core/src/o08/adcopy.ts 复算同一条链:同样的子串匹配、同样的排序、同样的 max 处置。它读同一份 config/o08_adcopy.json,产出和 Python 逐字符一致的摘要。
// file: core/src/o08/adcopy.ts
// tier: P
// verify: node --test 'core/test/o08_*.test.ts'
//
// 广告素材内容合规内核:与 sim/o08_adcopy_scan.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o08_adcopy.json;全整数、无浮点、无 Math.random(纯确定性字符串匹配)。
export interface AdRule {
readonly id: string; readonly category: string; readonly severity: string; readonly patterns: readonly string[];
}
export interface AdText { readonly id: string; readonly kind: string; readonly text: string; }
export interface AdCopyConfig {
readonly version: number; readonly game: string; readonly rules: readonly AdRule[];
readonly replacements: Record<string, string>; readonly texts: readonly AdText[];
}
export interface Hit {
readonly ruleId: string; readonly category: string; readonly severity: string;
readonly pattern: string; readonly index: number;
}
export interface Suggestion { readonly pattern: string; readonly replacement: string; }
export interface ScanRow {
readonly id: string; readonly kind: string; readonly text: string; readonly hits: readonly Hit[];
readonly hitCount: number; readonly decision: string; readonly categories: readonly string[];
readonly suggestions: readonly Suggestion[];
}
const RANK: Record<string, number> = { pass: 0, review: 1, block: 2 };
/** 大小写归一子串匹配;命中按(首次出现位置, 规则 id)排序。 */
export function scanText(text: string, rules: readonly AdRule[]): Hit[] {
const hay = text.toLowerCase();
const hits: Hit[] = [];
for (const rule of 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 : a.ruleId > b.ruleId ? 1 : 0));
return hits;
}
/** 分级处置:取命中里最高的等级(block > review > pass)。 */
export function decide(hits: readonly Hit[]): string {
let top = "pass";
for (const h of hits) if (RANK[h.severity] > RANK[top]) top = h.severity;
return top;
}
export function categoriesOf(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 scanAll(cfg: AdCopyConfig): ScanRow[] {
return cfg.texts.map((t) => {
const hits = scanText(t.text, cfg.rules);
return { id: t.id, kind: t.kind, text: t.text, hits, hitCount: hits.length, decision: decide(hits),
categories: categoriesOf(hits),
suggestions: hits.map((h) => ({ pattern: h.pattern, replacement: cfg.replacements[h.pattern] ?? "" })) };
});
}
/** 跨语言对拍摘要:与 sim/o08_adcopy_scan.py 的 digest 逐字符一致。 */
export function digest(rows: readonly ScanRow[]): string {
const hist: Record<string, number> = { pass: 0, review: 0, block: 0 };
for (const r of rows) hist[r.decision] += 1;
return rows.map((r) => `${r.id}:${r.decision}:${r.hitCount}`).join(",")
+ `;pass=${hist.pass},review=${hist.review},block=${hist.block}`;
}
配套测试 core/test/o08_adcopy.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等。在本机跑 node --test 'core/test/o08_*.test.ts',5 个用例全过;两套独立实现给出同一个摘要:
digest = c-name:block:2,c-creative:block:2,c-cta:review:1,c-level:pass:0,c-event:block:2,c-guide:pass:0;pass=2,review=1,block=3
只要有一边的子串匹配、命中排序或 max 处置写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一套规则,在 Python 扫描、TS 内核、端上预检里必须给出同一个判定。
X 层:真实审核不可复现
上面算的是规则侧:给定词库与文案,命中与处置是多少。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的平台审核红线与词库(各家口径不同、且不公开完整清单,会随监管与舆情动态调整);
- 真实的审核结果(机器初审 + 人工复审的最终判定,还叠加账号历史、素材上下文);
- 真实的法律定性(同一句文案在不同场景、不同地区的合规结论可能不同,需专业法务判断)。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三维齐全 | 每条规则都有类别、等级、词库,缺一不可 |
| 处置明确 | 取命中里的最高等级,不含糊、不跳级 |
| 脱敏可用 | 高风险词给出可执行的改写建议,而非只驳回 |
| 引擎可对拍 | Python 与 TS 两套实现给出同一 digest |
| 词库可反标定 | config 能直接吃平台公告与法务输入的更新 |
| 边界清晰 | 真实红线与法律定性标 X,不写成「实测」 |
阅读导航:上一节:7.3 退款、风控与对账 · 下一节:8.2 未成年人广告与防沉迷联动 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。