在日常社区运营和内容审核的对接中,一类高频出现的文本总是让人头疼:账号在评论区、商品问答区、私信入口批量投放“哈喽,我们是源头工厂,有需要就找我呀”这类揽客话术。它不算明显的违规广告,单看每个字都很正常,但架不住量大、变体多、人工审不完。本文就以此为切入点,完整实现一个可运行的营销引流文本检测小工具,从文本归一化、规则设计、权重打分到配置外置、灰度上线全流程拆解。读者不需要机器学习基础,只要会写 Python 函数就能跟着跑通;已经做过内容风控的同学,也可以直接拿去对照自己的规则库查漏补缺。
1. 从一个真实场景说起:营销引流文本为什么需要工程化治理
先把问题定义清楚。所谓“营销引流文本”,指的是那些目标明确、但表述上刻意模糊的商业推广内容:自称厂家、批发、一手货源,引导用户离开当前平台去私下沟通,制造限时紧迫感,等等。这类内容在电商评论区、二手交易区、内容社区、在线客服会话里都非常常见。
它和传统意义上的垃圾广告有几个明显区别,也正因为这些区别,简单的关键词黑名单很难奏效。
1.1 这类文本的三个技术特征
第一个特征是词汇本身是正常词。“源头工厂”“批发价”“有需要找我”这些词单独拎出来,在正规的商品描述、售后说明、用户提问里都会出现。你把“源头工厂”整个拉黑,第二天就会收到一堆误伤投诉。所以规则必须建立在词组组合上,而不是单词上。
第二个特征是变体成本极低。加个全角字母、塞几个零宽字符、把“微信”写成“薇信”、把汉字重复几遍,都能在不改变人眼阅读效果的前提下绕过字面匹配。这一点在后文会专门用一节讲文本归一化,因为这是所有文本风控的第一道地基。
第三个特征是分级困难。一句“我是源头工厂,欢迎咨询”到底该直接拦截还是人工复核?如果全部拦截,误伤率会高到业务方无法接受;如果全部放过,等于没做。实际工程里必须有中间档位,也就是“疑似”状态,把判断权交给人。
1.2 几种常见方案的边界对比
在动手写代码之前,先看清楚几种技术路线的适用边界,避免一上来就选错工具。
| 方案 | 实现成本 | 可解释性 | 变体对抗能力 | 适合阶段 |
|---|---|---|---|---|
| 纯关键词黑名单 | 极低 | 强 | 很弱 | 冷启动、兜底 |
| 归一化 + 组合正则 | 低 | 强 | 中等 | 规则库主力 |
| 词向量/文本分类模型 | 高 | 弱 | 强 | 有标注数据后 |
| 大模型语义判别 | 中 | 中 | 强 | 增量补充、复核辅助 |
需要强调的是,这几条路线不是替代关系,而是叠加关系。一个成熟的文本风控系统通常是:归一化打底,规则引擎跑主力,模型负责兜底和兜住长尾,人工复核处理争议样本,最后把复核结果回流成新的规则和训练数据。
本文聚焦第二条路线,因为它是最容易在两周内上线、并且效果立刻可量化的方案。对于日均评论量在几万到几十万量级的业务,一套设计良好的规则引擎能得到超过八成的问题召回,误伤也完全可控。
1.3 本文要实现的系统能力
具体来说,我们要做的东西包含这几个能力点:
- 输入一条原始文本,输出一个 0 到 100 的分数,以及
block/review/pass三档结论。 - 支持解释:不仅告诉你“这条有问题”,还要告诉你“命中了哪条规则、命中了哪几个字”。
- 支持配置化:规则库不写死在代码里,能通过文件修改并生效。
- 支持批量与单条两种调用方式,便于本地调试和接入服务。
- 附带单元测试,保证改了规则之后不会悄悄把已有能力改坏。
下面进入环境准备。
2. 环境准备与项目结构
2.1 运行环境与依赖选择
这个项目刻意只使用 Python 标准库,原因很实际:内容审核往往要嵌到已有的服务里,多引一个第三方包就多一份版本冲突风险和供应链风险。re、unicodedata、json、argparse、unittest全部内置,装好 Python 就能跑。
环境说明:
- Python 版本:建议使用 3.8 及以上。本文用到的
dataclasses在 3.7 之后是标准库,f-string 的格式说明符在 3.6 之后可用。具体小版本请按你自己的项目统一,这里不写死。 - 操作系统:Windows、macOS、Linux 均可,代码中没有平台相关调用。
- 编辑器:PyCharm、VS Code 都行,建议开启保存时自动格式化。
- 不需要数据库、不需要 Web 框架,全部逻辑跑在单进程内存里。
如果你后续要接到 Flask / FastAPI 的接口里,直接把RuleEngine实例挂在应用启动阶段初始化一次即可,规则编译是一次性的,运行期只有正则匹配开销。
2.2 目录结构设计
项目结构如下,保持扁平,方便直接复制:
text_guard_demo/ ├── text_guard/ │ ├── __init__.py │ ├── normalize.py │ ├── rules.py │ ├── engine.py │ └── cli.py ├── config/ │ └── rules.json ├── samples.txt └── tests/ └── test_engine.py这里的分层是按职责切的:normalize.py只负责把文本洗干净,rules.py只放规则数据,engine.py负责匹配和打分,cli.py只做输入输出。这样切的好处是,将来你想把规则换成模型,只动engine.py;想把规则换成数据库下发,只动rules.py的加载逻辑。很多人在写这类小工具时喜欢全塞在一个文件里,调试时舒服,上线后维护会很痛苦。
2.3 安装与运行方式
创建目录并初始化:
mkdir -p text_guard_demo/text_guard text_guard_demo/config text_guard_demo/tests cd text_guard_demo touch text_guard/__init__.py tests/__init__.py__init__.py可以为空,它的作用是让text_guard成为一个包,这样python -m text_guard.cli才能正常找到模块。tests/__init__.py同理,方便测试发现。
运行单元测试:
python -m unittest discover -s tests -v运行命令行工具:
python -m text_guard.cli -t "哈喽,我们是源头工厂,有需要就找我呀"下面开始写真正的逻辑。
3. 文本归一化:把变体拉回可比较的形态
3.1 为什么归一化是第一步
先看一组例子。下面这五条文本,人一眼就能看出是同一句话,但字符串比较的结果全是“不相等”:
哈喽,我们是源头工厂,有需要就找我呀 哈喽,我们是源头工厂,有需要就找我呀 哈喽,我们是源头工厂,有需\u200b要就找我呀 哈喽,我们是源头工厂,有需要要要找我呀 哈喽,我们是源头工厂,有需要就找我丫第一条用的是全角逗号,第二条用半角逗号;第三条中间插了一个零宽空格U+200B;第四条把“要”重复了三遍;第五条把“呀”换成了字形接近的“丫”。
如果你把规则写成re.search("有需要就找我", text),只有第一条能命中。这不是规则写得不对,而是输入没有对齐。所以归一化的定位不是“美化文本”,而是把所有等价写法映射到同一个可比较形态,把对抗成本从规则方转移回发布方。
3.2 三类必须处理的字符噪声
第一类:全角与兼容字符。全角字母A到Z、全角数字0到9、全角标点、带圈数字、罗马数字,都属于 Unicode 的兼容字符区。Python 的unicodedata.normalize("NFKC", text)可以把它们统一折叠成常见形态。这个函数不需要额外依赖,一次调用就能把大量“换皮”写法拉平。
需要注意的是,NFKC 是幂等的,重复调用结果不变,所以在整条流水线里调一次就够,不必每步都调。
第二类:不可见字符。零宽空格U+200B、零宽非连接符U+200C、零宽连接符U+200D、左右方向标记U+200E、U+200F、字节序标记U+FEFF、软连字符U+00AD,这些字符在终端里看不见,却真实存在于字符串中。用正则把这些区间整体删掉即可。同时把 ASCII 控制字符(除\t、\n、\r外)也一并清理,避免日志里出现乱码。
第三类:夸张重复。把某个汉字重复两三次是极常见的规避手法。处理方式是用反向引用压缩:连续三个及以上相同字符折叠成一个。这里有一个细节必须注意——数字不能压缩。如果你无脑用(.)\1{2,},那么“最后100件”会被压成“最后10件”,价格和型号信息就丢了,后续如果要做数量识别会出错。所以正则里要把数字和空白排除掉。
3.3 归一化顺序为什么不能随意调换
归一化的每一步都有先后依赖,顺序错了会留下漏洞。推荐顺序是:
- 删除不可见字符和控制字符。
- NFKC 折叠全角与兼容字符。
- 统一转小写。
- 压缩重复字符。
- 压缩连续空白并去掉首尾空格。
为什么“删不可见字符”要放在最前面?因为这些字符可能夹在 Unicode 组合序列中间,先折叠再做删除,有可能产生新的组合结果,导致漏删。为什么不转小写就压缩重复?大小写混写如Vvv折叠前后是两种形态,先转小写能保证压缩逻辑看到的是同一个字符。
另外提醒一点:本文的规则库是针对中文话术设计的,所以没有做繁简转换。如果你的业务场景里繁体内容占比不低,建议在 NFKC 之后、转小写之前插入一步繁简统一,用开源的字表映射即可,不要自己手写几百对字符。这一步加不加,取决于你的实际数据分布,不要为了“完整”而堆功能。
4. 规则设计与打分模型
4.1 用组合词而不是单词
这是规则库设计里最容易踩的坑。假设你把“工厂”设为关键词,后果是什么?用户问“这个工厂的生产资质能看下吗”,直接被判成营销。误伤几次之后,业务方就会要求关掉整个功能。
正确的做法是把词拆成“意图信号”,每个信号用词组或短语表达:
- 身份宣称类:源头工厂、源头厂家、一手货源、厂家直销、工厂直供、原厂直供。注意这里全都是两到四个字的组合,单独一个“工厂”或“货源”不构成信号。
- 引导联系类:加V、加微信、留联系方式、私信我、私聊我、滴滴我、扣1。这类信号的危险度最高,因为它的目的就是把人从平台导走。
- 招呼揽客类:有需要就找我、欢迎咨询、欢迎来撩、随时找我。这是最典型的“哈喽体”尾巴。
- 价格诱惑类:批发价、出厂价、低价清仓、一件代发、量大从优。
- 紧迫感类:限时、秒杀、手慢无、仅限今天、最后 N 件。
- 批量起订类:N 件起批、起订量、大量现货。
这样拆分以后,单条文本的召回和精准度都会好很多。同时规则具备可解释性:命中了哪一类,审核人员一眼就能看懂原因。
4.2 权重怎么定
有了信号之后要给权重。权重的意义不是“精确概率”,而是相对危险度的排序。本文的取值思路是:
| 规则 | 权重 | 理由 |
|---|---|---|
| 引导联系 | 25 | 直接触达用户,风险最高 |
| 招呼揽客 | 18 | 典型揽客尾巴,但可能出现在客服场景 |
| 身份宣称 | 15 | 单独出现不足以判定,需组合 |
| 价格诱惑 | 12 | 常见于正常促销 |
| 紧迫感 | 10 | 广告常用,但正常活动也用 |
| 批量起订 | 8 | 补充信号 |
几个设计要点值得说明。
第一,同一条规则在一条文本里只计一次分。有人会想“命中三次就乘三”,这会让刷分变得极其容易——重复五遍“加V”就能把分数顶到上限。用re.search而不是re.finditer,天然做到一条规则一次计分。
第二,分数要有上限。所有命中权重求和后做一次min(raw, 100),避免后续加规则时分数计算溢出预期区间,也方便阈值调整。
第三,权重是调出来的,不是想出来的。上线后一定要跑真实的复核样本,把误判的样本和漏判的样本分别列出来,看是该调权重还是该补规则。凭感觉调权重,两周之后没人记得为什么是 15 而不是 12。
4.3 阈值分级与人工复核
三档阈值的取值建议:
score >= 45→block,直接拦截或折叠展示。20 <= score < 45→review,进入人工复核队列。score < 20→pass,正常放行。
为什么中间要留这么宽的一档?因为营销文本的判定本质上是概率性的,两档(拦/放)会逼着你在“误伤太多”和“漏放太多”之间二选一。有了 review 档,你可以先用较宽的规则召回,再通过人工复核把样本标出来,逐步把规则收紧。这也是冷启动阶段最务实的策略。
需要提醒的是,阈值不能几个业务线共用一套。客服会话里的“欢迎咨询”是正常的,商品评论区里的“欢迎咨询”就很可疑。合理的做法是给不同场景各配一套阈值,或者给规则加上场景标签。
5. 完整实战:一个可运行的文本风控小工具
下面把四个模块的代码完整写出来,每段都能直接复制进对应文件运行。
5.1 归一化模块
文件路径:text_guard/normalize.py
"""文本归一化:把各种变体写法统一成可比较的形态。""" import re import unicodedata # 零宽字符、方向控制符、软连字符等不可见字符 _INVISIBLE_RE = re.compile(r"[\u200b-\u200f\u202a-\u202e\u2060\ufeff\u00ad]") # ASCII 控制字符(\t、\n、\r 交给后面的空白压缩处理) _CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") # 三个及以上连续相同的「非数字、非空白」字符,压缩成一个 _REPEAT_RE = re.compile(r"([^\d\s])\1{2,}") # 连续空白压缩成一个空格 _SPACE_RE = re.compile(r"\s+") def strip_invisible(text: str) -> str: """删除零宽字符和控制字符,避免「看起来一样、实际不同」的文本绕过规则。""" return _INVISIBLE_RE.sub("", _CTRL_RE.sub("", text)) def to_halfwidth(text: str) -> str: """使用 NFKC 归一化,把全角字母数字、兼容字符折叠成常见形态。""" return unicodedata.normalize("NFKC", text) def collapse_repeats(text: str) -> str: """压缩夸张重复:有需要要要找我 -> 有需要找我。数字不压缩,避免破坏价格和型号。""" return _REPEAT_RE.sub(r"\1", text) def normalize(text: str) -> str: """归一化主入口,顺序不可随意调换。""" if not text: return "" t = strip_invisible(text) t = to_halfwidth(t) t = t.lower() t = collapse_repeats(t) t = _SPACE_RE.sub(" ", t).strip() return t逐段解释一下。strip_invisible做了两层sub嵌套,外层删零宽字符,内层删控制字符,顺序无关因为两类区间不重叠。to_halfwidth里用 NFKC 而不做手工映射,是因为全角字母有 26 个、全角数字 10 个、各种标点几十个,手写映射表既容易漏又难维护,标准库已经把这件事件做好了。
collapse_repeats的替换串是r"\1",配合正则里的捕获组([^\d\s]),把整段重复压缩成第一个字符。数字被显式排除,是为了保住“最后100件”这类信息。
最后normalize函数的顺序就是 3.3 节说的顺序,注释里也标明了不可随意调换。
5.2 规则定义模块
文件路径:text_guard/rules.py
"""规则数据定义。这里只放数据,不放逻辑。""" from dataclasses import dataclass from typing import List @dataclass(frozen=True) class Rule: rule_id: str # 规则唯一标识,用于日志和解释 pattern: str # 正则在「已归一化文本」上匹配 weight: int # 命中分值 desc: str # 人话描述,给审核人员看 DEFAULT_RULES: List[Rule] = [ Rule( rule_id="src_factory_claim", pattern=r"源头工厂|源头厂家|一手货源|厂家直销|工厂直供|原厂直供", weight=15, desc="自称源头/厂家直供", ), Rule( rule_id="contact_solicit", pattern=r"加\s*[v威薇微w]|加个?微信|留个?联系方式|私信我|私聊我|滴滴我|扣\s*1", weight=25, desc="引导私下联系", ), Rule( rule_id="sales_greeting", pattern=r"有需要就?找我|需要的?话?[^,。?]{0,6}找我|欢迎咨询|欢迎来撩|随时找我", weight=18, desc="招呼式揽客", ), Rule( rule_id="price_claim", pattern=r"批发价|出厂价|低价清仓|跳楼价|赚差价|一件代发|量大从优", weight=12, desc="低价/代发话术", ), Rule( rule_id="urgency", pattern=r"限时|秒杀|手慢无|仅限今天|最后\s*\d+\s*件", weight=10, desc="制造紧迫感", ), Rule( rule_id="wholesale_qty", pattern=r"\d+\s*件起批|起订量|大量现货", weight=8, desc="批量起订话术", ), ]几个正则细节需要单独说明。
加\s*[v威薇微w]里的\s*是为了兼容“加 V”这种中间带空格的写法。归一化已经把连续空白压成一个空格,但空格还在,所以匹配时必须允许它。
需要的?话?[^,。?]{0,6}找我是“需要的资料可以找我”这类长句的兜底。中间允许 0 到 6 个非标点字符,超过 6 个字符通常说明这是两个独立语义,误伤风险上升。这里的[^,。?]用的是半角标点,因为归一化已经通过 NFKC 把中文逗号句号问号转成了半角。
最后\s*\d+\s*件允许数字两边有空格,同样是为了兼容“最后 100 件”这类排版。注意这里和collapse_repeats不冲突,因为数字没有被压缩。
5.3 规则引擎模块
文件路径:text_guard/engine.py
"""规则引擎:负责匹配、打分、给出结论。""" import re from dataclasses import dataclass, field from typing import List, Sequence from .normalize import normalize from .rules import Rule, DEFAULT_RULES VERDICT_BLOCK = "block" VERDICT_REVIEW = "review" VERDICT_PASS = "pass" @dataclass class Hit: rule_id: str desc: str weight: int matched: str @dataclass class Decision: text: str normalized: str score: int verdict: str hits: List[Hit] = field(default_factory=list) def to_dict(self) -> dict: return { "verdict": self.verdict, "score": self.score, "normalized": self.normalized, "hits": [ { "rule_id": h.rule_id, "desc": h.desc, "weight": h.weight, "matched": h.matched, } for h in self.hits ], } class RuleEngine: def __init__( self, rules: Sequence[Rule] = DEFAULT_RULES, block_threshold: int = 45, review_threshold: int = 20, ): if review_threshold >= block_threshold: raise ValueError("review_threshold 必须小于 block_threshold") # 规则在初始化阶段一次性编译,运行期不再重复编译 self._rules = [(rule, re.compile(rule.pattern)) for rule in rules] self.block_threshold = block_threshold self.review_threshold = review_threshold def decide(self, text: str) -> Decision: normalized = normalize(text) hits: List[Hit] = [] for rule, pattern in self._rules: # 用 search 而不是 finditer:同一条规则只计一次分 m = pattern.search(normalized) if m: hits.append( Hit( rule_id=rule.rule_id, desc=rule.desc, weight=rule.weight, matched=m.group(0), ) ) score = min(sum(h.weight for h in hits), 100) if score >= self.block_threshold: verdict = VERDICT_BLOCK elif score >= self.review_threshold: verdict = VERDICT_REVIEW else: verdict = VERDICT_PASS return Decision( text=text, normalized=normalized, score=score, verdict=verdict, hits=hits, )这段代码里有三处设计是刻意的,值得单独讲。
构造期编译正则。re.compile本身有缓存,但显式编译能保证行为确定,也避免运行期第一次调用时的抖动。规则库一旦大起来(几百条),每次请求重复编译的开销不可忽视,尤其是高频短文本场景。
用search而不是finditer。这是 4.2 节说的“一条规则只计一次分”的实现基础。如果改成finditer并累加,恶意用户只要把话术重复几遍就能把分数打满,整个阈值体系就失效了。
分数封顶 100。这个细节在多规则叠加时很重要。如果不封顶,将来加规则容易把分数推到几百,阈值就需要跟着重算,历史积累的调参经验全部作废。封顶之后,阈值区间保持稳定。
5.4 命令行入口
文件路径:text_guard/cli.py
"""命令行入口:支持单条、文件和标准输入三种方式。""" import argparse import json import sys from .engine import RuleEngine def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="text-guard", description="营销引流话术检测小工具", ) source = parser.add_mutually_exclusive_group() source.add_argument("-t", "--text", help="检测单条文本") source.add_argument("-f", "--file", help="检测文本文件,每行一条") parser.add_argument("--block", type=int, default=45, help="拦截阈值,默认 45") parser.add_argument("--review", type=int, default=20, help="复核阈值,默认 20") parser.add_argument("--json", action="store_true", help="以 JSON 行格式输出") return parser def main(argv=None) -> int: args = build_parser().parse_args(argv) engine = RuleEngine(block_threshold=args.block, review_threshold=args.review) if args.text is not None: items = [args.text] elif args.file: with open(args.file, encoding="utf-8") as f: items = [line.rstrip("\n") for line in f if line.strip()] else: items = [line.rstrip("\n") for line in sys.stdin if line.strip()] for idx, item in enumerate(items, start=1): decision = engine.decide(item) if args.json: payload = decision.to_dict() payload["line"] = idx print(json.dumps(payload, ensure_ascii=False)) else: print(f"[{idx}] {decision.verdict.upper():<6} score={decision.score:<3} {item}") for hit in decision.hits: print(f" - {hit.rule_id}({hit.weight}): {hit.matched}") return 0 if __name__ == "__main__": raise SystemExit(main())ensure_ascii=False这一行很关键。默认的json.dumps会把中文转成\uXXXX,日志里几乎没法看,排查问题时非常痛苦。加上这个参数后输出保持中文原样,配合encoding="utf-8"读文件,整条链路不会出现乱码。
raise SystemExit(main())是标准写法,保证入口函数的返回值能正确传递给操作系统,便于在脚本里通过退出码判断成功与否。
5.5 单元测试
文件路径:tests/test_engine.py
import unittest from text_guard.engine import ( RuleEngine, VERDICT_BLOCK, VERDICT_PASS, VERDICT_REVIEW, ) from text_guard.normalize import normalize class NormalizeTest(unittest.TestCase): def test_fullwidth_to_halfwidth(self): self.assertEqual(normalize("加V"), "加v") def test_zero_width_removed(self): self.assertEqual(normalize("加\u200bv"), "加v") def test_repeat_collapsed(self): self.assertEqual(normalize("有需要要要找我"), "有需要找我") def test_digits_kept(self): self.assertEqual(normalize("最后100件"), "最后100件") class EngineTest(unittest.TestCase): def setUp(self): self.engine = RuleEngine() def test_marketing_greeting_is_review(self): d = self.engine.decide("哈喽,我们是源头工厂,有需要就找我呀") self.assertEqual(d.verdict, VERDICT_REVIEW) self.assertEqual(d.score, 33) def test_hard_sell_is_block(self): d = self.engine.decide("源头工厂一手货源,加V:abc123,一件代发,量大从优") self.assertEqual(d.verdict, VERDICT_BLOCK) self.assertEqual(d.score, 52) def test_normal_question_passes(self): d = self.engine.decide("这个零件是源头工厂生产的吗?我想了解下工艺流程") self.assertEqual(d.verdict, VERDICT_PASS) def test_contact_only_is_review(self): d = self.engine.decide("有货的话加个微信聊") self.assertEqual(d.verdict, VERDICT_REVIEW) if __name__ == "__main__": unittest.main()测试用例的设计思路是“一类风险一条”。归一化层测四个点:全角折叠、零宽删除、重复压缩、数字保留。引擎层测四个点:打招呼式揽客、硬广、正常提问、仅引导联系。这样改规则时有基线保护,不会出现“修了漏判、炸了误伤”还不知道的情况。
如果你的团队用 pytest,这套用例可以直接跑,不需要改任何断言写法,因为unittest.TestCase的子类 pytest 也识别。
5.6 运行验证与结果解读
准备样本文件,文件路径:samples.txt
哈喽,我们是源头工厂,有需要就找我呀 源头工厂一手货源,加V:abc123,一件代发,量大从优 这个零件是源头工厂生产的吗?我想了解下工艺流程 你好,请问这个型号还有货吗 限时秒杀,最后3件,手慢无,需要的私聊我执行:
python -m text_guard.cli -f samples.txt预期输出:
[1] REVIEW score=33 哈喽,我们是源头工厂,有需要就找我呀 - src_factory_claim(15): 源头工厂 - sales_greeting(18): 有需要就找我 [2] BLOCK score=52 源头工厂一手货源,加V:abc123,一件代发,量大从优 - src_factory_claim(15): 源头工厂 - contact_solicit(25): 加v - price_claim(12): 一件代发 [3] PASS score=15 这个零件是源头工厂生产的吗?我想了解下工艺流程 - src_factory_claim(15): 源头工厂 [4] PASS score=0 你好,请问这个型号还有货吗 [5] REVIEW score=35 限时秒杀,最后3件,手慢无,需要的私聊我 - contact_solicit(25): 私聊我 - urgency(10): 限时逐条解读一下结果,这几条正好覆盖了系统的主要行为。
第 1 条就是标题里那句话,得分 33,落在 review 档。它命中了“源头工厂”这个身份宣称和“有需要就找我”这个揽客尾巴,但没有引导联系、没有价格诱惑,所以不直接拦截。这个判定是合理的——如果你看到一条评论是这么写的,通常会选择折叠或者限流,而不是直接删除账号。
第 2 条得分 52,直接 block。注意它的命中项里contact_solicit匹配到的是归一化之后的加v,说明全角字母折叠生效了。三条规则叠加刚好越过拦截线,这正是“组合信号”的价值——单独任何一条都不足以定罪。
第 3 条只命中身份宣称,得 15 分,判 pass。这条最值得注意,它证明了两件事:一是组合词设计确实降低了误伤,如果规则里放的是单个“工厂”,这条会被判 review;二是 review 阈值设在 20 是有依据的,单信号不应该进复核队列,否则复核队列会被大量正常提问淹没。
第 4 条零命中,正常放行。
第 5 条命中了引导联系和紧迫感,得 35 分进 review。这里其实可以讨论:私聊我权重 25 是否过高?如果这条来自客服场景,可能是正常回复。这就是为什么 4