基于Python的文档站点快照与长图归档实践 做文档归档这件事说难不难说简单也不简单。前阵子我折腾了一个“开源数字博物馆”的小项目本质就是一个Python爬虫驱动的文档站点快照与长图归档器用来把那些随时可能改版、下线或者被删的文档页面变成一张张长图和一个可检索的本地站点把内容永久留存下来。这篇就当是踩坑复盘把我怎么设计、怎么实现、怎么解决各种奇葩问题的过程完整记录下来给想做文档快照、网页归档或者爬虫项目的人一个参考。整个项目最核心的诉求其实很简单看到一个文档站点想把它整个“搬回家”并且能像逛博物馆一样随时翻阅。这里的“快照”不是简单的存个HTML而是要把页面渲染后的样子、样式、图片、代码高亮等一并保留“长图归档”则是把每一个完整的文档页面直接渲染成一张超长截图方便日后快速预览和分享。用Python来做这件事主要看中它的生态成熟requests、lxml、Playwright、Pillow这些库各司其职不需要重复造轮子。1. 项目概述为什么需要文档快照与长图归档1.1 数字博物馆的“展品”是什么我们常遇到这类场景某个技术框架的官方文档某天突然改版了以前写好的博客链接全部失效某个产品的说明书因为版本更新被塞到了废弃页面搜索引擎也找不到甚至有些站点会直接撤下某些页面内容就像从互联网上蒸发了一样。数字博物馆的“展品”就是这些曾经存在过的网页内容。我做的这个项目本质上是给这些网页建一个“诺亚方舟”抓取页面内容、保存静态资源、生成可视化的长图最后按站点维度归档成一个可以离线浏览的静态站点。这里需要明确一下它不是要把整个互联网爬下来而是针对“文档类站点”做精准抓取。文档站点的特点是结构规整、页面之间通过目录树关联、内容和视觉呈现相对稳定非常适合做快照。比如常见的GitBook、Docsify、ReadTheDocs生成的文档站或者企业内部的帮助中心都属于这类目标。1.2 项目目标与整体框架搭建这个归档器的目标有三个一是完整性尽量不丢失原文的格式和图片二是可追溯性每个页面要有明确的抓取时间和版本信息三是可读性长图和本地站点的视觉体验要接近原站。整体的技术框架分四层抓取层负责从目标站点获取页面列表和页面HTML处理层负责清洗内容、转换链接、保存资源渲染层负责把处理后的页面转成长图和静态站点存储层负责按时间戳组织归档目录并生成索引页。这四层之间通过中间产物衔接我用的中间产物是“标准化HTML快照”也就是把页面里所有的样式、图片、脚本全部本地化之后的HTML文件。这样后续无论是生成静态站点还是渲染长图都可以基于同一份数据源避免重复抓取。2. 核心模块设计与技术选型2.1 爬虫模块文档站点抓取策略爬虫模块是整个项目的地基。针对文档站点我采用了两级抓取策略先抓“目录页”后抓“内容页”。目录页通常是主页、侧边栏或者sitemap.xml通过它们能拿到站点内部所有页面的URL列表。内容页则是真正包含文档正文的页面也是快照和长图的主要对象。这里要注意一点很多文档站点的URL并不是一步到位的有的通过JavaScript动态渲染有的带有hash路由。如果直接拿requests去请求得到的可能是空的骨架HTML。所以我做了两套抓取方案对于服务端渲染的页面直接使用requests lxml解析对于客户端渲染的页面使用Playwright驱动无头浏览器先等页面渲染完成再抓取最终HTML。判断依据很简单请求后的HTML里如果找不到文档正文的关键节点就自动切换到Playwright重试。链接提取阶段我优先使用XPath的text函数配合URL去噪。文档站点的目录链接通常集中在特定的容器内比如nav、sidebar、menu这些节点。用XPath筛选出这些区域的a标签再通过正则清洗掉mailto、javascript:、锚点链接等无意义的URL最后把相对路径拼接成绝对路径去重后得到一个干净的URL集合。这一步有一个容易踩的坑不要全站无脑提取链接否则会把评论区、相关推荐、搜索引擎跳转链接都抓进来既浪费资源又污染目录结构。2.2 快照模块HTML保存与资源本地化快照模块要解决的是“页面保存下来之后离线打开还能看到完整的排版”这个核心问题。单纯的HTML文件在离线状态下通常会丢失样式和图片因为它们的URL指向的还是远程服务器。所以必须做资源本地化把CSS文件、JavaScript文件、图片、字体全部下载到本地然后把HTML里的引用路径改成相对路径。我用的方案是后处理替换先用lxml解析HTML找出所有link[relstylesheet]、script[src]、img[src]、source[src]节点逐个下载文件并保存到预设目录然后用替换后的相对路径更新节点属性。这里有个细节CSS文件内部的url()引用背景图以及CSS中引用的字体文件需要单独解析并替换否则还是会有部分资源离线丢失。如果页面引用了第三方CDN资源比如Google Fonts或jsdelivr也要一并下载否则离线渲染时字体和样式会退化。另外对于代码块的高亮主题有些文档站用的highlight.js或者prism.js是动态计算样式的离线打开时如果脚本被本地化了可能还能工作但如果脚本被浏览器拦截高亮就会丢失。所以我额外做了一个优化快照时用Playwright执行页面里的高亮脚本然后把最终DOM的innerHTML抓回来相当于把高亮结果硬编码进HTML里。这样离线浏览时就算JS失效代码块的配色也不会变。2.3 长图生成模块从HTML到图片的转换方案长图生成是“数字博物馆”里最有视觉冲击力的部分。把一篇完整的文档渲染成一张竖向长图可以直接在微信里分享或者在本地预览时快速了解文章结构。我对比过几种方案最终选了Playwright原因很直接它渲染出来的效果和真实浏览器几乎一致支持自定义视口宽度、等待字体加载、处理懒加载内容还能精确控制页面高度和滚动行为。实现思路是用Playwright打开本地化的HTML快照文件设置一个合理的视口宽度比如1440px然后获取页面完整高度把视口高度设为完整高度手动触发懒加载图片的滚动事件如果页面有懒加载最后调用page.screenshot()截取整页。关键点在于页面中如果存在吸底导航、回到顶部按钮这类固定定位元素它们会重复出现在长图的多个纵向位置需要先通过CSS把它们隐藏掉否则长图会出现奇怪的重影。对于特别长的页面Playwright整页截图有时会失败或者生成的图片分辨率超出常规工具的处理范围。我的处理方案是分段截图把页面按照20000像素高度切段逐段滚动截图然后用Pillow做垂直拼接并在每段之间做小范围重叠以消除拼接缝隙。拼接后的长图用PNG格式存储文件体积会比较大如果文档站图片繁多建议保存为WebP格式体积能减少70%且现代看图软件和浏览器都兼容。3. 实操过程从零搭建一个文档归档器3.1 环境准备与依赖安装先交代环境我在Windows 11和Ubuntu 22.04上都跑通了完整流程。Python版本建议3.10以上因为后面要用的Playwright对Python版本有要求老的3.7、3.8在部分API上会踩兼容性的坑。安装依赖分为三部分基础解析库、渲染引擎、图像处理库。基础解析库用requests、lxml、BeautifulSoup4虽然我用lxml做主要解析但BeautifulSoup4在调试时很顺手渲染引擎用Playwright它会自动下载Chromium内核图像处理用Pillow。额外建议安装tqdm归档几十上百个页面时进度条能让你心里有底。pip install requests lxml beautifulsoup4 playwright Pillow tqdm python -m playwright install chromium这里有一个国内环境下需要注意的点Playwright下载Chromium时可能很慢可以设置PLAYWRIGHT_DOWNLOAD_HOST环境变量指向国内镜像。如果没有这个变量下载超时会卡在“Downloading Chromium”这一步很多人误以为pip装失败了其实只是内核没拉下来。3.2 爬虫代码实现XPath提取文档内容接下来直接写代码。整个爬虫的核心是两层先获取页面列表再抓取页面正文。我以抓取一个常见的GitBook文档站为例这里不点名具体站点免得给人家服务器增加压力。获取页面列表的代码大致如下import requests from lxml import html def get_page_links(start_url): resp requests.get(start_url, headers{ User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 }) tree html.fromstring(resp.text) # 找到侧边栏容器这里假设是 nav[idbook-summary] 或者 div[classsidebar] nav tree.xpath(//nav[idbook-summary]//a | //div[contains(class,sidebar)]//a) links [] for a in nav: href a.get(href) if not href: continue if href.startswith((#, javascript:, mailto:)): continue full_url requests.compat.urljoin(start_url, href) # 去掉 anchor 部分 full_url full_url.split(#)[0] if full_url not in links: links.append(full_url) return links为什么用XPath而不是直接用BeautifulSoup因为在复杂文档结构下XPath可以通过谓词精确定位导航容器比逐层遍历更高效而且可以用text函数提取链接文本用于后续生成目录索引。比如a[contains(class,menu-item)]//text()能直接拿出菜单项文字这在生成归档索引时很有用。抓取正文时就需要注意页面类型。如果开启JavaScript渲染就用Playwrightfrom playwright.sync_api import sync_playwright def fetch_rendered_html(url): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page(viewport{width: 1440, height: 900}) page.goto(url, wait_untilnetworkidle, timeout60000) html page.content() browser.close() return htmlnetworkidle模式会等网络请求基本完成再返回这对于包含大量懒加载资源的文档站很有用。但要注意有的页面会一直有长轮询请求导致networkidle超时这时候可以把wait_until改成domcontentloaded再手动sleep 2秒等待后续接口返回。3.3 快照保存与资源本地化拿到页面的最终HTML之后就要做资源本地化。这段代码是我整个项目里最费心思的部分因为要考虑的因素很多资源URL的拼写、文件重名、CSS内部的资源引用、CDN资源是否可达等。我实现了一个save_assets函数基本逻辑是解析HTML后遍历所有资源节点然后下载并重写URL。import os import re import requests from urllib.parse import urljoin, urlparse from lxml import html, etree def localize_assets(html_str, page_url, asset_dir): tree html.fromstring(html_str) # 改写 CSS 链接 / JS / 图片 for attr, tag in [(src, script), (src, img), (href, link), (srcset, source)]: for node in tree.xpath(f//{tag}[{attr}]): url node.get(attr) if not url: continue abs_url urljoin(page_url, url) # 跳过 data: 和 blob: 协议 if abs_url.startswith(data:) or abs_url.startswith(blob:): continue # 下载文件到本地 local_path download_asset(abs_url, asset_dir) # 改成相对路径 rel_path os.path.relpath(local_path, asset_dir) node.set(attr, rel_path) # 处理 CSS 文件内部的 url() 引用 for link_node in tree.xpath(//link[relstylesheet]): css_path link_node.get(href) if css_path and os.path.exists(css_path): rewrite_css_urls(css_path) return etree.tostring(tree, encodingunicode)download_asset里要处理文件重名问题不然多个目录下的index.css会互相覆盖。我的做法是保存文件时根据URL的MD5作为文件名前缀例如css/8f27a1c39a.css这样既保证唯一性又不需要维护复杂的目录映射。href是原始URL的引用这样我需要下载的是哪个CSS、哪个图片都由我控制。以上只是局部核心逻辑实际上还涉及资源下载限流、SSL证书异常捕获等细节后面问题排查部分会重点聊。3.4 长图生成与拼接长图生成的代码依赖Playwright的整页截图能力。但正如前面说的固定元素重影问题非常常见。我写了一个clean_fixed_elements函数在截图前把页面上所有position:fixed的节点隐藏掉通常一个低密度正则就够from playwright.sync_api import sync_playwright def generate_long_screenshot(html_path, output_png): with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page(viewport{width: 1440, height: 900}) page.goto(ffile://{html_path}) page.evaluate( document.querySelectorAll(*).forEach(el { const style getComputedStyle(el); if (style.position fixed) { el.style.display none; el.style.visibility hidden; } }); ) # 获取完整高度 total_height page.evaluate(document.body.scrollHeight) page.set_viewport_size({width: 1440, height: total_height}) page.screenshot(pathoutput_png, full_pageTrue, typepng) browser.close()这里有一个坑如果body高度是动态变化的比如字体加载完成后高度增加在set_viewport_size后再滚动触发懒加载高度可能会变化导致截图底部出现空白或截断。我的办法是先强制触发所有懒加载图片加载再重新计算高度然后截图。对于超长页面分段截图更稳妥。分段逻辑是用Pillow新建一个空白画布逐段滚动截图后粘贴上来。每段之间重叠200像素粘贴时从上一段的结束位置减去重叠区高度这样拼缝处不会丢内容。3.5 归档目录组织与索引页所有归档内容按以下目录结构组织archive/ └── 2024-11-10_21-30-00/ ├── index.html # 本地站点入口 ├── pages/ │ ├── 001-introduction.html │ ├── 002-invocation.html │ └── ... ├── assets/ │ ├── css/ │ ├── js/ │ └── img/ └── screenshots/ ├── 001-introduction.png ├── 001-introduction.preview.webp └── ...索引页index.html由项目脚本在抓取完成后自动生成它会读取pages目录下的HTML文件提取每个页面的标题和第一段摘要文字然后生成一个目录列表。这样打开归档站点时先看到的是数字博物馆的“导览台”点击任意标题就进入对应的页面快照右侧还能看到对应长图的下载入口。生成索引我用的是Jinja2模板这里简单记录一下为什么不用纯字符串拼接因为需要对标题做HTML转义防止页面标题里包含特殊字符导致索引页结构错乱。用模板引擎等于让框架帮我们做这部分安全处理。4. 常见问题与排查技巧实录4.1 动态页面抓取失败遇到最多的场景是requests拿到的是空内容但用浏览器打开却正常。这十有八九是因为页面数据是前端通过接口异步加载的。初期我写过很多次无用的代码验证后来总结出一条经验先打开浏览器开发者工具的Network面板刷新页面看看XHR请求通常能找到返回JSON数据的接口。这时候直接请求这个接口反而比无头浏览器更轻量。如果接口请求复杂需要带加密参数或者通过WebSocket推送那就直接上Playwright。但要注意控制资源开销每抓一个页面都启动一个浏览器实例是灾难级的浪费。正确姿势是复用浏览器上下文with sync_playwright() as p: browser p.chromium.launch(headlessTrue) context browser.new_context() for url in url_list: page context.new_page() page.goto(url) html_content page.content() page.close() browser.close()4.2 资源本地化时的链接失效资源本地化还有一个隐蔽问题有些站点开启了防盗链Referer不对就不返回图片。这种情况在离线快照里体现为图片区域出现占位图或裂图。我的解决方式是在下载资源时把浏览器访问该站点时使用的Referer和User-Agent原样带上。实测下来大多数站点的防盗链只校验Referer域名少数还会校验Cookie。如果遇到更严格的情况比如图片地址需要签名且带有效期那就需要在快照时用Playwright的page.request或者直接浏览器渲染后的“已加载资源”来获取图片二进制而不是重新用requests下载。Playwright的page.screenshot已经在渲染时加载了图片那部分的图片是可以直接体现在长图里的但如果要保存独立图片文件还是要走网络请求。4.3 长图截断与内容丢失长图截断最常见的原因是页面中的iframe或canvas。iframe内容默认不算在scrollHeight里这会使得截图区域比实际内容矮一截底部的内容被截掉。canvas则相反它虽然会撑高页面但截图时机如果太早canvas内的绘图还没完成长图会出现一块纯色空白。针对iframe如果iframe内容是同源文档我直接在page.evaluate里对每个iframe执行同样高度计算并汇总如果是跨源的只能在截图前把iframe替换成一个占位块并配上说明文字。针对canvas我采用“读取readyState”的思路简单来说就是在截图前轮询检查页面上所有canvas的像素序列如果全是全透明就再等等最多等5秒。4.4 性能优化与调度归档一个50页的文档站串行抓取加渲染可能要20分钟但如果用线程池调度能把时间压缩到5分钟左右。我用的是concurrent.futures.ThreadPoolExecutor线程数控制在5到8个。为什么不能无限加大因为目标站点可能对IP做并发限制一旦触发429或503反而会拖慢整体速度。调度维度上我把“抓取页面列表”和“抓取页面内容”分开前者串行其实很快一次请求就完事后者并行。所有下载任务包括资源下载也放到同一个线程池里。这里要特别强调Playwright的启动非常占资源我用的是全局一个浏览器进程然后每个线程通过一个独立的context访问线程池大小设定为4这个值在多数机器上表现稳定。5. 扩展建议与实践体会5.1 从单站点到多站点的扩展如果要把这个归档器扩展成支持多个站点需要把站点配置抽象出来。我建了一个config.json记录每个站点的名称、入口URL、导航容器XPath、内容容器XPath、页面编码等信息。每次归档任务只需要读取配置然后调用固定的流程函数整个代码就能复用。本质上站点之间的差异就是URL规则和容器的不同把这些差异外部化之后新增一个站点几乎不需要改代码。比如一个站点的导航在nav标签里另一个站点的目录树在ul[classtree]里爬虫只需要通过配置读取不同的XPath即可。这个思路也可以推广到保存规则的灵活配置比如针对某个站点把下载图片的限定域名改成空白即不下载图片只保留文字快照。5.2 定时快照与差分比较进一步延伸文档站点的内容会持续变化我们可以加入定时快照机制。用系统的cron或者GitHub Actions每天凌晨自动跑一次归档然后把新快照和旧快照做差分比较得出“哪些页面更新了”的清单。这个清单可以作为索引页里的“新增亮点”让数字博物馆变成活的历史馆。差分比较不需要复杂的算法我用的方案是把HTML标准化去除空白字符、统一标签属性顺序后计算MD5两个版本的MD5不一样就说明页面有变化。然后用difflib生成一个简要的文本变更记录不需要把整篇对比细节都呈现在索引页上只保留“有更新”的标记即可。5.3 我的实操体会与避坑指南整个项目做完我最深的体会是爬虫的成功率永远不是100%所以项目里每个环节都要有“容错思维”。下载资源时失败就重试三次重试失败就跳过并记录日志页面渲染超时就跳过但保留占位页长图生成失败时至少保留HTML快照。归档的核心是把内容留存下来某一个页面暂时失败没关系下次快照时能补上就行。另外关于反爬虫我实际遇到的99%的拦截场景其实都只在两种情况下发生一是请求频率过高二是显式暴露了自动化测试特征。对于前者在每页抓取之间加一个随机sleep0.5到2秒就能显著缓解对于后者Playwright启动时通过添加--disable-blink-featuresAutomationControlled参数并删除navigator.webdriver属性能规避掉大多数检测。但这只是应对正常防盗刷不要滥用这份技术去打无休止的请求合规边界要心里有数。踩过最大的坑反而是长图生成时Windows系统的字体渲染问题。同样的HTML快照在Windows服务器上生成的图片中文字体老是变成宋体跟原站有明显差别。排查后确认是系统缺失了无衬线字体解决方式是安装Noto Sans CJK字体或者在Playwright里使用page.add_style_tag强制指定字体族。如果你在服务器上跑这个项目记得提前处理好字体否则截图效果会让你怀疑人生。这个项目我已经在自己的工作流里跑了三个月期间给三个重前端技术栈的文档站都建了快照归档长图加起来一百多张还顺手把其中两处“即将被删的旧版教程”完整保存了下来后来真的派上了用场。对我而言它已经从一个工具变成了习惯每次看到网站改版公告第一反应不再是“我赶紧复制粘贴保存”而是打开归档器跑一遍让备份、归档、索引这套流程自动完成。