豆备 DOUBAK
← 开发日志

脚本里的验证码不是风控:假阳性、降速与恢复原速

正常主页脚本源码里的「请输入验证码」让分类器如临大敌,默默触发了退避降速;排查假挑战之余,我们给面板补上了「恢复原速」按钮,并彻底梳理了断点恢复中的死锁。

昨天一位深度用户在实地抓取时报来了一连串让人匪夷所思的现象:

「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 级」与恢复按钮;
  • 后台提供安全的 resetPacer RPC 通道,一键将 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,成功抓完收尾!

找到了症结,修复便水到渠成:

  1. 停滞时显式停机:runBatch 当检测到无 ready 条目但存在 awaitingHuman 时,准确取其具体原因(如 challenge)停机并返回 stoppedBy,不再遗留 null。
  2. 内存恢复放行:runner.resume() 不再单纯以 frontier.stopped 为门槛,只要存在 awaitingHumanItems,必须执行 clearStop({ resumeHuman: true }) 将其重新放回队首。
  3. 断点冷恢复补齐:从本地磁盘断点重新拉起抓取时(!loadOnly),同样调用 clearStop({ resumeHuman: true }),杜绝死锁。
  4. 治愈连续性缺口:awaiting_human 条目采用带 URL 的 recordGap 记录,在用户解决验证码并成功重试抓取后,能够被 resolveGap 准确消除,完好无损地证明归档连续性。

一套在真实浏览器里跑出来的工具,最宝贵的就是这些在复杂多变的网络现实中撞出来的边角用例。 所有改动与 4 项新增回归测试现已合并入分支,感谢大家的细心反馈与现场日志!

← 更新:游戏评分成了访问者的:条目边界、缺席权威,与抓取中途的覆盖率 更早:叫「MewX的豆瓣存档」,以及多账号公开归档 →