13.2 素材本地化与合规

同一套买量素材投到不同地区,能不能用不是一个审美问题,而是一张「素材标签 × 地区规则」的合规矩阵:一条素材只要带一个禁投标签,整条在该地区就是禁投。本节用 D 层清单与 P 层 TypeScript 内核把每条素材在每个市场的处置与需改清单算出来,并跨语言对拍,证明最严者胜出这条规则没写错。

卷三「买量渠道与投放机制」 与「素材设计与创意迭代」 做的是渠道机制与素材设计框架(国内口径)——素材怎么设计、怎么迭代。本节不重做那套素材设计方法论,只做海外素材特有的合规约束。站内那篇独立游戏多语言本地化指南 给的是本地化行业与法务口径(怎么选供应商、合同怎么签),本节也不重写那些,只把「一条素材能不能投到某个地区」变成一个可机读、可对拍的合规判定——口径不对,投出去的素材会被拒审或下架,钱花了、量没起来。

素材合规不是「一条红线」,是一张矩阵

很多人以为素材合规就是「别碰色情暴力」这一条红线。工程上它是一张二维矩阵:行是素材携带的标签(真人、酒类、博彩暗示、儿童吸引、国旗、宗教符号、效果对比、价格承诺),列是目标地区。同一个标签在不同地区处置不同——「酒类」在德国可投、在沙特禁投;「儿童吸引」在巴西需改、在德国禁投。判定不是看素材本身,而是看「素材 × 地区」这个组合。

三档处置与「最严者胜出」

一条素材往往带多个标签,只要其中一个在目标地区是禁投,整条素材在该地区就不能投——这就是最严者胜出。所以处置分三档:

处置含义工程动作
可投 allow全部标签在该地区都放行直接投
需改 revise有标签需调整(换素材元素 / 改文案)出本地化变体后再投
禁投 block有标签触红线且不可低成本改写该地区不投这条

这条规则的工程含义是:合规判定必须逐标签做,再取最大值,不能只看「主标签」。一条主打「真人爆分」的素材,真人在德国只是需改、效果对比在德国是禁投——只看主标签会误判成「改一下就行」,实际是整条禁投。

D 层:合规矩阵与 lint

下面这份是 D 层配置 config/g13_creative_compliance.json,把九个素材标签、六个地区规则、五条素材与目标市场写成可机读整数表:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "海外素材本地化合规矩阵。口径是「公开可观察机制」的建模假设(观察时点 2026-10),不是任一平台广告政策原文。severity:0=可投、1=需改、2=禁投。regions[].rules 是「素材标签 → 该地区处置」,缺省视为 0(可投)。creatives[] 是一条素材及其携带的敏感标签与目标市场。gate = 该素材在该地区全部标签处置的最大值(最严者胜出);fixes = 处置非 0 的标签(需改或禁投)。真实广告审核结论与平台政策版本需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "severity": [
    { "v": 0, "id": "allow", "label": "可投" },
    { "v": 1, "id": "revise", "label": "需改" },
    { "v": 2, "id": "block", "label": "禁投" }
  ],
  "tags": [
    "gameplay_clip", "real_person", "alcohol", "gambling_hint",
    "minor_appeal", "national_flag", "religious_symbol", "before_after_claim", "price_claim"
  ],
  "regions": [
    { "id": "US", "name": "美国", "rules": { "gameplay_clip": 0, "real_person": 0, "alcohol": 1, "gambling_hint": 1, "minor_appeal": 1, "national_flag": 0, "religious_symbol": 0, "before_after_claim": 1, "price_claim": 0 } },
    { "id": "DE", "name": "德国", "rules": { "gameplay_clip": 0, "real_person": 1, "alcohol": 0, "gambling_hint": 2, "minor_appeal": 2, "national_flag": 1, "religious_symbol": 0, "before_after_claim": 2, "price_claim": 1 } },
    { "id": "JP", "name": "日本", "rules": { "gameplay_clip": 0, "real_person": 1, "alcohol": 0, "gambling_hint": 2, "minor_appeal": 1, "national_flag": 0, "religious_symbol": 0, "before_after_claim": 1, "price_claim": 0 } },
    { "id": "KR", "name": "韩国", "rules": { "gameplay_clip": 0, "real_person": 0, "alcohol": 1, "gambling_hint": 2, "minor_appeal": 1, "national_flag": 1, "religious_symbol": 0, "before_after_claim": 1, "price_claim": 1 } },
    { "id": "BR", "name": "巴西", "rules": { "gameplay_clip": 0, "real_person": 0, "alcohol": 1, "gambling_hint": 1, "minor_appeal": 1, "national_flag": 0, "religious_symbol": 0, "before_after_claim": 1, "price_claim": 0 } },
    { "id": "SA", "name": "沙特", "rules": { "gameplay_clip": 0, "real_person": 1, "alcohol": 2, "gambling_hint": 2, "minor_appeal": 0, "national_flag": 1, "religious_symbol": 2, "before_after_claim": 2, "price_claim": 1 } }
  ],
  "creatives": [
    { "id": "hero_burst", "name": "主角爆分", "tags": ["real_person", "before_after_claim"], "markets": ["US", "DE", "JP"] },
    { "id": "coin_rush", "name": "金币雨", "tags": ["gambling_hint", "price_claim"], "markets": ["US", "BR", "KR"] },
    { "id": "family_time", "name": "合家欢", "tags": ["minor_appeal", "national_flag"], "markets": ["US", "JP", "SA"] },
    { "id": "lucky_spin", "name": "幸运转盘", "tags": ["gambling_hint", "real_person", "religious_symbol"], "markets": ["US", "DE", "SA"] },
    { "id": "calm_clip", "name": "纯玩法演示", "tags": ["gameplay_clip"], "markets": ["US", "DE", "JP"] }
  ]
}

