清理 X (Twitter) 评论区的批量垃圾评论。按文案相似度聚类识别,本地隐藏 + 持久化黑名单 + 可选的批量拉黑。
垃圾评论是批量号发的,文案骨架固定、中间塞随机 emoji:
应该没人比我玩的开了吧😊🌽 我福不黑不信你看
应该没人比我玩的开了吧🖐🦉 我福不黑不信你看
应该没人比我玩的开了吧8️⃣❣️ 我福不黑不信你看
直接按原文匹配全部失效。X Clear 先把 emoji、标点、空白、零宽字符、全半角差异全部剥掉,
得到骨架 应该没人比我玩的开了吧我福不黑不信你看,再按相似度匹配。
黑名单里存的是规范化骨架,不是原文 —— 所以明天换一批新号发相似文案,照样命中。
需要 Chrome 111+(用到了 world: "MAIN" 的 content script)。
- 打开
chrome://extensions/ - 右上角开启「开发者模式」
- 点「加载已解压的扩展程序」,选择本目录
页面右下角会出现一个蓝色的 XC 按钮。
扫描页签 —— 点开自动扫描当前页面:
- 重复 / 相似文案:同屏出现 ≥2 次相同或相似文案的直接成簇,默认全选。不用你先挑一条,一次点击处理全部簇。
- 可疑账号:只出现一次、但账号画像可疑的(用户名像批量生成、昵称是短汉字+emoji、零互动、默认头像、粉丝极少), 单独列出需要人工确认,默认不选。
底部三个按钮:
| 按钮 | 行为 |
|---|---|
| 重新扫描 | 往下滚动加载更多评论后再扫一次 |
| 隐藏 N | 本地隐藏 + 文案指纹和账号入库。零风险、可撤销 |
| 隐藏并拉黑 N | 先做上面那步,再真实调用 X 的拉黑接口。会二次确认 |
黑名单页签 —— 查看/删除已记录的文案指纹和账号,看每条文案累计命中了多少次。
设置页签 —— 匹配参数、拉黑节流、导入导出。
顶部的 👁 按钮临时显示被隐藏的内容(半透明 + 红色虚线框),用来核对有没有误伤。
核心是相似度匹配,指纹精确匹配只是把见过的骨架瞬间挡掉的快速通道。每条评论按这个顺序判定,能早退就早退:
- 账号池 ——
O(1)哈希查找,处理过的号直接隐藏 - 指纹精确匹配 ——
O(1),规范化后的骨架哈希 - 相似度匹配 —— 两阶段:bigram Jaccard 粗筛(≥0.4)挡掉绝大多数,剩下的再算归一化编辑距离(默认阈值 0.85)
相似度匹配是抓变种的核心:改了字、塞了随机串的,都靠它命中。用 bigram 而不是 trigram,是因为中文一个字就是一个语义单位, trigram 在十几个字的短句上太脆——改两个字相似度就掉到 0.48 了。编辑距离的比值则直接对应「改了几个字」: 19 个字改 2 个 = 0.89,配 0.85 的阈值刚好抓住,而 6 个字改 1 个 = 0.83,不会被误判。
minLength 默认 6,规范化后短于 6 个字的评论不参与匹配,避免「哈哈哈」这种误伤。
扫描聚类同样以相似度为准,不只看指纹完全相同。 有一类刷屏是「骨架固定、中间塞随机字符」:
刷了半天的X fc就她的主页能打✈️了 0j
刷了半天的X ah就她的主页能打✈️了 3h
刷了半天的X rf就她的主页能打✈️了 3a
刷了半天的X af就她的主页能打✈️了 5i
中间 2 个字母和尾部 2 个字符都随机,规范化后指纹各不相同,纯精确聚类会让它们全部落单。
扫描时在指纹分组之后再加一道相似合并(bigram Jaccard 粗筛 + 编辑距离,默认 clusterSimilarity = 0.75),
把它们并进同一簇。
为什么聚类门槛(0.75)比自动隐藏门槛(0.85)松:聚类只是把候选摆到台面上给你确认,误判取消勾选即可; 自动隐藏是滚动时默默删掉,误伤代价高,必须严格。也正因如此,点「隐藏」时会把簇里每个不同的变体都入库 -- 变体之间相似度可能低于 0.85,只存一个代表的话同款重发会被自动隐藏漏掉,存全了才能精确命中。
「隐藏并拉黑」调用的是 POST /i/api/1.1/blocks/create.json,
这是 X web 端自己在用的内部接口,不是公开 API。自动化调用它违反 X 的服务条款,
理论上存在账号被限流甚至处置的风险。这是你自己的权衡。
代码里做了这些约束:
- 默认间隔 1500ms + 随机抖动,串行发送,不并发
- 遇到 429 读
Retry-After退避,读不到就等 60 秒 - 401/403(token 过期)立即中止整个队列
- 连续失败 5 次自动中止,不继续糟蹋账号
- 单次任务默认上限 100 个,可在设置里调
- 随时可以点「中止」
另外,拉黑只治标。这些是批量注册的号,你拉黑 7 个,明天换 7 个新号发同样的文案。 真正管用的是文案指纹池——所以即使开了拉黑,隐藏和入库也是先于拉黑执行的, 保证拉黑失败时本地至少已经干净了。
全部存在 chrome.storage.local,不上传任何地方。
不用 storage.sync 是因为它只有 100KB 配额还有写入频率限制,指纹池攒几个月就会撑爆。
换设备用设置页的导出/导入,导入是合并策略,不会覆盖,避免多设备互相清空。
manifest.json
src/
main-world/interceptor.js MAIN world:劫持 fetch/XHR 抓 GraphQL 响应
lib/normalize.js 规范化、指纹、bigram、编辑距离
lib/store.js 黑名单池(storage.local,防抖写入)
lib/collect.js DOM 采集、聚类、账号画像、匹配
lib/blocker.js 拉黑队列(节流、退避、中止)
content/panel.js 悬浮按钮 + 面板(shadow DOM)
content/index.js 主流程编排 + MutationObserver
test/
normalize.test.js
cluster.test.js
DOM 只用来定位「哪个节点要隐藏」。字段尽量用拦截到的 GraphQL 数据, 因为 DOM 里拿不到作者的数字 id(拉黑时用它比用 screen_name 更准,对方改名也不会打错人), 而且互动数在 DOM 里被缩写成了「1.2万」这种。
npm test用例直接取自实际遇到的垃圾评论,覆盖 emoji 变体收敛、干扰字符剥离、模糊匹配边界、 聚类,以及正常用户不被误判。
- X 改 DOM 结构或 GraphQL 字段时需要跟进维护
- 拦截器只认
/i/api/graphql/的响应;拿不到 API 数据时会退化成纯 DOM 采集,此时没有数字 id,拉黑走screen_name - 引用推文里的作者会被当成独立推文采集到(X 的 DOM 里它们也是
article) - 只处理
x.com和twitter.com顶层框架