ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

文档站点快照与长图归档:从爬虫抓取到无头浏览器渲染实战

文档站点快照与长图归档:从爬虫抓取到无头浏览器渲染实战

1. 为什么文档站点快照比想象中更依赖"长图"这条出口

1.1 文档站点的消亡从不打招呼

文档站点这东西,平时不觉得金贵,一旦改版或者下线,满群找缓存的人能排到明年。我做过不少Python爬虫项目,说实话,最能让我觉得"有复用价值"的,反而不是那些花里胡哨的数据采集,而是一个不太起眼的文档快照归档器——把站点原样抓下来,再顺手渲染成长图,给团队留底存档。

说个实际场景:某项目的内部技术文档站,页面里写满了接口变更记录、数据库字段说明、上线排查手册。某天运维升级,站点目录结构全变了,几百个技术文档的旧链接直接打不开。如果没有事前快照,很多一次性记录的细节就彻底丢了。归档的意义不是预防什么大事,而是让"查旧资料"这件事永远有退路。

你可能会问,直接点击浏览器"另存为"不行吗?当然行,但只能一页一页存。面对一个几十上百页的文档站,手工操作效率太低,而且保存下来的HTML文件散落在一堆命名混乱的目录里,后续根本没法快速找回。我做这个工具的思路很明确:输入一个站点入口链接,程序自己遍历、抓取、落盘,输出一套结构清晰的本地快照,再把每个页面渲染成长图归档,方便在聊天工具和文档群里直接转发查看。

1.2 快照方案对比:HTML、PDF、长图各管一段

动手前先想清楚一点:快照为什么最终要落到长图?很多人第一反应是存HTML就够了,但实测下来,不同格式对不同场景各有优势。

归档格式优点缺点适合场景
原始HTML文字可检索、体积小、保留原站结构依赖本地资源文件,稍有不慎就面目全非深度归档、后续全文搜索
单页截图直观、接近真实渲染效果页面高一点就要滚动N次,一屏放不下快速分享单页内容
长图能完整呈现整篇页面、适合聊天工具里翻阅生成拼接需要处理滚动截屏、合图成本较高团队日常查阅、离线备份、对外展示
PDF打印排版规范、可加书签目录中文字体嵌入麻烦、重构网页布局需要时间正式交付、打印场景

我的归档器最终采用"HTML快照 + 长图"双轨制:HTML负责深度保存和检索,长图负责快速阅读和分享。这个组合在实际使用中基本覆盖了所有需求,而且每种格式的生产成本都不高。

1.3 典型用法与项目目录设计

工具拿到一个入口URL,会主动发现站内所有相对路径的页面,生成如下目录:

archive_root/ mirror_site/ docs/ intro.html api/ user.html order.html assets/ css/ js/ images/ screenshots/ docs/ intro.html.png api/ user.html.png index.html manifest.json

镜像目录按域名下的路径结构一对一还原,截图目录跟随镜像目录平行生成。这样后续想查某个页面的文字内容就进mirror_site,想在聊天群里甩图就直接找screenshots,各取所需。

2. 抓取层的正确姿势:不写通用爬虫,只写会过日子的爬虫

2.1 先摸清站点结构再动手

拿到目标站点后,我不会直接开写爬虫,而是先做一次手动侦察。打开站点,观察导航栏、目录页、分页结构,再看URL规律。绝大多数文档站的链接结构都很规整,要么是/docs/xxx.html这样的静态路径,要么是/article?id=123的动态参数。这个规律决定了我后续的遍历策略。

以最常见的静态站点为例,整个过程分三步:先用一行代码拉取入口页,用解析库提取页面上所有同域链接,再用宽度优先遍历逐层往里走。这个过程的示意图大概是:

  1. 从入口页提取所有a标签的href。
  2. 过滤掉外部域名、mailto:、javascript:等无效链接。
  3. 把链接标准化成绝对路径,放进待抓取队列。
  4. 依次抓取每个待抓取页面,重复第1步提取新链接。
  5. 设定最大深度,防止递归抓出整个外网。

关键参数我一般这样设置:最大深度设3到5层,同域限制必须打开,抓取间隔控制在0.5到1秒。有人可能觉得间隔无所谓,但真遇到对端有频率限制时,你哭都来不及。慢一点,稳一点,归档工具不是竞速比赛。

2.2 递归遍历、URL去重与同域限制