下面这段是 D 层门禁脚本 sim/g13_creative_compliance.py。它读上面那份配置,把每条素材在每个市场的处置(gate)与需改清单(fixes)算出来,并做 schema lint:

#!/usr/bin/env python3
# file: sim/g13_creative_compliance.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g13_creative_compliance.py --config config/ --out out/
"""海外素材本地化合规矩阵与门禁(D 层:清单结构 / lint)。

把「素材标签 × 地区规则」解析成每条素材在每个目标市场的处置(gate)与需改清单(fixes):
    gate(creative, region) = max(region.rules[t] for t in creative.tags)   # 缺省 0(可投)
    fixes(creative, region) = sorted(t for t in creative.tags if rules[t] > 0)
severity:0=可投、1=需改、2=禁投;gate 取最严者胜出,所以一条素材只要带一个禁投标签,整条
素材在该地区就是禁投。lint:素材标签必须已声明、目标市场必须是已知地区、地区规则必须覆盖全部
标签。产出 out/g13_creative_compliance.json + .csv。全整数、无浮点、无随机。口径是「公开可观察
机制」的建模假设(观察时点 2026-10);真实广告审核结论与平台政策版本需真实业务环境,命令行
不可复现(X)。
"""
from __future__ import annotations

import argparse
import csv
import json
import os

LABEL = {0: "allow", 1: "revise", 2: "block"}


def evaluate(cfg: dict) -> list[dict]:
    """每条素材在每个目标市场的处置与需改清单。"""
    regions = {r["id"]: r["rules"] for r in cfg["regions"]}
    rows = []
    for c in cfg["creatives"]:
        for mkt in c["markets"]:
            rules = regions[mkt]
            gates = [rules.get(t, 0) for t in c["tags"]]
            gate = max(gates) if gates else 0
            fixes = sorted(t for t in c["tags"] if rules.get(t, 0) > 0)
            rows.append({"creative": c["id"], "region": mkt, "gate": gate,
                         "gateLabel": LABEL[gate], "fixes": fixes, "fixCount": len(fixes)})
    return rows


