起因
在口袋48 里给成员发翻牌,偶尔会弹「此内容因涉嫌违反相关法律法规和政策,不可发送」,但它不告诉你是哪个词。改一个字再试、再改一个字再试,很烦。既然是 App 在本机拦的,那把它的判定搬出来就行。
它是怎么拦的
拆开 7.1.42 的安装包,敏感词这条链路很短:
- 词库不在安装包里,是启动时拉的。 App 每次启动调
GET /home/api/check/maskword?clientTime=<本地词库版本>,服务端回{ needUpdate, time, url }。需要更新就去url下载一份纯文本,一行一个词,存到本地的maskword/目录,并把time记到本地配置里当版本号。这个接口不需要登录,词库文件放在公开的静态资源域名上。 - 匹配在本机做,是一棵前缀树。 词库读进来后逐字建成
Map<字, Map>,词尾打一个标记。检测时从文本的每个下标出发沿树往下走,途中只要经过过词尾就算命中,把命中的片段收进一个集合。 - 命中就拦,顺便上报。 有命中时弹那句提示、不发请求,然后把「哪个模块、原文、命中了哪些词」
POST到sensitive/record。翻牌、成员回答翻牌、帖子评论、圈组消息、生日祝福、直播付费弹幕,全都走这一个函数,模块名不同而已。
词库现网 1.3 万多条(去重后),2023 年 11 月之后没再更新过。
两个要照抄的怪癖
照着算法写一遍不难,难的是和 App 判定完全一致。有两处如果按「常识」写就会对不上:
- 不做任何归一化。 词库怎么写就怎么比:区分大小写、不去空格。词库里有三个词自带尾随空格,只有你也多打一个空格才会命中它们。
- 命中长度是「走过的深度」,不是最后一个词尾的位置。 词库同时有
ab和abcd时,文本abc会整段命中abc:走到 b 已经过了词尾,c 还在树上,d 没了才停。所以网页上标出来的片段偶尔比词本身长一截,这不是 bug,App 就是这么判的。
网页版怎么做的
- 服务端一个加载器,按 App 的方式问
check/maskword要更新、把词库文件落盘,一天同步一次;接口不通就用磁盘上的旧词库,一份都没有才退到写死的兜底地址。词库条数低于一千直接拒绝覆盖,防止拉到错误内容把好的冲掉。 - 前端拉到词库后在浏览器里建树、在浏览器里比对,你输入的内容不出本机。1.3 万条建树 20 毫秒左右,逐字检测是微秒级的,所以可以边打字边标。
- 命中片段用区间标出来,右侧再列一遍去重后的词,和 App 上报时用分号拼起来的那份是同一个集合。
它只覆盖 App 本机这一道拦截。成员端、服务端另外还有没有审核,从客户端看不到,这里不替它们打包票。