Backend

网页抓取已经拿到正文,加上浏览器兜底后却被判为失败,排查记录

1676 字约 6 分钟

摘要普通 HTTP 请求已经抓到了导师的姓名和简介,只是浏览器解密邮箱时超时,整次抓取却被判定为失败。修正返回值之后,第二次请求仍然失败。

在 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. 写操作只保留一个出口: 不要让尝试性执行的分支自己写共享状态。缓存应该保存最终的决策结果,而不是中间的尝试过程。

评论