Moonlight
← projects
已上线2026 年 9 月 20 日

翻牌违禁词检测

把口袋48 App 里拦翻牌的那套词库和匹配逻辑搬到网页上,发之前先标出会被拦的字眼。

Next.jsTypeScriptjadx

起因

在口袋48 里给成员发翻牌,偶尔会弹「此内容因涉嫌违反相关法律法规和政策,不可发送」,但它不告诉你是哪个词。改一个字再试、再改一个字再试,很烦。既然是 App 在本机拦的,那把它的判定搬出来就行。

它是怎么拦的

拆开 7.1.42 的安装包,敏感词这条链路很短:

  1. 词库不在安装包里,是启动时拉的。 App 每次启动调 GET /home/api/check/maskword?clientTime=<本地词库版本>,服务端回 { needUpdate, time, url }。需要更新就去 url 下载一份纯文本,一行一个词,存到本地的 maskword/ 目录,并把 time 记到本地配置里当版本号。这个接口不需要登录,词库文件放在公开的静态资源域名上。
  2. 匹配在本机做,是一棵前缀树。 词库读进来后逐字建成 Map<字, Map>,词尾打一个标记。检测时从文本的每个下标出发沿树往下走,途中只要经过过词尾就算命中,把命中的片段收进一个集合。
  3. 命中就拦,顺便上报。 有命中时弹那句提示、不发请求,然后把「哪个模块、原文、命中了哪些词」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 本机这一道拦截。成员端、服务端另外还有没有审核,从客户端看不到,这里不替它们打包票。