8.1 广告内容合规与审核

广告内容合规审核回答的是「这条素材能不能上、这句文案要不要改」:把涉政涉黄涉赌、侵权 IP、虚假宣传、绝对化用语、诱导行为组织成类别 × 等级 × 词库,做大小写归一子串匹配给出 pass/review/block 分级处置与脱敏建议,并配可跑的 Python 扫描脚本与逐位对拍的 TypeScript 合规内核。

站内《上线变现与长线运营》 第 10 章把资质备案、内容安全红线与防沉迷讲过了,站内广告变现设计清单 则从形态与收益角度谈广告。本节与两者分工不同:它不重写备案流程,也不重做收益模型,更不提供具体法律意见,只做一件每天都会撞上的事——一条买量素材、一句落地页文案、一个活动名,能不能上?要不要改? 给它一台可跑、可复算的审核器。

广告内容合规管的是「文案与素材」

前面七章处理的是「钱怎么花、怎么收」。这一章换一条线:对外说出去的话。广告素材是整条商业链里唯一「公开可见、可被截图、可被举报」的产出——游戏本身不好玩没人告你,但一句「稳赚不赔」就可能让渠道下架整个广告计划,甚至牵连包体。所以广告内容合规不是法务的收尾动作,而是投放前的第一道闸门。

规则维度:类别 × 等级 × 词库

一条广告文案的合规风险,可以拆成「类别(哪类风险)× 等级(处置多狠)× 词库(哪些词触发)」三维。类别决定找谁复核,等级决定拦不拦,词库决定命中与否:

类别等级示例词处置动作
涉政 / 涉黄block领导人 / 裸聊直接拦截,人工必审
赌博 / 博彩block押注 / 现金红包直接拦截
侵权 IPblock宝可梦 / 马里奥直接拦截
医疗保健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/,结果是(同机相对量级,绝对数值不可移植):

文案命中数处置命中词
游戏名2block一键暴富、稳赚不赔
买量素材2block宝可梦、全网最低
落地页CTA1review分享给
关卡名0pass—
活动文案2block现金红包、顶级
新手引导0pass—

六条文案覆盖三种处置: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 未成年人广告与防沉迷联动 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制