爬虫的第一大敌不是反爬,而是自己的失控。URL去重做得不好,很容易把同一个页面抓几十次。我用一个seen集合存已经访问过的URL标准化结果,标准化的过程包括:去掉#锚点、默认端口号、大小写归一化。对动态参数,则按是否影响页面内容来决定要不要保留。

同域限制要单独强调。我们用urllib.parse.urljoin把相对链接拼成绝对链接,然后判断目标URL的netloc是否和入口一致。如果入口是文档站的主域名比如docs.example.com,那么所有指向cdn.example.com的静态资源链接不能作为页面抓取入口,但可以作为资源下载地址单独处理。

实际在项目里,我会把URL分类存放:需要抓取的文档页链接、需要下载的资源链接、不需要跟进的外链。这种分类比统一放进一个队列更安全,后面做资源落盘时也更顺手。

2.3 页面保存与资源落盘

抓下来的HTML不能只存一个<html>字符串,要把页面的CSS、JS、图片一并下载到本地,否则断网后打开快照就是一堆没有样式的纯文本,归档效果大打折扣。

具体做法是先解析HTML里所有<link>、<script>、<img>、<source>标签的地址,然后逐个下载到assets目录。下载时注意资源路径的映射关系:原站路径是/assets/css/theme.css,本地就保存成assets/css/theme.css。这样HTML里只要把绝对路径改成本地相对路径,就能在无网状态下完整渲染。

不少页面还带字体文件、图标文件,这些容易漏。我的策略是不管类型,只要在assets目录下且属于资源标签,一律下载。文件量大时控制并发数,我常用线程池限制5个并发下载任务,避免把对方站点资源服务器打穿。

3. 静态重放与本地化改造:让快照不依赖任何在线资源

3.1 HTML里的CSS/JS必须跟着落地

快照能不能"活",关键看CSS和JS有没有跟着到位。很多爬虫工具只抓HTML不抓资源,结果离线打开时页面布局全乱,这不算真正的快照。要称得上快照,必须保证:把HTML文件拷到任何一台没有网络的电脑上,用浏览器打开,效果和线上一致。

资源落地的方式我分三步实现:第一步提取所有资源的URL;第二步下载到和原站路径一致的本地位置;第三步把HTML里的src和href改写成相对路径。改写相对路径有个坑,就是文档里使用了/开头的绝对路径时,需要根据当前页面在本地目录中的深度,计算出一串../前缀。

例如页面保存在docs/api/user.html,资源地址是/assets/css/theme.css,那么本地改写后应该是../../assets/css/theme.css。不少工具就挂在这个计算上,我会写一个独立的路径转换函数,并给每个生成的HTML做一遍自检,确保所有引用的本地资源都能通过相对路径找到。

3.2 链接改写与编码处理

页面里除了资源引用,还有大量站内链接,这些链接如果不改,用户点击快照里的链接时会跳到线上,违背"快照"的初衷。我的做法是把所有站内链接统一改写成指向本地镜像内对应HTML文件的相对路径。

如果链接指向的页面没有抓到,就先留一个占位标记,同时生成一个missing列表,归档完成后统一检查。这样即使有漏网之页,也能在检查时发现。

编码问题同样容易忽略。老一点的站点还在用gbk编码,新站点基本是utf-8。我的做法是抓取响应时优先看Content-Type头里的charset参数,没有的话再通过页面<meta>标签判断,最后才用字节流做嗅探。保存文件时统一转成utf-8并同步修改<meta charset>,这样后续不管在什么操作系统上打开都稳定。

3.3 遇到前端渲染站点怎么办

现在不少新文档站改用前端框架,页面内容全靠JS动态渲染。如果直接抓取原始HTML,拿到的只是一个空骨架,真正的文档正文根本不在里面。这时候我的策略是先渲染再抓取,也是后面的长图环节一定会用到的手段。

我用了无头浏览器来渲染页面,等待页面网络空闲、关键节点出现后再获取渲染后的HTML。和直接抓取相比,这个步骤慢一些,内存占用也高一些,但面对现代前端站点是必须的。考虑到快照本就是对"当前站点状态"留存,渲染后的HTML比原始HTML更有保存价值。

4. 长图生成:从"能截屏"到"截得干净"

4.1 选择无头浏览器而不是requests直接截

如果你以为长图就是拿抓下来的HTML拼一拼,那就低估这事了。长图的本质是"把浏览器视口里渲染出的页面完整截出来,按原逻辑拼在一起"。想想也知道,requests能拿到HTML文本,但拿不到渲染结果。真正做这件事的是无头浏览器。

