资讯详情 Python实现文档站点快照与长图归档:从爬虫到离线保存
📅 2026/10/7 22:26:56
这些年我养成了一个习惯凡是觉得以后可能还会翻出来看的网页都会顺手做一份快照。因为踩过太多次“收藏夹里躺着一堆404”的坑尤其是那些写得很好的技术文档、接口说明、教程长文说没就没连个缓冲的余地都不留。后来我干脆用Python写了一个小工具把“抓取页面 - 生成快照 - 拼成长图归档”这条链路整个自动化也就是标题里说的“文档站点快照与长图归档器”。这篇文章就把这套工具从需求拆解到代码实现、再到排坑实录完整过一遍。这套工具解决的是三个很具体的痛点一是网页内容失效二是文档资料碎片化三是长文不便保存和分享。把它做好之后一条命令就能把一个页面变成一份带完整样式的HTML快照和一张长图归档到本地目录里随时能查、能翻、能分享。如果你平时有收集资料、存档报道、维护个人知识库的需求或者刚学Python爬虫想找一个完整的实战项目练手这篇内容应该能给你一个可以直接落地复现的参考方案。1. 需求拆解与整体设计思路在动手写代码之前我得先把“文档站点快照与长图归档器”这个标题拆成三个关键词来理解爬虫、快照、归档。爬虫解决的是“把内容拿下来”的问题快照解决的是“把内容定格在某个时间点”的问题归档解决的是“拿下来的东西怎么组织、怎么长期保存”的问题。这三件事听起来是线性关系但其实每个环节都有自己的独立难点放到一起做需要先想清楚取舍。1.1 这个工具到底解决什么问题最朴素的使用场景是这样的你在网上看到一篇写得很扎实的技术文档比如某个框架的源码解析、一套运维排障手册或者一份接口对接协议。你收藏了甚至复制粘贴到笔记软件里。然后某一天站点改版了或者站长关站了这些内容就再也找不回来了。复制粘贴的笔记也有问题——图片是外链的样式是丢掉的代码块是高亮失效的页面里大量上下文信息根本没法通过“选中文本再粘贴”来保留。如果你是想长期保存一份“当时看到的样子”那么真正的解决方案是给整个页面做一次快照把HTML抓下来把图片、样式、脚本这些资源也一起下载下来让这份快照在本地能完整复现当时页面的状态。那为什么还要长图因为HTML快照虽然完整但分享和快速预览不太方便。你给别人发一个HTML文件对方不一定愿意打开手机上看起来也费劲。而长图是通用性最好的载体微信、邮件、笔记软件都能直接展示也便于打印和二次编辑。所以说快照负责“完整保真”长图负责“方便流通”两者是互补关系。归档器则是把这两类产物统一管理起来按域名、按日期、按标题组织目录生成一个索引避免“快照存了一大堆想找的时候根本不知道哪个是哪个”的尴尬。这一层功能看起来不起眼但实际操作下来我觉得它才是决定这个工具能不能长期用下去的关键。1.2 技术方案选型与取舍技术选型上我并没有一上来就上Scrapy这种重型框架而是用了requests加lxml的轻量组合原因有几点。第一这个场景不是大规模采集而是“按需抓取”一次只处理几个或者几十个URLScrapy的并发调度能力在这里属于杀鸡用牛刀。第二requests加lxml的学习曲线低代码逻辑直白出问题好排查。第三对于需要抓动态渲染页面的场景我单独用Playwright做补充这样动静分离哪些页面用静态抓取、哪些页面走无头浏览器完全由配置决定能省不少时间。快照层面的选型核心是决定用什么方式保存页面。我试过两种路线一种是直接把整个网页保存为单个HTML文件另一种是把页面内的资源全部下载下来、把链接改成相对路径。前者的优点是简单、文件数少但有些站点的CSS和JS比较复杂内联后可能出现样式错乱。后者保真度高但会生成大量零散文件管理成本高。我最后的选择是正文类页面优先做“单文件HTML快照”用工具把所有资源内联进去如果遇到资源特别多、内联后文件过大的情况就退化为“HTML加资源目录”的保存方式页面主文件保留图片和样式按原路径结构存放到assets目录里。长图生成这一层我对比过三套方案细节放在后面第3章细说这里先给结论最省事的是用无头浏览器直接截图整个页面最可控的是自己用Pillow把多段截图拼起来。我的工具默认走Playwright整页截图因为它的渲染效果最接近真实浏览器代码量最少但当页面过长超过20屏或者某些元素在滚动加载时会变化时我会退回到分段截图加Pillow拼接的方案。2. 核心模块一页面抓取与正文提取这一章先把整条链路的第一个环节讲透如何稳定地把一个文档页面的内容抓下来并且把正文从一堆导航、侧边栏、广告、推荐阅读里剥离出来。这一步做得好不好直接决定了后续快照和长图的质量。2.1 请求层头信息、超时、重试与限速写爬虫这么多年我最大的体会是请求层是翻车率最高的地方但恰恰是很多人最不重视的地方。很多人写爬虫就是一行requests.get(url)然后就开始为各种报错头疼。一个规范的请求层至少要有这么几样东西浏览器风格的User-Agent、Accept、Accept-Language这些头信息超时设置重试机制以及对目标站点的基础限速。我自己常用的写法是构造一个session对象统一设置headers然后用requests.adapters.HTTPAdapter把重试逻辑挂载到session上。这样在代码里就不需要到处写try except重试HTTP连接层面的临时故障会自动处理。import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, }) retry_strategy Retry( total3, status_forcelist[429, 500, 502, 503, 504], allowed_methods[HEAD, GET, PUT, DELETE, OPTIONS, TRACE], backoff_factor0.5, ) adapter HTTPAdapter(max_retriesretry_strategy) session.mount(http://, adapter) session.mount(https://, adapter)这里有几个值得说透的细节。status_forcelist里的429表示“请求太频繁”很多站点用这个状态码做限流遇到它自动重试是对的但必须配合backoff_factor做退避否则就是加重对方服务器压力也容易被封。Retry默认不会重试POST之类的方法但我建议allowed_methods里只保留GET和HEAD因为快照抓取场景里根本没有POST请求的必要。超时设置也得分两个维度connect超时控制建立连接的最长时间read超时控制读取响应的最长时间。很多文档站点的响应并不快尤其是一些老旧的文档系统read超时给到30秒是合理的connect给10秒就差不多了。连接都建立不了的情况10秒内该失败早就失败了。最后是限速。我做快照是一个一个页面顺序抓取的每个请求之间sleep一下一般1到2秒。这个速度对个人归档场景完全够用又不至于对目标站点造成压力。2.2 正文提取标题、时间、正文容器与清洗规则拿到HTML之后解析层就上场了。我的解析流程是先解析标题再找发布时间最后定位正文容器然后对正文做清洗。标题的提取比较简单绝大多数页面都有h1标签而且文档站点一般一个页面只有一个h1直接取第一个h1的文本即可。但如果h1的内容是“首页”“文档”“帮助”这类导航性文字就说明这个页面可能不是正文页需要靠后续的正文提取来判断。发布时间的提取麻烦一些。有的站点有meta标签有的把时间写在页面里。我的策略是优先读meta标签里的article:published_time如果没有就用正则去常见的时间格式里碰例如import re def extract_pub_time(html_text): patterns [ rpropertyarticle:published_time content([^]), rnamepubdate content([^]), rtime[^]*datetime([^]), ] for pat in patterns: m re.search(pat, html_text, re.IGNORECASE) if m: return m.group(1) return 正文定位是这层的关键。文档站点通常结构规律正文容器多集中在article、main、div[class*content]、div[id*content]这类节点里。我的做法是给候选节点打分节点内文本长度、链接密度、段落数量、代码块数量综合加权后取分数最高的节点作为正文区域。链接密度这个指标很实用。一个纯正文的容器链接密度应该很低如果某个容器里全是“相关阅读”“热门推荐”那链接密度必然畸高。用这个特征能过滤掉绝大多数干扰。正文拿到之后清洗也有讲究。不是把标签一删就完事而是要保留结构信息。我保留p、h2、h3、pre、code、ul、ol、li这些标签去掉script、style、ins、iframe这些无关元素。顺便把图片标签里的src和alt保留下来这样后面做长图展示时图片不会丢。2.3 资源本地化让快照真正“独立存活”很多人理解的网页快照就是CtrlS存个网页但真正意义上的快照必须做到“断网也能完整打开、样式图片全部在本地”。这就要处理资源本地化。处理思路是两步第一步扫描页面里所有需要加载的资源包括img的src、link的href、script的src可能还有CSS里通过url()引用的图片和字体第二步逐个下载这些资源然后把页面里的URL替换成本地路径。听起来容易做起来坑不少。最大的坑是相对路径和绝对路径的混用有的资源是完整URL有的是以斜杠开头的站点相对路径有的干脆是“../../images/xxx.png”这种目录相对路径。统一处理的办法是先用urljoin把相对路径拼成完整URL再统一做下载。from urllib.parse import urljoin, urlparse import os def localize_resource(page_url, resource_url, save_root): full_url urljoin(page_url, resource_url) # 按路径结构生成本地保存路径 parsed urlparse(full_url) path parsed.path.lstrip(/) if not path or path.endswith(/): path path index.html local_path os.path.join(save_root, parsed.netloc, path) return full_url, local_path第二个坑是防盗链。不少站点会检查Referer头不允许外部站点直接引用它的图片。下载资源的时候我得把Referer设置成原始页面URL这个细节不处理的话快照里会有一堆破图。第三个坑是CSS里的资源。有些页面的背景图、字体文件是从CSS里引用的如果只处理HTML里的标签快照的样式还是会缺。精细一点的方案是用CSS解析器去扫描CSS内容里的url()然后把它们也下载下来。但我要提醒你这个操作有点费劲而且收益看站点。我的策略是文档站点这种以文字和代码为主的页面优先保证HTML里的图片资源本地化CSS背景图这类资源允许失败。3. 核心模块二从快照HTML到长图HTML快照负责“完整保真”但绝大多数人日常更愿意看长图。这一章讲清楚从已经保存的HTML到最终长图中间到底发生了什么以及每一条路线各自的优劣势。3.1 三种主流截图方案对比我在做长图生成的时候接触过三类方案这里直接上结论对比方案原理优点缺点无头浏览器整页截图用Playwright/Chrome DevTools直接截取全页还原度高样式和懒加载都能处理代码量最少对内存有一定要求页面过长容易失败HTML转PDF再转图片先用工具把HTML转成PDF再渲染成图片分页自然适合打印场景宽度控制不好代码高亮和夜间模式容易失真分段截图再拼接按视口高度分段截屏用Pillow拼接可控性最强能处理超长页面滚动过程中动态加载的元素可能错乱先说HTML转PDF再转图片这条路。工具本身很成熟但你会发现一个问题文档站点的正文宽度一般被限制在800像素左右但PDF渲染出来后放大到宽图时字体和间距会有微妙的变化。更麻烦的是代码高亮这个东西在PDF渲染时经常丢样式因为代码块通常是用span标签加行内样式实现的而PDF渲染器对行内样式的支持有时候不太稳定。这条路适合“我已经有一份稳妥的HTML只想顺便出一版PDF存档”的场合不适合作为长图的主要生成手段。无头浏览器整页截图是目前最主流的方式。它的逻辑就是打开一个真实的浏览器内核把页面加载出来然后调接口把整个页面画到一张图上。用Playwright做这事代码量非常小而且对懒加载、异步渲染、CSS动画都有比较好的支持因为浏览器引擎帮你处理了这一切。分段截图再拼接属于保底方案。当页面超过一定长度无头浏览器整页截图会超时或内存溢出这时候就手动控制滚动每滚动一屏截一张图最后用Pillow拼起来。缺点在于某些页面滚动时会触发懒加载而滚动期间有些元素可能来不及渲染拼出来的长图会出现内容缺失。3.2 Playwright截图的完整实操我当前的实现默认走Playwright。具体做法是启动一个无头Chromium实例设置合适的视口宽度打开页面等待网络空闲然后截图。这里有一个关键调优点视口宽度。桌面端的文档页面在1200像素到1440像素之间会有一个比较舒服的排版宽度长图做出来在电脑上看得清楚。但我实际测试发现很多文档站点在响应式布局下超过某个宽度反而会把侧边栏展开导致正文区域变窄。所以我一般把视口宽度设置成1440像素然后判断正文区域的实际宽度和位置截图时精确裁剪到正文区域。from playwright.sync_api import sync_playwright def capture_full_page(url: str, output_path: str, max_height: int 30000): with sync_playwright() as p: browser p.chromium.launch() page browser.new_page(viewport{width: 1440, height: 900}) page.goto(url, wait_untilnetworkidle, timeout60000) page.wait_for_timeout(1000) # 手动触发一下滚动让懒加载的内容渲染出来 page.evaluate( async () { const scrollHeight document.body.scrollHeight; const step window.innerHeight; for (let y 0; y scrollHeight; y step) { window.scrollTo(0, y); await new Promise(r setTimeout(r, 100)); } window.scrollTo(0, 0); } ) height page.evaluate(document.body.scrollHeight) if height max_height: raise Exception(f页面高度 {height} 超过上限 {max_height}建议走分段截图方案) page.screenshot(pathoutput_path, full_pageTrue) browser.close()这段代码里有三个细节值得注意。第一个是wait_untilnetworkidle。它表示等待所有网络请求都结束才继续执行。但有些页面有轮询请求每隔几秒就发一次心跳networkidle可能会一直等不到导致超时。我在实际使用里会把超时设成60秒并且如果networkidle等不到就退而求其次用domcontentloaded多等几秒再截图。第二个是手动滚动的这个动作。很多文档页面有图片懒加载如果用full_page直接截图往往底部的内容还没加载出来图片位置全是空白。我特意先让页面从上到下滚动一遍每100毫秒滚一屏让懒加载的图片有时间完成请求然后再滚回顶部最后才截整页图。这个步骤看着多此一举实际对长图完整性影响很大。第三个是max_height的上限判断。页面超过3万像素的时候整页截图的内存压力会很大我见过截出来的图直接花屏的情况。所以超过这个阈值就改用分段截图不硬扛。3.3 长图后处理拼接、裁剪和压缩如果走分段截图的保底方案那就要用Pillow处理拼接。我的做法是固定视口宽度每次滚动一个固定距离然后滚动偏移取整避免相邻两张截图出现缝隙或者重叠。from PIL import Image import os def stitch_images(segments_dir: str, output_path: str): imgs [] for name in sorted(os.listdir(segments_dir)): if name.endswith(.png) or name.endswith(.jpg): imgs.append(Image.open(os.path.join(segments_dir, name))) width max(img.width for img in imgs) total_height sum(img.height for img in imgs) canvas Image.new(RGB, (width, total_height), white) y 0 for img in imgs: canvas.paste(img, (0, y)) y img.height canvas.save(output_path, quality90)拼接的思路是创建一个足够高的空白画布然后按顺序把每一屏的截图贴上去。这里的细节集中在滚动步长的计算上如果用固定像素滚动浏览器总会有亚像素的取舍相邻截图可能会有几像素的偏差。我的做法是先用evaluate取页面总高度除以屏幕数量得到一个平均步长再从0开始按步长累加滚动每张截图后用window.scrollY的返回值校正下一步的位置确保滚动距离严格对齐。长图做出来之后还有个压缩的问题。一张完整的文档长图动辄三四千像素高如果不压缩一张图可能好几MB发微信会被自动压缩成渣。我的习惯是把长图限制在2MB以内通过调整JPEG压缩质量来实现正文类页面质量降到85肉眼看不出区别体积却少很多。如果是代码为主的页面我保存成PNG因为代码区域有大量纯色块和文字PNG压缩率其实不差还不会有JPEG带来的文字边缘噪点。4. 核心模块三归档管理与增量更新快照和长图都生成了如果只是丢在一个乱糟糟的文件夹里那这个工具还缺最后一公里。个人文档归档器要想长期用下去组织方式非常重要。4.1 目录结构设计我采用的目录结构是按站点和日期组织的。结构上长这样archive/ ├── index.json ├── www.example.com/ │ ├── 2025-01-15/ │ │ ├── 001-how-to-build-snapshot/ │ │ │ ├── snapshot.html │ │ │ ├── assets/ │ │ │ ├── page.png │ │ │ └── meta.json第一层是域名第二层是抓取日期第三层是编号加标题片段。这样的好处一目了然你想回顾某个站点某天抓了什么直接进对应文件夹不同站点之间不会互相污染同一个页面抓了多次也互不影响。但这里有一个必须想清楚的坑一天之内同一个URL可能会被抓多遍如果都用同一个日期目录新文件会覆盖旧文件。我的策略是在文件重名时追加时间戳后缀。归档这种事宁可多存几份“同一内容的历史版本”也不要让新快照悄悄覆盖掉旧的有用信息。meta.json这个文件不是为了装样子而存在的它记录了抓取时间、目标URL、页面标题、当时的HTTP状态码、快照文件大小、长图文件大小这些信息。这些信息在后期检索和维护时会非常有用比如你能快速揪出哪些页面抓取失败了哪些页面体积异常大哪些页面当时返回了重定向。4.2 索引与检索让资料变得可查文件目录有了我还会额外维护一个index.json作为整个归档器的索引。每次抓完一个页面就把页面的元信息和关键字段追加进索引。index.json的结构很轻量就是一个数组每个元素对应一次归档。字段包括publish_time、title、url、local_path、screenshot_path、archived_at和domain。为什么要做成JSON而不是塞进某个数据库因为这个归档器的体量就是千级以下文件一个JSON文件几十KB打开和修改都很快而且可以直接被git追踪通过git来管理历史变更。有了这个索引后续可以做的事情就多了。比如写一个简单的搜索函数按关键词过滤title和正文前200字再比如用FastAPI包一层HTTP接口做成个人知识库的后端。不过这些都属于扩展玩法核心还是先建立起“每一份快照都有明确索引”的纪律。4.3 增量更新与去重策略文档站点是活的今天抓的快照下周可能就更新了。归档器诞生第一天就要思考增量更新问题。我采用的策略是以URL为主键做去重。每次归档前先查index.json里有没有同一URL的记录。如果没有全量抓取。如果有就检查这次抓到的页面和上次快照是否有内容差异比较方式是对正文容器的文本做哈希比对。有差异才更新没差异就不重复生成长图只更新元数据里的last_checked时间。文本哈希比对这件事看起来要写不少代码但实际上就是取正文区域之后调一下hashlib非常简单。关键是你要在提取正文之后、生成快照之前做这一步。这样“是否值得重新生成长图”在早期就被决定可以省下大量截图开销。import hashlib def fingerprint_text(content_element_text: str) - str: return hashlib.md5(content_element_text.encode(utf-8)).hexdigest()另一个值得说的去重策略是“面向内容的重复检测”。有些文档站点会有转载内容不同URL指向的正文内容是一样的。在index.json里顺便记录正文的指纹哈希归档时如果发现新页面与已有页面的哈希相同我就可以选择不新增记录而是给已有记录追加一个新的URL别名。这对搜索引擎优化、对个人知识库的整理都有很实际的意义能让归档器里尽量少存重复内容。5. 实操全流程与高频问题排查前面几章把核心模块的原理讲透了这一章面向实际操作从环境准备到完整跑通再到常见问题排查一次性给你一套可复现的流程。5.1 从零跑通完整流程先交代环境。我在Windows和Linux上分别跑过这套工具Python版本用的是3.10以上主要依赖是requests、lxml、Pillow和playwright。前三个装起来很快pip install requests lxml Pillow pip install playwright playwright install chromiumplaywright install这一步会下载一个Chromium内核体积不小但这是整页截图的基础设施躲不掉。国内网络环境如果下载慢可以考虑设置playwright的下载镜像。代码组织上我建议不要把所有逻辑塞在一个文件里。我的实际工程结构是snapshot_archiver/ ├── config.py # 限速、超时、目录配置 ├── fetcher.py # 请求层 ├── parser.py # 正文提取和清洗 ├── localizer.py # 资源本地化 ├── screenshot.py # 长图生成 ├── archiver.py # 归档管理和索引更新 └── cli.py # 命令行入口跑通的流程是读待归档URL列表逐个抓取提取正文和元信息做指纹去重然后生成HTML快照和长图最后更新索引。5.2 高频问题从403到乱码再到拼接白条我在这套工具的迭代过程中遇到不少问题挑几个典型的连同排查思路列在下面症状常见原因排查与对策请求返回403站点做了基本防盗识别出非浏览器请求检查User-Agent和Accept头补上Referer降低抓取频率页面内容一堆乱码编码检测错误先看响应头里的charset没写就用requests的apparent_encoding兜底长图底部空白懒加载内容没被触发截图前先模拟滚动让图片区域完成加载长图里有内容截断整页截图超内存降级为分段截图再拼接图片全部破图防盗链未处理下载图片时必须带上原始页面的RefererCSS样式丢失资源本地化不完整检查CSS里的url()引用是否已处理403这个问题我要多说两句。很多人一遇到403就想到“封IP”或者“绕过限制”但在归档这个场景里绝大多数情况只是你的请求长得太像爬虫。先补全请求头再降低频率往往就解决了。如果对方站点确实明确禁止爬虫技术上你总有办法继续但合规层面就必须停下来这个原则我在第5.3节展开说。乱码问题也很典型。文档站点大多是UTF-8但老站经常出现GBK和GB2312。我的做法是写一个统一的decode_response函数优先用响应头里charset参数如果没有就在前1024个字节里做charset检测实在不行才用apparent_encoding。5.3 合规红线、抓取边界与个人经验爬虫这个领域技术本身就是中性的但使用场景有边界。我这个归档器从设计的初衷就是“个人备份自用”所以有两条线我始终坚持。第一只归档我有权访问的内容不绕登录墙不破解权限。站点公开可访问的内容我做一份本地快照自用这跟用户用浏览器看一遍、再存个书签没有本质区别。但如果内容是登录后才能看的那就不能通过技术手段绕过。第二抓取频率必须克制。归档器的定位是低频工具不是抢票软件默认1到2秒一个请求已经很够了没必要把对方服务器打得喘不过气。再补充一点合规层面的经验机器人协议文件我建议花一分钟看看如果对方的robots里明确禁止爬虫抓取那就尊重这个约定。有些站点即使没写但是服务条款里有相关声明也一样要遵守。个人存档用途和技术讨论是一回事碰触到版权和商业利益的边界就是另一回事了。关于频率控制我有个比较保守但省心的实现用一个简单的装饰器在所有“对外发请求”的函数上统一做限速。这样就算新增了抓取规则也不会忘了限速逻辑。import time import functools _last_request_time 0.0 def rate_limit(min_interval1.0): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): global _last_request_time elapsed time.time() - _last_request_time if elapsed min_interval: time.sleep(min_interval - elapsed) result func(*args, **kwargs) _last_request_time time.time() return result return wrapper return decorator6. 写在最后归档之外的思考这套文档站点快照与长图归档器我陆陆续续用了大半年中间迭代了好几个版本。最初它只是解决我自己的痛点——某些技术文档站点频繁改版旧链接成片地失效我连备份都没留下。后来的价值渐渐超出工具本身当我开始用git来管理整个archive目录时它实际上变成了个人版的网页时光机。每次快照提交上去目录的索引也会变化整个演进历史一目了然。我特别想分享的一个小技巧是把归档器接到定时任务里每周跑一次对收藏夹里的核心URL做一次增量抓取。更新过的正文会被自动发现新版本会生成新的快照老版本保留长图也随之更新长期运行下来你手上就多了一份“谁在什么时候改了什么”的信用记录这在查证资料时非常有用。另外这套代码的可复用性很高。爬虫抓取、正文提取、快照保存、长图生成这几个模块单独拿出来也能用在其他项目里比如批量抓取在线文档并整理成离线阅读包、监控某个页面是否更新、甚至把长图生成用于新闻报道存档。如果你自己动手把这一套跑通了后面各种知识管理相关的需求都能顺着这个思路延展。