前阵子做了个纯前端小工具「副业照妖镜」:把招聘广告或群聊话术粘进去,它会把可疑的词标红,给一个 0 到 100 的风险分,再告诉你下一句该问对方什么。整个东西就一个 HTML,没有后端,文字不出浏览器。
这篇只讲实现:规则怎么组织、分数怎么算、标红怎么做不会乱套,以及正则上几个容易出错的地方。
规则就是一个数组
每类骗局一条规则,字段固定:
js
const RULES = [
{ id: 'fee', name: '先交钱才能开始', w: 30, hard: 1,
re: /(会员费|押金|保证金|培训费|学费|报名费|激活费|解冻费|交\s*\d+\s*元|先交|买课)/g,
why: '正规工作是别人付钱给你,不是你先付钱。',
ask: '费用能从第一笔收入里扣吗?能签合同写明可退吗?' },
{ id: 'brush', name: '刷单 / 垫付返佣', w: 25, hard: 1,
re: /(刷单|垫付|返本金|本金.{0,8}?佣金|做任务|联单|卡单)/g, ... },
{ id: 'urgent', name: '催你马上决定', w: 10,
re: /(名额有限|仅剩|只剩\s*\d+|限时|今天截止|先到先得)/g, ... },
// 一共 12 条
];
w 是权重,hard 标记硬红旗。why 和 ask 直接给界面用,规则和文案放在一起,改一条规则不用去翻模板。
词表是照着公安机关公开通报的常见诈骗类型整理的:刷单返利、付费学 AI 接单、导师带单炒币、出租银行卡、拉人头这些。
打分:加权求和,再加一个保底
js
const HARD_FLOOR = 70;
function score(ids) {
let s = 0, hard = false;
ids.forEach(id => {
const r = RULES.find(x => x.id === id);
s += r.w;
if (r.hard) hard = true;
});
s = Math.min(100, s);
if (hard) s = Math.max(s, HARD_FLOOR);
return s;
}
同一类规则命中多少个词都只算一次,否则一段话里「刷单」「垫付」「返本金」各出现一次就能把分数堆上去,而一段只写了「先交 99 元会员费」的话术分数反而低。后者其实更危险,因为它已经在要钱了。
所以分两层:类别去重后按权重加,只要碰到一条硬红旗(先交钱、拉人头、垫付、保本收益、炒币、借卡),分数至少 70,直接落进「高度疑似」档。轻度信号像「宝妈学生党」「名额有限」单独出现最多二三十分,只提示去查证。
用工具自带的 4 个示例跑出来是这样:
| 示例 | 分数 | 命中类别 |
|---|---|---|
| 刷单返利 | 85 | 垫付返佣、先给甜头、轻松高薪、私聊群、催促 |
| AI 接单交学费 | 100 | 先交钱、保本收益、先给甜头、轻松高薪、私聊、催促 |
| 带单炒币 | 90 | 保本收益、炒币、陌生 App、先给甜头 |
| 正常兼职前端 | 0 | 无 |
正常兼职那段里有「可远程」「按页面结算」「试稿付费」,都没被误伤,这是我比较在意的一点。
标红:区间要先合并
每条规则跑一遍 exec,把命中位置收集成 [start, end] 区间,最后统一拼 HTML:
js
function scan(text) {
const hits = [], ranges = [];
RULES.forEach(r => {
r.re.lastIndex = 0;
const kws = new Set();
let m;
while ((m = r.re.exec(text)) !== null) {
if (!m[0]) { r.re.lastIndex++; continue; }
kws.add(m[0]);
ranges.push([m.index, m.index + m[0].length]);
}
if (kws.size) hits.push({ r, kws: [...kws] });
});
ranges.sort((a, b) => a[0] - b[0]);
const merged = [];
ranges.forEach(g => {
const last = merged[merged.length - 1];
if (last && g[0] <= last[1]) last[1] = Math.max(last[1], g[1]);
else merged.push([...g]);
});
let html = '', p = 0;
merged.forEach(([a, b]) => {
html += esc(text.slice(p, a)) + '<mark>' + esc(text.slice(a, b)) + '</mark>';
p = b;
});
return { hits, html: html + esc(text.slice(p)) };
}
不合并的话会出问题。刷单示例里有一句「新人进群领红包」,「先给甜头」规则命中整句,「私聊群」规则又命中中间的「进群」,两个区间重叠,直接按顺序插 <mark> 会切出半截标签。排序后合并重叠区间,是最省事的做法。
这里还有三个容易漏的细节:
- 带
g的正则是有状态的。RULES是全局常量,第二次点检测时lastIndex还停在上次的位置,会漏掉开头的命中。每次扫描前手动lastIndex = 0。 - 防零宽匹配死循环。现在的规则都不会匹配空串,但以后有人往词表里加个
.*?之类的分支,exec会在同一位置无限返回空匹配。if (!m[0]) lastIndex++是保险。 - 先转义再插标签。用户粘进来的是任意文本,切片要各自
esc之后再拼<mark>,不能先拼再转义,也不能不转义直接innerHTML。
非贪婪 + 距离限制
很多话术是两个词隔几个字出现的,比如「本金......佣金」「邀请......返」「新人......红包」。我统一写成 A.{0,8}?B:
js
/本金.{0,8}?佣金/
/邀请.{0,8}?(?:返|提成|奖励)/
/新人.{0,8}?(?:奖励|福利|红包)/
{0,8} 限制两个词最多隔 8 个字,避免跨句乱连;? 让它尽早结束,标红范围不会拖太长。测刷单示例时,「新人进群领红包」整段会被标出来,读起来反而更直观。
已知的误判
规则匹配的局限很明显。AI 接单那条示例里,「学员都已回本」被「保本 / 固定高收益」规则里的「回本」命中了,严格说这句是在晒成果,不是承诺保本。好在它同时命中了「报名费」,结论不受影响,但单独出现时就是误报。
反过来,骗子换个说法就能绕过去,比如把「押金」说成「诚意金」。所以工具里一直写着「话术会变」,词表也是在持续加的:有读者在评论里贴过一段「打卡 N 天全额退还学费」的话术,我就把「打卡N天退」「全额退还」「承诺可退」补进了先交钱那一类,原话贴进去现在是 70 分。
想做得更准,下一步是接一个大模型做语义判断,正则当第一道快速过滤。不过那样密钥就不能放前端了,得加个后端,这是后话。
工具在码上掘金上可以直接试,代码也都在里面。