我选择无头浏览器的主要原因有三个:一是能稳定等待懒加载图片完成;二是能设置一致的viewport宽度,避免不同屏幕下截图比例失控;三是能获取完整的页面渲染高度,为分段截屏提供坐标依据。跑文档站快照这个场景,headless模式足够了,不用弹真实窗口。

初始化和浏览器窗口设置的要点如下:打开本地HTML文件而不是线上URL,这样可以完全排除网络因素的影响。viewport宽度根据目标站点设计稿设定,我常用1440像素;这个宽度必须固定,因为后续拼接长图时所有截图尺寸要一致。

4.2 分段滚动截屏的合图策略

页面高度动辄三四千像素,远超单屏高度,直接截一张大图在很多操作系统中会被裁剪或超限。我的做法是分段截取:按视口高度把整页切分成N段,每段滚动到对应位置后截一张图,最后用图像库按顺序垂直拼接。

用Python描述核心逻辑就是:

# 先获取整页高度和视口高度 page_height = browser.evaluate("document.body.scrollHeight") viewport_height = browser.evaluate("window.innerHeight") # 分段截图 screenshots = [] for y in range(0, page_height, viewport_height): browser.screenshot(clip={"x": 0, "y": y, "width": viewport_width, "height": viewport_height}) screenshots.append(part_image) # 拼接 final_width = screenshots[0].width final_height = sum(img.height for img in screenshots) canvas = Image.new("RGB", (final_width, final_height), (255, 255, 255)) current_y = 0 for img in screenshots: canvas.paste(img, (0, current_y)) current_y += img.height

这个方案简单直接,但有一个隐藏陷阱:浏览器滚动时如果页面内容有动画或动态加载,两次截图之间元素位置可能漂移。我用了一个小技巧——每次滚动后固定等待几百毫秒,确保渲染稳定再截图。

4.3 六个容易翻车的细节

分段截图看着简单,实际操作中我从踩坑里总结出了六个细节:

第一,懒加载图片。页面里很多图片是滚动到附近才开始加载的。如果第一段第一次滚动时下面的图片还没触发加载,后续截取到那段时可能已经加载了,也可能没有。我的解决方式是在截屏前先模拟一次"慢速滚动到底",触发所有懒加载后再回到顶部开始正式分段截图。

第二,固定页头。现在很多文档站有position: sticky的顶部导航,滚动时它一直悬浮在视口顶部。分段截图如果直接各截各的,拼接出来的长图上会有多个重复的导航栏。解决办法是截屏前临时在页面上注入一段样式,把sticky元素改成position: static,等截图完成再还原。这个处理非常关键,不做的话长图根本没法看。

第三,WebGL和canvas动态内容。这类元素经常渲染不出来或者截出来是空白。如果归档的文档站点包含图表类页面,我会额外等待一小段时间再截屏,但不是所有动态内容都能救回来。遇到这种情况,我的经验是在索引里打个标记,提示该页截图质量受影响。

第四,长图尺寸上限。对非常长的页面,比如完整打印版的API文档,分段数量多到几百张时内存占用巨大,拼接也可能超时。我限定单文件最长不超过两万像素,超过就生成"分段长图"并在文件后缀里加序号,例如user.html.part1.png、user.html.part2.png。

第五,背景透明。某些页面的body背景是透明的,拼接时如果底色不一致,长图上会出现一道明显的分界线。我会在拼接前把画布底色统一设置成白色,同时把每段截图的透明区域先合成为白色背景。

第六,中文字体渲染。Linux服务器上如果不装中文字体,截图里的中文全是方块。部署前先用fc-list检查有没有中文字体,没有就安装一套开源中文字体。这一步虽然不算代码问题,但栽过跟头的人都知道它的重要性。

5. 归档索引与增量差异:快照工具的使用感和自动化的关键

5.1 自动生成索引页

快照和长图都有了,如果连入口都找不到,归档就变成了一堆没有目录的仓库。我会给每次归档生成一个index.html索引页,按站点的原始目录结构展示所有抓取页面,每一行显示页面标题、链接、截图状态以及抓取时间。

索引页本质上是个小的静态HTML,生成逻辑不复杂:遍历镜像目录,提取每个HTML页面的<title>标签,再结合截图目录做状态标注。抓取失败的页面会在索引里被标成红色,方便一眼看出哪些链接有问题。

这里分享一个小经验:索引页的生成时间要用本地服务器时区,不要用UTC,否则团队成员看时间还得换算。另外,索引页里可以加一个最基础的站内搜索框,用JavaScript做标题过滤。这个小功能大幅提升日常查找效率,实现成本只有二三十行代码。

