你有过这种瞬间吗?收藏夹里躺了很久的文章终于打开,正文只出现两行,剩下的全是登录墙;想下载一个工具包,点下载按钮被弹到注册页;更别提那些等你鼠标刚挪到浏览器顶部就弹出来的订阅框,每次都要手动去找那个藏在角落的关闭按钮。最让我爆炸的是自己搭的社区,一天能收到几千个注册,用户名全是乱码,纯纯的脚本灌水。正是在这种状态下,我写了 FckSignups。名字糙是糙了点,但当时真的签到烦了——与其忍,不如直接干掉它。
FckSignups 本质上是一个跑在浏览器里的用户脚本,用“特征识别 + 动态 DOM 监听 + 动作分发”的方式,把网页里那些“不注册不让你好好看”的弹窗、注册表单、遮罩层自动移除或拦截;同一套打分思路稍微改改,还能用到服务端做反垃圾注册过滤。下面会给出完整的可运行代码、特征权重表、踩坑记录和调试方法,适合被垃圾注册困扰的站点管理员,也适合单纯想在浏览网页时少被打扰的普通用户。
1. 先看清敌人:四种典型的注册骚扰
1.1 注册墙:内容被账号锁死
打开文章只显示开头两段,随后全屏遮罩,按钮写着“注册账号继续阅读”。这类模式在资讯站和文档站最常见。关键特征:全屏或半屏的 overlay、body 的 overflow 被改成 hidden、以及一个包含邮箱和密码的表单。对这类,FckSignups 的处理是:检测到表单打分超过阈值后,向上找最近的 modal/popup/overlay 容器直接移除,并把 body 滚动恢复。注意不是简单地 display:none,而是 remove(),把节点从 DOM 里彻底干掉,避免那些在关闭按钮上挂状态监听的前端代码继续生效。
1.2 订阅弹窗:等你鼠标挪出页面再出手
订阅新闻信(newsletter)弹窗通常是“离开意图”触发:你的鼠标刚移到浏览器顶部,系统判定你要关页面,瞬间弹出一个“留下你的邮箱吧”。这类弹窗是后来注入的 DOM,初始 HTML 里没有,所以暴力清除脚本如果只在页面加载时扫一遍,3 秒后弹窗插进来,扫描早就结束了。这就是为什么必须用 MutationObserver 持续盯着页面变化,等弹窗节点插进来的第一时间就去处理。
1.3 伪装成下载/查看的注册引导
“点击下载”按钮实际是个 a 链接,跳转到注册页面;也有“阅读全文”按钮,进去以后还是注册。对于这类,FckSignups 里有个特殊的 redirect 模式:如果链接的 href 指到 signup/register/join 等路径,脚本可以阻止默认跳转并尝试回退到原文链接。但下面这条边界非常重要:如果资源本身必须登录才能下载,这是站点的规则,脚本不去伪造会话、不去绕服务端鉴权,只处理前端的“诱导层”。比如下载按钮被引导到一个纯注册页而资源其实公开可用,这种场景可以救回来;如果后端真正做了权限控制,前端再怎么拦都白搭,那就不该和站点规则硬刚。
1.4 账号农场:不是打扰你,是污染你的数据库
还有一类“注册骚扰”不是面向普通浏览者的,而是冲着你服务器的注册接口来的。机器人用随机邮箱和用户名批量创建账号,主要用来发广告、刷评论、刷权重。解决思路跟前端拦截完全两回事:得在服务端接一层过滤器,用蜜罐字段、提交耗时、邮箱域名黑名单、频率限制去挡住。这个我在第 4 节单独讲,虽然和 FckSignups 名字同源,但它真正的位置在你的后端中间件里。
2. 怎么判断一个表单到底该不该拦
2.1 无脑删表单一定会翻车
写第一版的时候,图省事,写的是“只要页面上出现 form 就全删”。结果嘛,第一个真实页面就把登录框干掉了,用户想登录发现表单没了,直接懵。原因很简单:登录框也是 email + password,也是 form,表面上看和注册框长得一模一样。如果按“有邮箱有密码就删”的规则,那全站都不用登录了。因此必须给表单做特征打分:不是非黑即白,而是算出一个分数,超过阈值才动手。
2.2 特征打分表
我的实现里,分数是加权叠加的。下表是核心特征和权重,实际用的时候可以根据你经常访问的站点类型调整:
| 特征 | 权值 | 说明 |
|---|---|---|
| action、class 或 id 含 signup / register / join / subscribe | +4 | 强信号,直接标明注册意图 |
| 表单可见文本含“注册 / 创建账号 / 立即加入 / 免费订阅” | +2 | 中文站点上的强信号 |
| 同时存在 email 输入框和密码输入框 | +2 | 注册和登录都会命中,只能算中等信号 |
| 存在 autocomplete="new-password" | +2 | 新密码提示,注册场景的标志 |
| 存在同意条款 checkbox | +1 | 注册流程常要求勾选协议 |
| 存在“忘记密码”链接或 autocomplete="current-password" | -3 | 登录/找回特征,直接降权 |
| action 等属性含 login / signin | -2 | 明确是登录,不该拦 |
阈值我默认设成 8。有些新闻站订阅框打得非常准,邮箱 + 订阅按钮 + 大遮罩,分数轻松过 10;而登录框通常会被“忘记密码”和 current-password 拉到 2~3 分,稳稳放行。
2.3 MutationObserver:等弹窗自己冒出来
很多注册弹窗不是页面初始就有的,而是在定时器或“离开意图”触发后才注入到 DOM。所以脚本不能只跑一遍,得持续观察页面变化。MutationObserver 是浏览器原生 API,性能比 setInterval 轮询好很多,去监听 childList 和 subtree,有节点插入就重新扫描一次。但要注意:一插就是几十个节点,如果每次都全量扫描所有 form 会有点浪费,因此我外面包了一层 300ms 的防抖,把同一批插入合并成一次处理。
3. 核心代码拆解:FckSignups 的拦截器
3.1 脚本主入口
先上是完整可用的一版,Tampermonkey / Greasemonkey 都能跑:
// ==UserScript== // @name FckSignups // @namespace local.fcksignups // @version 0.4.2 // @description 移除并拦截网页里的注册弹窗和注册表单,保留登录/找回密码 // @match http://*/* // @match https://*/* // @grant none // @run-at document-idle // ==/UserScript== (() => { 'use strict'; const CFG = { threshold: 8, debug: true, whitelist: ['github.com', 'news.ycombinator.com'], }; function isWhitelisted(host) { return CFG.whitelist.some(w => host === w || host.endsWith('.' + w)); } function scoreForm(form) { let score = 0; const attrs = [ form.getAttribute('action') || '', form.id || '', form.className || '' ].join(' ').toLowerCase(); const text = (form.textContent || '').toLowerCase(); ['signup', 'sign-up', 'register', 'create-account', 'join', 'subscribe', 'newsletter', 'member'] .forEach(w => { if (attrs.includes(w)) score += 4; }); ['注册', '创建账号', '立即加入', '免费订阅', '加入'] .forEach(w => { if (text.includes(w)) score += 2; }); const email = form.querySelector('input[type="email"], input[name*="email" i]'); const pass = form.querySelector('input[type="password"]'); const newPass = form.querySelector('input[autocomplete="new-password"]'); const currentPass = form.querySelector('input[autocomplete="current-password"]'); if (email && pass) score += 2; if (newPass) score += 2; if (email && !pass) score += 1; if (form.querySelector('input[type="checkbox"]')) score += 1; if (currentPass || form.querySelector('a[href*="forgot"], a[href*="reset"]')) score -= 3; ['login', 'signin', 'log-in', 'sign-in'].forEach(w => { if (attrs.includes(w)) score -= 2; }); return score; } function findOverlay(el) { return el.closest('div[id*="modal" i], div[class*="modal" i], div[class*="popup" i], div[class*="overlay" i], div[class*="lightbox" i], div[role="dialog"]'); } function restoreScroll() { document.documentElement.style.overflow = ''; document.body.style.overflow = ''; } function actor(form) { if (isWhitelisted(location.hostname)) return; const score = scoreForm(form); if (CFG.debug) console.info('[FckSignups] form scored', score, form.action || form.id); if (score < CFG.threshold) return; const overlay = findOverlay(form); if (overlay) { overlay.remove(); restoreScroll(); return; } form.addEventListener('submit', ev => { ev.preventDefault(); ev.stopPropagation(); console.warn('[FckSignups] blocked signup submit'); }, true); } function scan() { document.querySelectorAll('form').forEach(actor); } function debounce(fn, wait) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); }; } const scanner = debounce(scan, 300); new MutationObserver(scanner).observe(document.documentElement, { childList: true, subtree: true, }); scan(); })();3.2 打分函数为什么这么写
核心是 scoreForm。它把所有“文案特征”和“行为特征”捏在一起算一个总分,而不是用单个条件做决定。这里有个细节:判断邮箱输入框时用了[name*="email" i],带上 i 标志做不区分大小写匹配。很多站点的输入框 name 写的是 Email 或 EMAIL,前两版脚本就是栽在这里,全小写匹配会漏掉。
还有一点,登录框和注册框都可能有 email + password,只靠这个判断一定误杀。所以代码里特别检查了 current-password。按 HTML 规范,登录表单的密码框应该是autocomplete="current-password",注册/改密表单才是new-password。现在主流框架基本都按这个标准写,靠着它可以把登录框稳稳降权。
3.3 三种动作怎么选
- remove:表单在弹窗里,整颗弹窗节点直接 remove,顺手恢复 body 滚动。前提是 findOverlay 能通过 id/class 匹配到外层容器。匹配范围故意写得宽一些,涵盖 modal、popup、overlay、lightbox、role="dialog",但用带 i 的不区分大小写匹配避免漏掉
Modal这种写法。 - block:表单是页面内嵌的、找不到外层弹窗容器时,不删节点,而是给 submit 绑定捕获阶段的监听,阻止提交。为什么用第三个参数 true?因为这样可以抢在页面自己的 submit 处理之前拦截,尤其在事件里同时调用了 stopPropagation 的时候,能第一时间断掉后续提交链路。
- skip:第 1.3 节提到的“跳转到注册页”的链接,在完整版里可以同时监听 a[href] 的 click,检测 href 是否命中 signup/register/join 类路径,再决定是否放行。这属于配置型能力,默认关闭,避免误伤正常注册入口。
4. 同一套思路搬到服务端:反垃圾注册过滤器
4.1 蜜罐字段
原理:往注册表单里塞一个用户绝对看不到、正常用户也不会填的隐藏字段,比如“公司网站”或“确认邮箱”,用绝对定位移出可视区,同时加 tabindex="-1"、aria-hidden="true" 防止被辅助技术读到。真人会忽略它,机器人会无脑把每个字段都填了。服务端收到请求后只要发现这个字段有值,直接判定机器人,不发验证邮件、不写库,静默拒绝。
为什么不能简单用 display:none?因为不少机器人只识别“可见”字段,display:none 的字段反而会被跳过;用移出视口的方案在视觉和语义上都是隐藏的,但字段真实存在于表单里,机器人反而更容易踩中。
4.2 邮箱域名黑名单
垃圾注册最常见的模式是使用一次性邮箱域名。社区站点可以维护一个名单,在注册接口里校验邮箱后缀命中就拒绝或转人工。但要提醒的是,硬拒绝会误伤一部分用隐私邮箱的真实用户。我的做法是:命中黑名单时不直接 403,而是提示“为了安全,该邮箱需要额外验证”,发送包含动态链接的确认邮件,把门槛提高,但不彻底关死。
4.3 时间陷阱
人在表单里正常填邮箱、密码、点击同意协议,哪怕手速再快也得 3 秒以上;机器人提交往往在 1 秒内。做法:前端在表单渲染时把一个毫秒时间戳存进隐藏字段,服务端拿当前时间减掉这个值,小于 2~3 秒的请求直接打回。这里有个坑:时间戳必须是表单 render 时生成,不能是页面 load 时生成,否则用户挂机 10 分钟再提交,时间差会被误判成机器人。更稳妥的做法是同时记录前端三个里程碑:渲染、首次输入、提交,三个差值一起看。
4.4 频率限制的平衡点
对同一 IP 或同一邮箱做滑动窗口限流,比如 60 分钟内最多 3 次注册。超过就要求走 CAPTCHA 或邮箱验证。太严格会误伤公司局域网、校园网这类出口 IP 共享的场景;太松又拦不住批量脚本。折中的做法是:注册接口本身就严格要求蜜罐 + 时间陷阱,限流只作为最后一道兜底,阈值可以放宽到 10 次/小时。
5. 实战踩坑:误杀登录框、SPA、shadow DOM
5.1 误删登录表单的翻车现场
第一版上线当天就收到反馈:某知识社区用户点登录,弹窗闪了一下就消失了。排查后发现那站点登录弹窗的容器 class 竟然是register-wrap,这是历史遗留样式,我们的打分函数看到 register 直接加了 4 分,再加上 email+password 的 2 分,总分过了阈值,整颗弹窗被移除。修复方式就是加 negative 特征:登录框里的“忘记密码”链接和current-password自动补全必须能压过注册词。现在规则是先算加分,再算减分,总分为负的不动,这是调试日志里最先要看的指标。
5.2 SPA 路由切换之后为什么不灵了
React、Vue 这类单页应用可以只替换页面局部 DOM,不像传统页面整页刷新。理论上 MutationObserver 挂着就能兜底,但实际会碰到两个麻烦:一是路由切换瞬间的频繁 mutation 会导致防抖扫描被不断推迟,弹窗在扫描间隔里出现又消失,就被跳过了;二是很多团队把弹窗组件做了懒加载,组件 chunk 加载完成后才在 rAF 里插入 DOM,如果你的扫描下一次执行正好在 chunk 加载之前,就会漏掉这一次,直到弹窗再次被触发。所以除了 observer,还要在路由变化后主动 scan 一次作为补位,监听 popstate 和 hashchange,必要时 monkey-patch 一下 history.pushState。
5.3 shadow DOM 和 iframe 里的表单
普通 querySelectorAll 根本穿不进 shadow DOM。现在不少前端组件库把弹窗封装在自定义元素里,外层的 document 选择器看不到里面的 form。解决方法是递归遍历 open shadow root:
function scanIn(root = document) { root.querySelectorAll('form').forEach(actor); root.querySelectorAll('*').forEach(el => { if (el.shadowRoot) scanIn(el.shadowRoot); }); }iframe 则是另一层限制。加脚本元数据里的@all-frames,可以覆盖同源 iframe;跨域 iframe 由于安全协议无法从外部操作,这种情况就交给各自 iframe 内部运行的用户脚本处理。
5.4 调试技巧:先开日志,再开火力
敏感操作最忌讳一上来就是 remove。我的建议是首次使用把 CFG.debug 设为 true,脚本只打分、只打日志,不删除、不拦截,跑一周。浏览器控制台里会留下所有表单的 score,把那些“你本来要正常登录”却被打了高分的表单记录下来,调完权重,再切换成拦截模式。这种方式比反复上线测试版效率高很多,因为你同时看到了正反两面的证据。
6. 安装、配置和边界
6.1 三种使用方式
最标准的是 Tampermonkey 扩展:新建脚本 → 粘贴上面的代码 → 保存 → 刷新页面。手机上可以装 Firefox + Tampermonkey,或者 Kiwi 浏览器 + Tampermonkey,效果一致。如果你只是临时想在某个页面试一下,打开开发者工具 Console,把去掉 UserScript 元信息的主逻辑贴进去也能跑,缺点是刷新就没了。
Whitelist 配置要主动用。像 GitHub、Stack Overflow、语雀这类我本来就登录并信任的站点,直接加入 whitelist 跳过全部检查,既省性能也避免误伤。默认配置里已经放了一部分,按自己常访问的站点继续加。
6.2 强烈不建议用的场景
银行、政务、医疗这类有实名和合规要求的平台,注册流程不只是流程,更是法定义务。脚本的粗暴删除可能让你漏掉重要的服务条款、实名认证提示,甚至影响后续密码找回。真遇到这类站点,把域名加进 whitelist 或者干脆别装脚本。另外,再次强调:FckSignups 处理的是“打扰型”注册弹窗和垃圾注册,不是破解工具。站点如果靠服务端权限控制强制登录,前端删掉表单只会让你什么都点不了,没有任何收益,还违反网站的使用协议。
6.3 后续能扩展的方向
如果这个项目真的被更多人用起来,我打算把特征库做成可订阅的规则包,按“资讯站”“文档站”“社区站”分类,大家直接导入别人调好的权重;再用 GM_setValue 做一个简易设置面板,阈值、白名单、动作模式都能在扩展菜单里改,不用每次编辑代码。服务端过滤器那边也可以抽成一个中间件包,让 Nginx 或 Express 应用一条指令接入。
装完脚本用到现在,大概三周。最大的体会不是“弹窗少了”,而是我重新拥有了对页面的控制权——我想注册才注册,而不是被网站逼着注册。开发 FckSignups 的过程也让我养成一个习惯:凡是自动执行的东西,一定要有日志模式,先观察,再下手。如果你准备照这套思路做一个类似工具,我的建议很简单:第一版绝不删除,只记录;把阈值和特征权重调稳了,再打开真正的暴力开关。就这个节奏,能帮你省下后面至少两轮误杀的麻烦。