网页抓取明明拿到了正文,加了浏览器兜底反而算失败的排查
在 Auto Email Sender 爬取高校导师主页时,我们通常遵循“轻量优先”原则: 绝大部分高校网站直接发普通 HTTP 请求,几毫秒内就能拿到完整的 HTML,姓名、职称、研究方向一览无余,既省带宽又省 CPU。
但有些高校的 CMS 相当顽皮,为了防垃圾邮件爬虫,把邮箱字段用 JavaScript 做了动态加密。页面刚返回时,邮箱位置只有一段类似 _tsites_encrypt_field 的占位符,必须等浏览器执行完一段 js 脚本,真邮箱才会动态渲染出来。
为此,爬虫流水线设计了一道“补强降级”: 一旦检测到静态 HTML 带有加密占位符,就自动启动无头浏览器(Chromium)把页面完整渲染一遍,把真邮箱挖出来。
设计初衷很美好:HTTP 打底拿骨架,浏览器兜底补细节。
但在线上跑批量任务时,一个诡异的反常现象出现了:明明第一次已经把导师的主页正文顺利抓下来了,但因为浏览器加载某些外部字体超时,整个任务竟然被判为了“抓取失败”!
为什么高校网站喜欢给邮箱加密?高校教师邮箱属于公开但高价值的靶子,容易被各类黑产和发票群发软件骚扰。很多高校站长会用简单的异或混淆、Base64 变换或者动态请求注入邮箱。这也逼得现代爬虫不得不引入带 JS 运行时的无头浏览器作为兜底。
成功竟被失败“反向覆盖”
翻看当时的调度代码,我哭笑不得。
最初的实现把无头浏览器当成了“更高贵、更权威”的数据源:只要发现有加密标记,系统就全权委托给 Chromium。
在正常网况下这当然没问题,浏览器把邮箱解出来了,结果比静态抓取更丰富。
可无头浏览器是出了名的重型组件:遇到弱网卡顿、CDN 字体加载缓慢,或者目标页面 js 报错死锁,Chromium 极易超时崩溃。
此时系统内存里其实拿着两份快照:
- HTTP 快照: 抓取成功,导师名字、职称、研究方向全都拿到了,唯一遗憾是没拿到邮箱;
- 浏览器快照: 加载超时,状态为失败,内容一片空白。
通俗比喻:买菜送葱,葱没送成就把整车菜全扔了这种逻辑漏洞就像生活里的荒唐事:
- 你去菜市场买了一大车新鲜蔬菜(名字、简介全拿到了),老板说“等一下,我再附送你一根小葱(浏览器解密邮箱)”;
- 结果老板在库房找小葱找了半分钟没找到(浏览器超时了);
- 负责运货的司机(旧代码)一拍大腿:“小葱没拿到,这次买菜全盘失败!”然后当场把一整车新鲜蔬菜连车带货全推进了垃圾桶。
旧代码不分青红皂白,只因“最终由浏览器接盘”,就一脚把原本沉甸甸的 HTTP 成功快照踹开,把浏览器的这具“空壳尸体”当成最终结果返回给了调用方!
用一次不痛不痒的细节增强失败,直接抹杀了前面已经到手的核心成果。
我立刻做了第一层修复:逻辑很简单嘛,如果浏览器失败了,保留 HTTP 快照,把正文存下来,至多标记“暂缺邮箱”由后续流程另想办法。
单元测试跑了一遍,返回值漂亮地变成了成功,数据全在!
我以为问题解决了,顺手在本地对同一个 URL 重新点了下“测试”。
啪!控制台再次弹红:抓取失败。
第二次调用根本没走网卡
我当场愣住了。
赶紧去扒请求日志:更诡异的事情发生了——在第二次触发抓取时,终端里既没有发起任何 HTTP 网络请求,也没有 Chromium 启动的任何系统进程!
没有任何网络交互,它是怎么在 2 毫秒内瞬间失败的?
答案只有一个:它命中了本地缓存。
顺着写入缓存的调用链路逐级排查,真正的内鬼终于露出了马脚:
1. 发起 HTTP 请求 ──> 成功拿到 HTML (暂存内存)
2. 检测到加密邮箱 ──> 唤醒 Chromium
3. Chromium 加载超时 ──> 【偷跑】内部模块顺手把 "抓取失败" 写入了磁盘缓存!
4. 外层调度捕获异常 ──> 决定降级保底,把步骤 1 的成功数据返回给前台
5. 用户发起第二次抓取 ──> 优先读缓存 ──> 读到了步骤 3 留下的 "失败快照" ──> 当场去世原来,浏览器模块在自己内部抛异常的时候,过于“敬业”地把失败结果提前持久化进了页面缓存。
而我外层的容灾修复,仅仅修复了当次函数的临时返回值,根本没有去覆写底层缓存里的脏数据!
返回值正确 ≠ 系统状态正确在有本地持久化和多级缓存的复杂流程中,仅凭单次函数返回
status == 'success'往往具有极强的迷惑性。必须连跑两次甚至多次,才能确认系统留下的“痕迹”没有被中间步骤污染。
谁才有资格拍板落库
这个 bug 的本质,不是少写了一句 cache.set(),而是系统权责的严重错位。
HTTP 模块觉得自己拿到了网页,想写缓存;浏览器模块觉得自己渲染完了,也想写缓存;外层调度器还在旁边做裁判决定选谁。
当多个底层执行器都拥有“向外部持久化宣示主权”的权力时,数据不一致几乎是宿命。
我彻底重构了缓存的决策架构:
- 执行器剥夺落盘权: 无论是普通 HTTP 请求还是 Chromium 浏览器,统统降级为“无状态的单纯执行者”,它们只负责返回候选结果,严禁碰任何全局缓存和数据库账本;
- 决策权收归外层调度器: 只有负责串联所有降级策略的外层仲裁者,在统揽全局、做完比较和保底决策后,才有资格向缓存写入最终的“法理事实”;
- 别名 URL 同步收敛: 重定向跳转前后的 URL、带锚点的 URL,由外层统一提取 Canonical URL 批量绑定,绝不给脏数据留下任何死角。
| 系统层级 | 改造前职责 | 改造后职责 |
|---|---|---|
| HTTP 模块 | 抓取 + 自己偷偷写缓存 | 仅负责发送请求,返回候选快照 |
| Browser 模块 | 渲染 + 自己偷偷写缓存 | 仅负责无头渲染,返回候选快照 |
| 外层仲裁调度器 | 仅仅做临时返回值转发 | 统管比较决策,唯一拥有缓存落盘权 |
总结
多级降级(Fallback)是高可用系统里最常见的模式:先上轻量方案,不行切重量方案;或者主线拿骨架,支线做增强。
但在设计这类管道时,必须时刻警惕两个陷阱:
- 分清“增强”与“替代”: 如果下游尝试只是锦上添花,下游崩溃时绝对不能拖累上游的核心资产,要优雅地降级为局部缺失,而不是整体报错;
- 收拢写操作的单一出口: 永远不要让尝试性执行的分支自己去写共享状态。缓存的是最终裁决,而不是中间的折腾过程。