5.2 用Git做版本对比

文档站是活的,今天记录的接口,明天可能就变了。如果每次归档都覆盖旧文件,那么"曾经的版本"就丢了。我的做法是把归档目录放进Git仓库,每次归档结束后提交一次,这样历史版本全部都有据可查。

Git在这里还承担一个重要作用:差异提醒。通过对比当前版本和上一版本的变动文件列表,可以快速知道哪些文档页发生了修改。我的自动化流程里,归档完成后会执行一次git diff --stat,然后把变动的文件清单单独输出到一个变更日志里。

有人会觉得文档站点改动频繁,Git仓库会越来越臃肿。实际上纯文档快照的变动量不大,多数页面常年不变,Git仓库的增长非常可控。如果归档频率高、页面体量大,也可以考虑只对文本型HTML做版本控制,把截图目录排除掉,截图留最新一份即可。

5.3 定时任务与站点变更警报

至此,手动执行归档已经没有问题了,但既然是归档器,就应该让它自动跑起来。我用系统的定时任务(比如Linux的crontab)设定每周日凌晨执行一次完整归档,工作日每天早上执行一次增量检查。

增量检查的模式是这样的:先去抓目标站点的首页或某个固定入口,做一个轻量对比,判断是否有明显的结构变动。如果有变动,就触发一次完整归档,并把变更提醒发到通知渠道。

值得注意的是,提醒里不要附带上敏感或内部信息,只发送"某文档站结构有变化,快照已更新"这类提示即可。归档工具是内部系统,保持低调更重要。

6. 稳定性设计与常见问题排查

6.1 拒绝被反爬:节奏和特征

有人可能会担心"做了爬虫会不会被目标站点封"。文档站一般不是高价值反爬目标,但存在基础防护的也不少。我从一开始就坚持三条原则:控制抓取频率、设置合理的请求头、遵守站点的robots协议。

频率控制是最重要的,所有请求之间至少要间隔几百毫秒,并发不要开太大。请求头方面,我会设置一个常规浏览器的User-Agent,不要用默认的Python UA。这里多说一句:很多爬虫被抓不是UA问题,而是请求间隔太规律,机器行为太明显。我实际测试时,会把间隔做一点随机抖动,比如0.6秒到0.9秒之间随机,效果比固定间隔好得多。

6.2 乱码、404、动态加载的兜底策略

实际运行中,我遇到过三类高频故障。

第一类是乱码。表现是归档后的HTML打开全是乱码,多半是编码判断失误。兜底方案是在抓取时把原始字节流先存一份,解析阶段既用Content-Type头判断,也做字节序列检测,两边都判断不了时再按照utf-8处理。如果取回后HTML里出现大量替换字符,就直接报告编码识别失败。

第二类是404和链接失效。文档站经常有旧链接指向已删除的页面。我的策略是:抓取过程中遇到404时记录状态,不中断整体流程;归档完成后输出一个失效链接清单,方便人工判断是站点迁移了还是确实删除了。

第三类是动态加载内容截不全。表现是长图里某个区块空白或者元素错位。这通常和前端渲染时序有关。兜底处理是在截屏前等待页面networkidle状态,额外等待秒数,同时在长图底部生成一个带时间的"归档生成说明"小尾巴,标明该图的生成环境。

6.3 我保留的两个增强项

这个归档器搭好后,我保留了两个增强功能没用默认开启,但随时可以启用。

第一个是全文检索。镜像目录里的HTML文件都是纯文本,用搜索引擎思路做全文索引并不难。把每个页面的正文提取出来存入索引库,就可以实现毫秒级的关键词检索。这个功能适合归档量特别大的场景,普通几十上百页的文档站暂时用不上。

第二个是数据库版归档清单。目前索引信息存在manifest.json里,页面量增加后可以迁移到数据库。迁移的好处是可以做更灵活的状态查询,比如"哪些页面连续三次没有变化"、"哪些页面的长图生成失败"。

我个人的习惯是:小站点用文件版,大站点用数据库版,需要切换时成本也就一次序列化导入导出。

最后再分享一个实际运行中的心得:这个归档器跑顺之后,最大的价值不在于爬虫本身,而在于"定时、可靠、不打扰"。我把它部署在一台低配置服务器上,每周静默执行,工作群里偶尔收到一条变更提醒。大家真正需要的时候,它把历史文档完整地摆在手边——这才是归档工具该有的样子。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进