网页抓取已经拿到正文,加上浏览器兜底后却被判为失败,排查记录
1676 字约 6 分钟
摘要普通 HTTP 请求已经抓到了导师的姓名和简介,只是浏览器解密邮箱时超时,整次抓取却被判定为失败。修正返回值之后,第二次请求仍然失败。
在 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)是高可用系统中常见的模式:先用轻量方案,不行再切换到重量方案;或者主流程拿到骨架,支线做补充。
设计这类流程时,需要留意两个问题:
- 区分“补充”和“替代”: 如果下游的尝试只是锦上添花,下游崩溃时不应该影响上游的核心结果,应该降级为局部缺失,而不是整体报错;
- 写操作只保留一个出口: 不要让尝试性执行的分支自己写共享状态。缓存应该保存最终的决策结果,而不是中间的尝试过程。