def lint(cfg: dict) -> int:
    """schema lint:素材标签已声明、目标市场已知、地区规则覆盖全部标签。"""
    ok = 1
    tagset = set(cfg["tags"])
    regionids = {r["id"] for r in cfg["regions"]}
    for c in cfg["creatives"]:
        if any(t not in tagset for t in c["tags"]) or any(m not in regionids for m in c["markets"]):
            ok = 0
    for r in cfg["regions"]:
        if set(r["rules"].keys()) != tagset:
            ok = 0
    return ok


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, "g13_creative_compliance.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    rows = evaluate(cfg)
    ok = lint(cfg)
    cnt = lambda v: sum(1 for r in rows if r["gate"] == v)  # noqa: E731
    parts = ";".join(f'{r["creative"]}|{r["region"]}|{r["gate"]}|{",".join(r["fixes"])}' for r in rows)
    digest = (f'schemaOk={ok},pairs={len(rows)},allow={cnt(0)},revise={cnt(1)},block={cnt(2)};' + parts)

    os.makedirs(a.out, exist_ok=True)
    cols = ("creative", "region", "gate", "gateLabel", "fixCount")
    with open(os.path.join(a.out, "g13_creative_compliance.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(cols + ("fixes",))
        for r in rows:
            w.writerow([r[c] for c in cols] + ["|".join(r["fixes"])])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "schemaOk": ok, "pairCount": len(rows), "rows": rows, "digest": digest,
           "csv": os.path.join(a.out, "g13_creative_compliance.csv")}
    with open(os.path.join(a.out, "g13_creative_compliance.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "schemaOk": ok,
                      "rows": [{"creative": r["creative"], "region": r["region"],
                                "gate": r["gateLabel"], "fixes": r["fixes"]} for r in rows]},
                     ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑 /opt/homebrew/bin/python3 sim/g13_creative_compliance.py --config config/ --out out/,15 个「素材 × 地区」组合的判定(同机相对量级):

素材标签USDEJPKRBRSA
主角爆分 hero_burst真人、效果对比需改禁投需改———
金币雨 coin_rush博彩暗示、价格承诺需改——禁投需改—
合家欢 family_time儿童吸引、国旗需改—需改——需改
幸运转盘 lucky_spin博彩暗示、真人、宗教符号需改禁投———禁投
纯玩法演示 calm_clip玩法演示可投可投可投———

(— 表示该素材未投该市场。)15 个组合里 3 可投 / 8 需改 / 4 禁投。这张表最该看的是 calm_clip:它只带「玩法演示」一个中性标签,在三个市场全部可投——合规成本最低的素材,往往不是最好看的那条,而是标签最少的那条。而 lucky_spin 在沙特是三重违规(博彩 + 真人 + 宗教符号),任何一条都够禁投。摘要行:

digest = schemaOk=1,pairs=15,allow=3,revise=8,block=4;hero_burst|US|1|before_after_claim;hero_burst|DE|2|before_after_claim,real_person;hero_burst|JP|1|before_after_claim,real_person;coin_rush|US|1|gambling_hint;coin_rush|BR|1|gambling_hint;coin_rush|KR|2|gambling_hint,price_claim;family_time|US|1|minor_appeal;family_time|JP|1|minor_appeal;family_time|SA|1|national_flag;lucky_spin|US|1|gambling_hint;lucky_spin|DE|2|gambling_hint,real_person;lucky_spin|SA|2|gambling_hint,real_person,religious_symbol;calm_clip|US|0|;calm_clip|DE|0|;calm_clip|JP|0|

P 层内核:跨语言对拍

下面这段是 P 层 TypeScript 内核 core/src/g13/creative_compliance.ts,复算同一套判定:同样的「最严者胜出」、同样的需改清单排序、同样的 lint。它读同一份配置,产出和 Python 逐字符一致的摘要。

// file: core/src/g13/creative_compliance.ts
// tier: P
// verify: node --test 'core/test/g13_*.test.ts'
//
// 海外素材本地化合规门禁内核:与 sim/g13_creative_compliance.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g13_creative_compliance.json;全整数、无浮点、无随机。

export interface Region { readonly id: string; readonly name: string; readonly rules: Readonly<Record<string, number>> }
export interface Creative { readonly id: string; readonly name: string; readonly tags: readonly string[]; readonly markets: readonly string[] }
export interface ComplianceConfig {
  readonly version: number; readonly game: string; readonly observedAt: string;
  readonly tags: readonly string[]; readonly regions: readonly Region[]; readonly creatives: readonly Creative[];
}
export interface GateRow {
  readonly creative: string; readonly region: string; readonly gate: number;
  readonly gateLabel: string; readonly fixes: readonly string[]; readonly fixCount: number;
}

export const LABEL: Record<number, string> = { 0: "allow", 1: "revise", 2: "block" };

/** gate = 该素材在该地区全部标签处置的最大值(最严者胜出);fixes = 处置非 0 的标签。 */
export function evaluate(cfg: ComplianceConfig): GateRow[] {
  const regions = new Map(cfg.regions.map((r) => [r.id, r.rules]));
  const rows: GateRow[] = [];
  for (const c of cfg.creatives) {
    for (const mkt of c.markets) {
      const rules = regions.get(mkt)!;
      const gates = c.tags.map((t) => rules[t] ?? 0);
      const gate = gates.length ? Math.max(...gates) : 0;
      const fixes = c.tags.filter((t) => (rules[t] ?? 0) > 0).slice().sort();
      rows.push({ creative: c.id, region: mkt, gate, gateLabel: LABEL[gate], fixes, fixCount: fixes.length });
    }
  }
  return rows;
}

/** schema lint:素材标签已声明、目标市场已知、地区规则覆盖全部标签。 */
export function lint(cfg: ComplianceConfig): number {
  let ok = 1;
  const tagset = new Set(cfg.tags);
  const regionIds = new Set(cfg.regions.map((r) => r.id));
  for (const c of cfg.creatives) {
    if (c.tags.some((t) => !tagset.has(t))) ok = 0;
    if (c.markets.some((m) => !regionIds.has(m))) ok = 0;
  }
  for (const r of cfg.regions) {
    if (Object.keys(r.rules).length !== tagset.size) ok = 0;
    for (const t of tagset) if (!(t in r.rules)) ok = 0;
  }
  return ok;
}

/** 跨语言对拍摘要:与 sim/g13_creative_compliance.py 的 digest 逐字符一致。 */
export function digest(cfg: ComplianceConfig): string {
  const rows = evaluate(cfg);
  const cnt = (v: number) => rows.filter((r) => r.gate === v).length;
  const parts = rows.map((r) => `${r.creative}|${r.region}|${r.gate}|${r.fixes.join(",")}`).join(";");
  return `schemaOk=${lint(cfg)},pairs=${rows.length},allow=${cnt(0)},revise=${cnt(1)},block=${cnt(2)};${parts}`;
}

配套测试 core/test/g13_creative_compliance.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算逐字符相等,并验最严者胜出、需改清单、跨地区差异与 lint。在本机跑 node --test 'core/test/g13_creative_compliance.test.ts',本节 6 个用例全过:

✔ 跨语言一致:TS 合规摘要 == Python 金标准
✔ 最严者胜出:一条素材带一个禁投标签,整条在该地区就是禁投
✔ 需改清单:只列出处置非 0 的标签,且按 id 排序
✔ 跨地区差异:同一条素材在 US 可投、在 DE 禁投
✔ lint 通过:标签已声明、市场已知、规则覆盖全部标签
✔ 统计口径:15 个「素材 × 地区」组合里 3 可投 / 8 需改 / 4 禁投
ℹ tests 6
ℹ pass 6
ℹ fail 0

X 层:真实审核结论与政策版本不可复现

上面算的是模型侧:给定标签与规则,判定是什么。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的广告审核结论(同一个标签在不同平台、不同时期的判定会变,还可能人工复核翻盘);
  • 平台政策版本(本节按观察时点 2026-10 冻结了一组规则,真实政策会更新);
  • 改写后的真实通过率(「需改」的素材改完能不能过,只有投了才知道)。

工程上的正确做法:把可复现的判定层与不可复现的政策层分开——判定层固定「逐标签 + 最严者胜出」这套算法;政策层在拿到平台最新政策或被拒审后反标定(把新规则补进 regions,把被误判的标签区间修正,重跑立刻看到新的处置)。这与上一节「海外渠道与投放口径」 是同一个思路:把口径做成可对拍的清单,而不是散落在人脑里的经验。

自检表

检查项合格标准
逐标签判定处置按每个标签算,再取最严者,不只看主标签
三档处置可投 / 需改 / 禁投 分开,需改给出具体标签
地区独立同一素材在不同地区判定可不同,规则按地区存
跨语言一致Python 与 TS 摘要逐字符相同
lint 完整标签已声明、市场已知、规则覆盖全部标签
边界清晰真实审核结论与政策版本标 X,不写成「实测」

素材合规最反直觉的一点是:它看起来是「内容好不好」,实际是「标签多不多」——标签越多,命中禁投的概率越高。下一节把口径与合规两条线收束到一件事上:这笔海外投放到底赚不赚、亏到什么程度该停。

阅读导航:上一节:13.1 海外渠道与投放口径 · 下一节:13.3 海外投放 ROI 与止损 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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