脚本里的验证码不是风控:假阳性、降速与恢复原速
正常主页脚本源码里的「请输入验证码」让分类器如临大敌,默默触发了退避降速;排查假挑战之余,我们给面板补上了「恢复原速」按钮,并彻底梳理了断点恢复中的死锁。
昨天一位深度用户在实地抓取时报来了一连串让人匪夷所思的现象:
「resume 的时候出现了 challenge 的输出,但是实际上根本没看到验证码,打开豆瓣网页访问也完全正常? 然后好像每输出一次这个 log 都会自动降速一级?最主要的是遇到这个 error 它也没有停下来让我处理,而是降速继续抓…… 后来抓完了其他所有页面,它卡在最后一条,后台心跳不断刷屏『不自动恢复:豆瓣暂时限制了访问』,按继续毫无反应, 非得手动点一次暂停再点继续才终于跑完。」
这串症状把好几个不同模块的毛病串在了一起。借着这起典型现场,我们把分类器判定、节奏退避以及断点恢复的全链路做了一次彻底大修。
案发一:脚本代码里的「验证码」不是风控
在反爬对抗中,豆瓣不仅会返回 HTTP 403 或 418,经常还会以 HTTP 200 返回包含验证码或限制提示的阻断页。
因此豆备的分类器(classifier.js)必须审视 HTML 正文,一旦发现「请输入验证码」、「有异常请求」或「访问过于频繁」,
必须将其拦截并标记为 challenge 或 blocked,绝不能当作正常数据存进 WARC。
但问题就出在「审视正文」上:早先的实现直接用正则搜索整份 HTML 字符串。
豆瓣在未登录或正常公开个人主页中,嵌入了一段弹窗脚本:
这段脚本用于在用户点击「关注此人」时弹出人机校验弹窗。而在未压缩的 JavaScript 源码里,
白纸黑字写着:请输入验证码。
这段脚本只是一段躺在 <script> 标签里的静态源码,页面本身是一个 100% 正常的个人主页!
然而正则一扫,命中了关键字,分类器当场判定为 verdict: 'challenge'。
修复:只看渲染内容,不看沉睡的脚本。
我们在分类判定前引入 stripScriptAndStyle,先行抹掉 <script>、
<style>、<template> 以及所有 HTML 注释块。
脚本源码里的字符串不再构成风控证据。
案发二:遇到真挑战绝不能「偷偷降速继续抓」
排查顺带引出了一个更严重的违背设计行为:
为什么之前判定为 challenge 后,抓取循环没有停下来让人处理,反而降速继续抓其他页面?
翻看抓取主循环(loop.js):
早先在 awaiting_human 分支中,它只是调用了 pacer.slowDown() 并记录了事件,
但漏掉了调用 frontier.stop(),并直接返回了 'failed'。
这导致主循环以为这只是一次普通的单页抓取失败,于是接着从队列里取下一个条目继续发请求! 而在豆瓣已经明确阻断的情况下继续发起请求,正是把软限制升级成永久封号的标准危险路径。
我们在 loop.js 中将其纠正:遇到 awaiting_human,立即停机(this._frontier.stop(...))并中断批次循环,
同时将当前条目记录为带 URL 的等待缺口,保证整场抓取立即刹车,等待用户在真浏览器里人工处理。
新功能:给用户一把「恢复原速」的扳手
Pacer(节奏控制器)是豆备的防撞墙中枢,为了防御风控,它的退避层级设计为只增不减: 从基准的 1 秒依次翻倍到 2 秒、4 秒、8 秒……
这种保守在面对真实风控时是对的。但一旦遭遇偶发的假阳性、或短暂的网络抖动后, 抓取节奏被永远锁死在很慢的层级,几千张封面可能要爬上好几个小时。
我们在扩展面板概览中增加了 [ 恢复原速 ] 按钮:
- 只要当前处于降速状态(
backoffLevel > 0),无论是运行中还是暂停中,均醒目展示「已降速 N 级」与恢复按钮; - 后台提供安全的
resetPacerRPC 通道,一键将 Pacer 等级重置为 0(恢复 1 秒基准间隔),并同步更新到 OPFS 断点。
案发三:只剩等待条目时的心跳死锁
最精彩的则是最后那个死锁: 为什么抓到最后只剩 1 个等待条目时,后台心跳会不断循环「不自动恢复:豆瓣暂时限制了访问」, 点击「继续」毫无反应,非得手动「暂停」再「继续」才跑通?
沿着状态机排查,还原出了这条令人啼笑皆非的死锁链条:
| 步骤 | 发生了什么 | 深层后果 |
|---|---|---|
| 1. 队列只剩 1 条 | 其他 25 条封面全部成功,唯一剩下主页条目处于 awaiting_human。 |
frontier.hasReady() 为 false,驱动层判定这一批 done = true,但 stoppedBy 却是 null。 |
| 2. 后台盲目降级 | background.js 收到 done && awaitingHuman,见 stoppedBy 为空,直接降级为 pauseRun('blocked')。 |
Supervisor 镜像被记成永久封锁。根据策略,心跳每 30 秒判一次「不可自动恢复」。 |
| 3. 用户点击继续被拒 | 用户按下了「继续」,调用 runner.resume()。 |
在内存中,frontier.stopped 实际为 false!代码判定 if (!stopped) return alreadyRunning,直接退出了,没有把等待条目重排入队! |
| 4. 暂停打破死锁 | 用户被逼无奈,按了「暂停」,再按「继续」。 | 暂停把 frontier.stopped 强行置为了 true。下一次继续时,才终于触发了 frontier.clearStop({ resumeHuman: true }),把卡住的条目放回 pending,成功抓完收尾! |
找到了症结,修复便水到渠成:
- 停滞时显式停机:
runBatch当检测到无 ready 条目但存在awaitingHuman时,准确取其具体原因(如challenge)停机并返回stoppedBy,不再遗留 null。 - 内存恢复放行:
runner.resume()不再单纯以frontier.stopped为门槛,只要存在awaitingHumanItems,必须执行clearStop({ resumeHuman: true })将其重新放回队首。 - 断点冷恢复补齐:从本地磁盘断点重新拉起抓取时(
!loadOnly),同样调用clearStop({ resumeHuman: true }),杜绝死锁。 - 治愈连续性缺口:
awaiting_human条目采用带 URL 的recordGap记录,在用户解决验证码并成功重试抓取后,能够被resolveGap准确消除,完好无损地证明归档连续性。
一套在真实浏览器里跑出来的工具,最宝贵的就是这些在复杂多变的网络现实中撞出来的边角用例。 所有改动与 4 项新增回归测试现已合并入分支,感谢大家的细心反馈与现场日志!