Junie's Blog

网页抓取明明拿到了正文,加了浏览器兜底反而算失败的排查

全文共 1833预计阅读 7 分钟

在 Auto Email Sender 爬取高校导师主页时,我们通常遵循“轻量优先”原则: 绝大部分高校网站直接发普通 HTTP 请求,几毫秒内就能拿到完整的 HTML,姓名、职称、研究方向一览无余,既省带宽又省 CPU。

但有些高校的 CMS 相当顽皮,为了防垃圾邮件爬虫,把邮箱字段用 JavaScript 做了动态加密。页面刚返回时,邮箱位置只有一段类似 _tsites_encrypt_field 的占位符,必须等浏览器执行完一段 js 脚本,真邮箱才会动态渲染出来。

为此,爬虫流水线设计了一道“补强降级”: 一旦检测到静态 HTML 带有加密占位符,就自动启动无头浏览器(Chromium)把页面完整渲染一遍,把真邮箱挖出来。

设计初衷很美好:HTTP 打底拿骨架,浏览器兜底补细节。

但在线上跑批量任务时,一个诡异的反常现象出现了:明明第一次已经把导师的主页正文顺利抓下来了,但因为浏览器加载某些外部字体超时,整个任务竟然被判为了“抓取失败”!

为什么高校网站喜欢给邮箱加密?

高校教师邮箱属于公开但高价值的靶子,容易被各类黑产和发票群发软件骚扰。很多高校站长会用简单的异或混淆、Base64 变换或者动态请求注入邮箱。这也逼得现代爬虫不得不引入带 JS 运行时的无头浏览器作为兜底。

成功竟被失败“反向覆盖”

翻看当时的调度代码,我哭笑不得。

最初的实现把无头浏览器当成了“更高贵、更权威”的数据源:只要发现有加密标记,系统就全权委托给 Chromium。

在正常网况下这当然没问题,浏览器把邮箱解出来了,结果比静态抓取更丰富。

可无头浏览器是出了名的重型组件:遇到弱网卡顿、CDN 字体加载缓慢,或者目标页面 js 报错死锁,Chromium 极易超时崩溃。

此时系统内存里其实拿着两份快照:

  1. HTTP 快照: 抓取成功,导师名字、职称、研究方向全都拿到了,唯一遗憾是没拿到邮箱;
  2. 浏览器快照: 加载超时,状态为失败,内容一片空白。
通俗比喻:买菜送葱,葱没送成就把整车菜全扔了

这种逻辑漏洞就像生活里的荒唐事:

  • 你去菜市场买了一大车新鲜蔬菜(名字、简介全拿到了),老板说“等一下,我再附送你一根小葱(浏览器解密邮箱)”;
  • 结果老板在库房找小葱找了半分钟没找到(浏览器超时了);
  • 负责运货的司机(旧代码)一拍大腿:“小葱没拿到,这次买菜全盘失败!”然后当场把一整车新鲜蔬菜连车带货全推进了垃圾桶。

旧代码不分青红皂白,只因“最终由浏览器接盘”,就一脚把原本沉甸甸的 HTTP 成功快照踹开,把浏览器的这具“空壳尸体”当成最终结果返回给了调用方!

用一次不痛不痒的细节增强失败,直接抹杀了前面已经到手的核心成果。

我立刻做了第一层修复:逻辑很简单嘛,如果浏览器失败了,保留 HTTP 快照,把正文存下来,至多标记“暂缺邮箱”由后续流程另想办法。

单元测试跑了一遍,返回值漂亮地变成了成功,数据全在!

我以为问题解决了,顺手在本地对同一个 URL 重新点了下“测试”。

啪!控制台再次弹红:抓取失败。

第二次调用根本没走网卡

我当场愣住了。

赶紧去扒请求日志:更诡异的事情发生了——在第二次触发抓取时,终端里既没有发起任何 HTTP 网络请求,也没有 Chromium 启动的任何系统进程!

没有任何网络交互,它是怎么在 2 毫秒内瞬间失败的?

答案只有一个:它命中了本地缓存。

顺着写入缓存的调用链路逐级排查,真正的内鬼终于露出了马脚:

1. 发起 HTTP 请求 ──> 成功拿到 HTML (暂存内存)
2. 检测到加密邮箱 ──> 唤醒 Chromium
3. Chromium 加载超时 ──> 【偷跑】内部模块顺手把 "抓取失败" 写入了磁盘缓存!
4. 外层调度捕获异常 ──> 决定降级保底,把步骤 1 的成功数据返回给前台
5. 用户发起第二次抓取 ──> 优先读缓存 ──> 读到了步骤 3 留下的 "失败快照" ──> 当场去世

原来,浏览器模块在自己内部抛异常的时候,过于“敬业”地把失败结果提前持久化进了页面缓存。

而我外层的容灾修复,仅仅修复了当次函数的临时返回值,根本没有去覆写底层缓存里的脏数据!

返回值正确 ≠ 系统状态正确

在有本地持久化和多级缓存的复杂流程中,仅凭单次函数返回 status == 'success' 往往具有极强的迷惑性。必须连跑两次甚至多次,才能确认系统留下的“痕迹”没有被中间步骤污染。

谁才有资格拍板落库

这个 bug 的本质,不是少写了一句 cache.set(),而是系统权责的严重错位

HTTP 模块觉得自己拿到了网页,想写缓存;浏览器模块觉得自己渲染完了,也想写缓存;外层调度器还在旁边做裁判决定选谁。

当多个底层执行器都拥有“向外部持久化宣示主权”的权力时,数据不一致几乎是宿命。

我彻底重构了缓存的决策架构:

  1. 执行器剥夺落盘权: 无论是普通 HTTP 请求还是 Chromium 浏览器,统统降级为“无状态的单纯执行者”,它们只负责返回候选结果,严禁碰任何全局缓存和数据库账本;
  2. 决策权收归外层调度器: 只有负责串联所有降级策略的外层仲裁者,在统揽全局、做完比较和保底决策后,才有资格向缓存写入最终的“法理事实”;
  3. 别名 URL 同步收敛: 重定向跳转前后的 URL、带锚点的 URL,由外层统一提取 Canonical URL 批量绑定,绝不给脏数据留下任何死角。
系统层级改造前职责改造后职责
HTTP 模块抓取 + 自己偷偷写缓存仅负责发送请求,返回候选快照
Browser 模块渲染 + 自己偷偷写缓存仅负责无头渲染,返回候选快照
外层仲裁调度器仅仅做临时返回值转发统管比较决策,唯一拥有缓存落盘权

总结

多级降级(Fallback)是高可用系统里最常见的模式:先上轻量方案,不行切重量方案;或者主线拿骨架,支线做增强。

但在设计这类管道时,必须时刻警惕两个陷阱:

  1. 分清“增强”与“替代”: 如果下游尝试只是锦上添花,下游崩溃时绝对不能拖累上游的核心资产,要优雅地降级为局部缺失,而不是整体报错;
  2. 收拢写操作的单一出口: 永远不要让尝试性执行的分支自己去写共享状态。缓存的是最终裁决,而不是中间的折腾过程。

评论