一次网页抓取流量异常排查
Auto Email Sender 是一个本地运行的导师联系工具。用户可以在里面整理个人材料,从高校官网抓取导师资料,分析研究方向是否匹配,再生成和审核邮件草稿;真正发送给谁、什么时候发送,始终由用户确认。邮件发出以后,系统还会继续追踪回复和后续任务。
其中,“智能抓取”负责整个流程最前面的一段:用户给出某个学院的教师列表页,系统自动找到导师主页,再整理姓名、职称、研究方向和邮箱。如果正文没有邮箱,还会继续检查页面里的 PDF、图片文字和少量相关链接。
从功能上看,这只是为后续匹配与写信准备公开文本。可在一次批量抓取运行期间,我打开 FlowWatch,看到负责渲染页面的 chrome-headless-shell 在过去 12 小时里下载了 10.202 GiB,其中约 4.8 GiB 集中在两个小时内。
我的第一反应是统计可能出了问题。
抓的是导师姓名、研究方向和邮箱,又不是在后台播放视频。几段网页文字,怎么会用掉 10 GB?
10 GB 的来源
我先把 FlowWatch 里的进程记录与应用自己的页面抓取账本对在一起。流量指向的正是项目随 Playwright 启动的 Chromium,不是系统里的其他浏览器,也不是监控把流量归错了进程。
接着,抓取账本给出了更不正常的一组比例:12 小时内共有 6,353 条页面访问记录,其中 5,502 条使用了浏览器,约占 86.6%;还有 1,726 条访问指向已经出现过的页面,重复比例约为 27.2%。
具体任务更直观。福州大学化学学院的一次抓取产生了 602 次页面访问,而且全部使用 Chromium。另一个任务只有 104 个不同的导师主页,却留下了 202 条导师主页访问记录。某个学院公共目录更是在同一次任务里被加载了 68 次:第一次来自正常入口,后面 67 次都发生在邮箱补全阶段。
此时最显眼的嫌疑是并发。多个任务同时运行,确实会让浏览器进程在短时间内集中下载大量内容。
但这个解释只能说明“为什么峰值这么高”,解释不了“为什么总量这么大”。把十次重复下载放慢执行,带宽曲线会变平,十份内容却仍然要下载。
并发放大了异常,却不是异常产生的地方。
我只能继续沿着一次抓取真正经过的路径往下找。
被放大的访问策略
抓取器同时支持普通 HTTP 请求和 Chromium。HTTP 适合直接返回完整 HTML 的页面,速度快、传输内容也少;Chromium 可以执行 JavaScript,用来处理动态渲染、交互式分页和加密字段。
两种方式本来各有用途。问题出在一个为了提高成功率而留下的“经验”上:只要某个学院目录曾经需要浏览器,系统就会记住这个域名更适合使用 Chromium。之后,同一域名下的导师主页也会直接交给浏览器。
我最初把这当作合理的站点能力判断。实际看过几个高校网站后才发现,目录页和导师主页往往完全不是一套技术:
- 教师列表可能依赖 JavaScript 分页;
- 导师主页却可能是服务端直接返回的静态 HTML;
- 同一个域名下,甚至还混有旧版网站和新版网站。
“这个目录页需要浏览器”被扩大成了“这个网站的所有页面都需要浏览器”。一次目录页判断,最终让数百个原本可以普通请求的导师主页启动了 Chromium。
第一项修改由此确定:目录页和导师主页分别记录访问策略;导师主页先尝试普通 HTTP,只有返回内容为空、被拦截或确实依赖脚本时,才使用浏览器。
为什么不直接停用 Chromium?一些教师目录确实依赖动态渲染、交互式分页或 JavaScript 处理后的字段。问题不在于使用浏览器,而在于把一个页面的判断扩大到整个域名。
这减少了浏览器启动次数,却还没有解释一个页面为什么能产生十几兆流量。
浏览器多做的工作
我继续查看传输内容,发现 Chromium 并没有做错什么。它只是像正常浏览器一样,忠实地下载头像、背景图、轮播图、字体、样式和媒体资源,努力把页面完整还原出来。
爬虫最后却只读取正文和链接。
某个学院页面的 HTML 本身并不大,一组字体资源却让单次访问产生了约 11 MiB 下载量。对于浏览网页的人,这些字体决定页面看起来是否正确;对于只想找到研究方向和邮箱的抓取器,它们没有提供任何新信息。
于是我开始按资源类型限制浏览器请求:字体和音视频不再下载,图片请求返回一个 1×1 的透明占位图;HTML、CSS、JavaScript、接口请求和 iframe 仍然正常加载。
写到图片拦截时,我立刻遇到一个矛盾:抓取流程本身支持图片 OCR。如果图片都被替换掉,邮箱图片还怎么识别?
答案在两条流程的边界里。浏览器加载图片,是为了渲染整张网页;OCR 只需要少量真正可能包含联系方式的原图。页面 HTML 中的 src、data-src 等地址仍然存在,因此系统可以在确实需要 OCR 时,再通过独立请求下载候选原图。
图片没有消失浏览器渲染阶段使用极小的透明占位图,OCR 阶段再按需下载候选原图。这样既不会让每个页面自动下载所有装饰图片,也不会失去识别图片邮箱的能力。
这个调整解决了“每次访问太重”,可抓取账本里那 27.2% 的重复访问仍然存在。
被重复打开的页面
顺着邮箱补全流程继续检查,我发现页面第一次抓取完成后,后续步骤只拿到了一段整理过的文本。原始 HTML、页面链接、图片地址和最终 URL 都没有一并传下去。
大多数时候,一段文本已经够用;一旦文本里没有邮箱,补全流程就需要检查 PDF、图片和相关链接。为了拿回这些信息,它只能再次打开导师主页。
更糟的是,模型有时会把学院公共目录或“联系方式”页面选作继续寻找邮箱的入口。几十位导师可能都指向同一个公共页面,如果当前任务没有缓存,这个页面就会随着每位导师重新下载一次。前面那 68 次目录加载,正是这样出现的。
此时,流量异常终于连成了一条完整因果:
- 域名级判断让大量静态导师页直接使用浏览器;
- 浏览器为每个页面下载完整的视觉资源;
- 第一次抓取只留下文本,后续补全不得不重新打开页面;
- 多位导师引用同一个公共页面时,相同请求继续重复;
- 网络失败和多轮重试又把前三项消耗一起放大。
真正需要保存的不是一个更长的文本字段,而是第一次访问得到的完整页面记录:最终 URL、访问状态、正文、原始 HTML、链接和实际采用的抓取方式。后续的 PDF、OCR 和相关页面筛选都从这份记录继续,不再为了“看一眼 HTML”重新打开导师主页。
对于同一任务中反复出现的 URL,系统会复用已有结果;如果多个步骤同时请求相同页面,只发起一次实际访问,其余步骤等待并共享结果。已经确认是学院目录的页面,也不再被当作某位导师的个人补充页面。
结果复用原则一次页面访问得到的信息,应当足够后面的步骤继续工作。
流量下降,内容不变
修改完成后,最容易得到的是一张更好看的流量图。但下载量下降并不能证明抓取器变好了——也可能只是正文、链接或动态内容一起被拦掉了。
我选了六个结构不同的高校公开页面,让优化前后的逻辑分别访问,并同时比较正文长度、链接数量、规范化文本和页面中的关键内容。对照结果可以归纳为三组:
| 对照范围 | 优化前 | 优化后 | 流量减少 |
|---|---|---|---|
| 六个页面合计 | 约 16.84 MB | 约 1.56 MB | 90.8% |
| 原本最重的页面 | 约 11.60 MB | 约 0.16 MB | 98.6% |
| 一个原本很轻的页面 | 约 0.12 MB | 约 0.12 MB | 基本不变 |
六个页面的正文、链接和关键内容保持一致,总传输量下降约 90.8%。其中,原本最重的页面减少了 98.6%;原本就很轻的页面则几乎没有变化。
后一个结果同样重要。它说明这套修改并不会凭空让所有网站都出现漂亮数字,收益来自页面原本确实加载了大量无关资源。没有冗余的页面,自然也没有多少可以削减的内容。
六个页面只能说明六个页面这组对照不能覆盖整个互联网。正文只存在于图片、依赖图片触发脚本、使用 Canvas、Blob URL 或 CSS 背景生成内容的页面,仍然需要继续观察。
没有继续收紧限制
排查期间还有几种看起来能继续降低流量的做法:大幅减少重试、限制最大分页数量,或者让多个任务共享同一个浏览器上下文。
它们并不是没有价值,只是会改变抓取能力本身。弱网环境可能确实需要多次重试,大型教师目录可能真的超过几十页,共享上下文还会让 Cookie、缓存和页面状态互相影响。
因此,第一轮只保留了能够通过前后对照解释清楚的修改:能直接请求的页面不启动浏览器;必须使用浏览器时不下载无关资源;第一次访问得到的完整结果交给后续步骤复用。
最初看到 10 GB 时,我以为问题可能是 Chromium 异常,随后又怀疑并发过高。真正查到最后,浏览器和并发都只是放大器。流量来自几个单独看都很合理的决定——记住站点访问方式、完整渲染页面、按需补全邮箱、失败后重试——在一条长流程里叠加到了一起。
这次排查最有价值的部分也不只是 90.8% 的下降,而是把“抓取成功”重新理解成了两件事:既要拿到正确内容,也要让已经拿到的内容不再被后续步骤反复下载。