
1. 项目概述与核心需求解析1.1 这个项目到底在解决什么问题先说清楚一件事文档站点的快照与长图归档本质上是在做“网页内容的可视化留档”。我当初接这个需求时甲方提了一堆看似零散的要求——某个内部知识库的页面经常改版领导希望能在每次发布前留一份“当时长什么样”的视觉证据运营那边想把一批产品文档整理成长图直接丢到公众号和朋友圈里传播还有一个更现实的问题——有些页面里的表格、代码块、流程图直接复制到 Word 里全乱套但截图又只能截一屏滚动后根本拼不完整。这三个需求凑到一起其实就是同一个技术方案用 Python 爬虫把目标页面抓下来渲染成完整的长图再把历史版本归档成快照文件。这个场景在业内有个更常见的叫法——网页长截图存档但加上“爬虫”这个前缀后它就不是简单按一个浏览器插件能解决的了而是要具备批量处理、定时执行、自动命名归档的能力。很多新手容易陷入一个误区觉得快照就是数据库备份觉得爬虫就是只提取 HTML 里的文本。但真正实操过才知道文档站点的快照归档器要处理的细节远比表面看到的复杂。页面里的懒加载图片是否全量加载完了动态渲染的内容有没有等到 JS 执行完毕固定定位的导航栏会不会在长截图中反复出现深色模式的背景色会不会在某些渲染引擎下变成黑底白字这些都是在开发过程中躲不开的坑。1.2 适合谁来参考这份实战方案这个项目的受众其实很明确。如果你是刚学完 Python 基础正愁找不到一个能串联 requests、Selenium、Pillow 等常见库的综合练习项目这个归档器非常合适。如果你是在公司内部负责文档平台、知识库、项目官网的运维或内容运营厌倦了每次改版前手动截图备份的手工活这篇文章可以直接给你一套能跑的自动化方案。如果你纯粹是想给自己的博客、简历站点做一个“历史版本博物馆”那更是匹配得不能再匹配。我不打算在这篇文章里堆一堆只会让新手劝退的高深理论而是从我真实踩过的坑出发把整个搭建过程拆成可以一步一步照着做的方案。我用的核心工具链是 Python 3.8、Selenium、requests、BeautifulSoup、Pillow 以及一个非常关键的“幕后功臣”——html2image 或者 Chrome Headless 模式。1.3 整体逻辑一句话讲清整个系统的工作流是这样的先用 requests 和 BeautifulSoup 分析目标站点的链接结构拿到所有需要归档的页面 URL接着用 Selenium 或 Chrome Headless 把这些页面依次打开控制滚动条让懒加载全部触发再用 DevTools 协议或直接截图接口切出完整页面尺寸最后用 Pillow 做图片拼接、压缩和命名归档同时把时间戳写进文件名形成一套可追溯的快照体系。这套方案有四个核心优势其一全自动化定时任务挂上之后不需要人工干预其二可视化留痕无论页面以后怎么改版历史版本都能随时打开看其三格式统一且可传播输出目录下就是一张张标准长图适合归档也适合发布其四扩展性强加一个邮件通知模块、加一个对象存储上传模块都是顺手的事。2. 技术选型与方案对比2.1 为什么选了 requests Selenium 组合拳爬虫圈有个经典争论到底用 requests 直接拉 HTML 解析还是用 Selenium 模拟浏览器行为我的结论很明确——小孩子才做选择成年人两个都要。这套归档器里requests 负责侦察和地图绘制Selenium 负责实地拍摄。requests 做得又快又轻请求头伪装、session 保持、cookie 传递、代理池切换这些都是它的强项。我在项目里用 requests 做的事情是把目标站点的目录页、列表页拉下来分析链接层级结构过滤出所有需要归档的文章页 URL。这个阶段如果也用 Selenium那效率会低得让人抓狂——每打开一个页面都要等浏览器初始化几百个 URL 可能要跑几十分钟。Selenium 负责的是真正“拍快照”的阶段。requests 拿到的 HTML 只是静态源代码但现代文档站点几乎全是动态渲染的React、Vue 这类框架把内容都藏在 JS 里直接抓 HTML 经常抓回一个空壳。Selenium 驱动真实浏览器执行完 JS、加载完所有资源后再截图才能保证快照的质量。我在实操中还发现很多页面用了 IntersectionObserver 做懒加载必须模拟真实的滚动行为才能把图片、图表、代码高亮这些异步加载内容全部“逼”出来。2.2 截图引擎的选型Chrome Headless 还是 html2image这个环节我花了不少时间做对比测试。方案一是直接用 Chrome 的 Headless 模式调用命令行截图优点是稳定、和真实浏览器环境完全一致缺点是参数调节不够灵活处理多标签页和复杂交互场景时脚本控制力有限。方案二是用 html2image 这个库它封装了 Chrome DevTools Protocol能把整个页面渲染成图片支持设置窗口大小、等待时间、截取元素等参数。我最终选了 Selenium Chrome Headless 为主、Pillow 做后处理的三层架构。原因是归档器需要的不只是截一张图而是控制滚动、等待加载、适配不同页面宽度这一整套流程。Selenium 可以让我精准控制浏览器的每一步行为比如先滚动到页面底部再回到顶部、等待某个元素出现后再截图、根据 viewport 高度动态计算截图次数。这些操作虽然用 CDP 也能实现但编写和维护成本高不少。2.3 图片拼接方案Pillow 还是手动拼接当你用 Selenium 截图时默认只能截取当前视口区域的图片而不是整个页面。想拿到完整长图有两种思路一种是调用 CDP 的 Page.captureScreenshot 接口并传入完整的布局度量值Chrome 会一次性渲染整页大图另一种是老老实实分多次滚动截图然后用 Pillow 按顺序拼接。我建议两种都要会因为实际场景下各有用武之地。页面结构简单、没有固定元素的情况下一次性截全图效率最高遇到那种导航栏固定、弹窗广告悬浮、背景图重复铺满的页面一次性截全图反而会出现各种错位或重复元素这时候分段截图加拼接反而更稳。Pillow 在这个流程里的地位是最后的保障哪怕前面都不顺利只要有一堆分块截图我就能拼出完整长图无非是处理一下边缘重叠部分的去重。2.4 请求伪装与反爬应对策略既然题目带了“爬虫”两个字就不可能绕开反爬这个话题。实操下来文档站点的反爬强度通常不高但也不是完全不设防。我遇到过的情况有直接 requests 访问返回 403 的访问频率过高后被临时封 IP 的页面里埋了数据伪造的“蜜罐”链接的。我的应对方案有三层。第一层requests 阶段就把请求头伪装成真实浏览器User-Agent、Accept-Language、Referer 全都要设置到位不能只丢一个 UA 就完事。第二层在 Selenium 阶段通过启动参数隐藏自动化特征比如禁用 navigator.webdriver 标志、加上 --disable-blink-featuresAutomationControlled 参数。第三层控制请求频率归档器默认每两个页面之间睡 1 到 3 秒随机停顿既礼貌又安全。重要提示做任何爬虫项目都必须在法律和道德的框架内行事。我只建议对你自己拥有权限的站点、公司内部系统、明确允许爬取的公开页面做归档。另外robots.txt 还是要尊重的别为了一时的数据需求去碰明确禁止的路径。3. 环境准备与依赖安装3.1 Python 环境与虚拟环境管理我在实操中强烈建议不要直接在系统全局环境里装依赖而是用一个独立的虚拟环境。这个教训是我在一次升级 Pillow 时踩的——全局环境里另一个项目依赖旧版 Pillow升级完那个项目的验证码识别直接挂了。所以归档器项目一定要有自己独立的环境。创建虚拟环境的命令很简单Python 3.8 及以上版本都自带 venv 模块mkdir doc_snapshot_archiver cd doc_snapshot_archiver python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate激活后能看到命令行前面多了个(venv)前缀就说明环境已经切换成功了。这一步的目的是隔离以后这个项目装什么库、升级什么版本都不会污染系统的其他 Python 项目。3.2 核心依赖库清单与安装命令这个项目依赖的库不算多但每个都有明确用途。我在下面的表格里列出完整清单和安装方式方便你直接复制pip install requests beautifulsoup4 lxml selenium pillow pip install webdriver-managerrequests负责页面列表和文章详情的 HTTP 抓取是整个流程的“侦察兵”。beautifulsoup4 lxml解析 HTML 结构提取链接、标题、正文元信息。selenium驱动 Chrome 浏览器执行 JS、模拟滚动、触发懒加载。pillow负责长图拼接、格式转换、压缩优化。webdriver-manager自动下载和匹配 ChromeDriver 版本省去手动找驱动的麻烦。这里有一个值得说明的选择为什么不直接用 Pyppeteer 或 Playwright它们的 API 确实更现代性能也好。但 Selenium 的生态最成熟遇到问题网上随便一搜就有答案对新手最友好。而且这个项目里浏览器的角色就是“无脑渲染然后截图”用 Selenium 这样成熟稳定的方案性价比最高。3.3 ChromeDriver 的版本匹配问题新手最容易卡住的就是 ChromeDriver 和 Chrome 浏览器版本不匹配。Chrome 一升级ChromeDriver 没跟上Selenium 直接报SessionNotCreatedException。我用了 webdriver-manager 这个库之后这个问题基本就根治了。它会自动检测你电脑上安装的 Chrome 版本然后去下载对应版本的驱动非常省心。如果你电脑上还没装 Chrome那就先装一个稳定版。如果你服务器环境是 CentOS 或 Ubuntu可以考虑用 chromium-browser 替代Selenium 一样能驱动它。实际操作时初始化浏览器对象的代码如下from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager options webdriver.ChromeOptions() options.add_argument(--headless) options.add_argument(--disable-gpu) options.add_argument(--no-sandbox) options.add_argument(--disable-blink-featuresAutomationControlled) options.add_argument(--window-size1440,3000) driver webdriver.Chrome( serviceService(ChromeDriverManager().install()), optionsoptions )--window-size1440,3000这个参数很有讲究。1440 是文档站点最常见的宽屏设计宽度3000 是给初始视口一个较高的值降低首屏截图时的高度误差。后面我还会用 CDP 命令动态获取真实页面高度这个初始值只是兜底用的。4. 核心功能实现详解4.1 链接采集模块从目录页提取所有归档目标归档器的第一步是把“要归档哪些页面”这个问题搞清楚。文档站点的链接结构通常有规律可循要么在侧边栏的目录树里要么在首页的卡片列表里。我用 requests 抓目录页 HTML然后通过 BeautifulSoup 定位所有符合规则的链接。核心代码如下import requests from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse def collect_page_urls(start_url, allowed_domain): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: start_url, } resp requests.get(start_url, headersheaders, timeout15) resp.raise_for_status() resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, lxml) urls set() for a in soup.find_all(a, hrefTrue): href a[href].strip() full_url urljoin(start_url, href) # 只保留允许域名下的 http/https 链接 if not full_url.startswith(http): continue if urlparse(full_url).netloc ! allowed_domain: continue # 过滤掉明显的非文档类链接 if any(keyword in full_url for keyword in [#, javascript:, mailto:, .zip, .pdf]): continue urls.add(full_url) return list(urls)这里有几个细节值得强调。第一resp.encoding resp.apparent_encoding这行必须要有很多站点不声明 charset 或者声明得不准确不手动改编码的话中文标题全是乱码。第二用urljoin处理相对路径而不是简单字符串拼接因为页面里经常有../docs/getting-started/这种带层级关系的路径。第三用一个 set 去重因为一个链接可能同时出现在侧边栏和页脚重复抓取纯属浪费。采集到这个 URL 列表后我会把它们保存成一个urls.txt文件每一行一个链接。这样做的目的是让采集模块和截图模块解耦我可以随时手动增删归档列表不必重新跑一遍抓取流程。4.2 页面标题与元数据提取归档文件需要有个合理的命名规则不能全是page_001.png这种没人看得懂的名字。我的方案是抓取每篇文章的title标签和一级标题通常是h1再把特殊字符替换成下划线作为文件名的一部分。具体实现逻辑如下def extract_meta(url, driver): driver.get(url) # 等待页面核心内容渲染完成 driver.implicitly_wait(5) title driver.title.strip() or untitled # 清洗非法文件名字符 title re.sub(r[\\/:*?|], _, title) return title为什么这一步要在 Selenium 拿到的页面里做而不是用 requests 的响应对象做因为现在很多站点的title是前端 JS 动态设置的静态 HTML 里只有一个默认值。用 Selenium 拿到的才是用户真实看到的标题。我前面有一版就是直接从 requests 解析标题结果几十个页面全叫“首页”归档文件几乎没法区分只能返工重抓。4.3 页面加载与滚动控制这是整个项目最核心、也最容易出 bug 的环节。很多文档页面的主体内容高度不止一屏但浏览器默认截图只能截到当前视口大小。为了让懒加载的图片、图表全部渲染出来必须先模拟用户滚动行为把一个页面从头滚到尾。我的滚动策略是分段均匀滚动每滚动一段就暂停一会儿模拟人的阅读节奏同时给浏览器留出执行 JS 和加载资源的时间import time def scroll_page_to_bottom(driver, pause0.3): # 获取页面的完整高度 last_height driver.execute_script(return document.body.scrollHeight) while True: # 每次滚动一个视口高度 driver.execute_script(window.scrollBy(0, window.innerHeight);) time.sleep(pause) new_height driver.execute_script(return document.body.scrollHeight) # 如果滚动后高度没有变化说明已经到底了 if new_height last_height: # 最后再滚动一次确保触发底部附近的内容 driver.execute_script(window.scrollTo(0, document.body.scrollHeight);) time.sleep(1) break last_height new_height这个逻辑里有个关键判断new_height last_height。为什么滚动过程中页面高度会变化因为很多页面是无限加载的滚动到接近底部时又加载出新内容页面高度就变大了。逻辑里用“高度不再变化”作为结束条件能兼顾那些滚动加载更多内容的站点。但也要注意某些站点存在无限滚动刷不完的假象比如 feed 流所以我额外设置了一个最大滚动次数上限比如 20 次后强制停止防止死循环。4.4 完整页面长图截取滚动完成后页面里的资源应该都加载齐了接下来就是截长图。我用的是 Chrome DevTools Protocol 的截图能力通过 Selenium 的execute_cdp_cmd方法调用def capture_full_page(driver, output_path): # 计算完整页面的宽高 metrics driver.execute_cdp_cmd(Page.getLayoutMetrics, {}) total_height metrics[contentSize][height] total_width metrics[contentSize][width] # 配置截图参数 params { format: png, fromSurface: True, captureBeyondViewport: True, clip: { x: 0, y: 0, width: total_width, height: total_height, scale: 1.0 } } result driver.execute_cdp_cmd(Page.captureScreenshot, params) with open(output_path, wb) as f: f.write(base64.b64decode(result[data])) return total_height这个方案比逐段截图加拼接要稳得多因为captureBeyondViewport: True让 Chrome 直接渲染一整块超出视口区域的内容图片是一气呵成的不会有接缝。需要注意的一点是如果页面里有position: fixed的导航栏这种截图方式可能会让导航栏在长图里只出现一次而在滚动阅读的场景下固定导航栏应该重复出现才符合视觉直觉。这个问题取决于你更想要“干净的长图”还是“存档真实观感”我的默认选择是干净的长图——因为归档的目的是记录内容本身而不是模仿浏览器的滚动体验。4.5 图片拼接与多版本对比图生成万一某次截图时 CDP 接口不好使我在某些自定义 Chromium 环境里遇到过接口缺失的问题就要退回分段截图加 Pillow 拼接的老方案。分段截图的基本逻辑是先把页面滚到顶部然后每次滚一个视口高度并截图最终把这些截图按顺序粘在一起。分段截图的边缘重叠问题是最大的坑。Chrome 的窗口高度和页面滚动高度之间存在对不齐的情况可能导致每两张相邻截图之间出现大约 1 到 2 像素的间隙拼接后长图就会出现细线。我的解决办法是每次截图范围在上一次基础上多截 20 像素的重叠区然后用 Pillow 的alpha_composite或简单从重叠区中间裁切掉一行让图片过渡自然。首次尝试的详细拼图代码如下from PIL import Image def stitch_images(clip_paths, output_path): images [Image.open(p) for p in clip_paths] total_height sum(img.height for img in images) max_width max(img.width for img in images) # 创建白色底布防止透明像素造成缝隙 canvas Image.new(RGB, (max_width, total_height), (255, 255, 255)) y_offset 0 for img in images: canvas.paste(img, (0, y_offset)) y_offset img.height canvas.save(output_path, quality95)我后来总结了经验能用 CDP 一口气截完的页面尽量别分段拼接。分段拼接只是保底方案。只有当页面内部有特别复杂的 Canvas 动画、WebGL 场景或者裁剪遮罩层时一次性渲染可能失败才需要分段。实际项目中九成以上的文档页面用 CDP 一次搞定就够了。4.6 快照归档与命名策略归档不仅是生成图片还要让图片“找得到”。我设计了如下目录结构snapshots/ ├── 2025-01-15_14-30-00/ │ ├── getting-started.md (可选保存页面正文文本) │ ├── getting-started.png │ ├── installation-guide.png │ └── meta.json ├── 2025-01-16_09-00-00/ │ └── ...命名规则是“日期时间页面标题”。日期时间代表这次批量归档的任务批次页面标题代表具体文档。meta.json里记录每个页面的 URL、标题、截图时间、页面尺寸、Chrome 版本等信息方便以后追溯。生成meta.json的代码不复杂核心是把各个模块产生的信息汇总到一个字典里然后用json.dump写入文件。我特别强调把 Chrome 版本号记录进去是因为不同版本渲染同一页面可能会有细微差异将来排查“为什么这周截图多了几像素偏移”这种问题时版本号能帮你快速定位。5. 地址提取与长图生成实操流程5.1 自定义目标站点适配器不同文档站点的链接结构千差万别所以我设计了一个简单的“适配器”模式把链接采集规则从核心逻辑中解耦出来。这样当我从站点 A 切到站点 B 时只需要新增一个适配器文件不用动主流程代码。适配器本质上就是一组配置加两个函数。配置里声明目标域名、目录页 URL、链接筛选规则函数里封装“从目录页提取所有文章链接”和“判断某个链接是否为有效文章页”这两个能力。这里给出一个通用的示例配置结构SITE_CONFIG { name: MyDocs, domain: docs.example.com, entry_url: https://docs.example.com/, url_patterns: [ r^https://docs\.example\.com/.*\.html$, r^https://docs\.example\.com/guides/.*$, ], exclude_keywords: [tag/, author/, category/], wait_after_load: 2, scroll_pause: 0.3, scroll_max_times: 20, }这套配置的妙处在于把“哪些页面该归档”这个业务决策从代码逻辑里抽离出来。我只需要在 config 文件里增加正则表达式规则就能精确控制抓取范围避免误抓整个站点下的无关页面。比如只归档guides目录下的页面或者只归档 URL 以.html结尾的静态页面。5.2 主流程控制代码主流程的控制逻辑应该足够清晰让任何一个接手的人都能看懂整个流程是如何串起来的。我写了一个run_archiver.py它按以下步骤依次执行def main(): config load_config(site_config.py) urls collect_page_urls(config[entry_url], config[domain]) # 初始化浏览器 driver create_driver() # 创建归档目录 snapshot_dir create_snapshot_dir() meta_list [] for idx, url in enumerate(urls, 1): print(f[{idx}/{len(urls)}] Processing {url}) try: driver.get(url) scroll_page_to_bottom(driver, config.get(scroll_pause, 0.3)) title extract_meta(url, driver) output_path os.path.join(snapshot_dir, f{sanitize(title)}.png) capture_full_page(driver, output_path) meta_list.append({ title: title, url: url, image: os.path.basename(output_path), time: datetime.now().isoformat() }) except Exception as e: log_error(url, str(e)) continue finally: time.sleep(random.uniform(1, 3)) driver.quit() save_meta(meta_list, os.path.join(snapshot_dir, meta.json))每一轮循环里都对单个页面做了异常捕获这是为了保证一个页面出错不至于让整个归档任务中断。finally里的随机延时是控制访问频率的“软性限速”也是对目标站点的基本尊重。5.3 长图生成后的压缩与格式转换Pillow 在这里派上了大用场。浏览器截出来的 PNG 长图动辄几 MB一张两张还好批量归档几百张就会占用大量磁盘空间。我的方案是默认保留 PNG 原图作为存档底版同时用 Pillow 生成一版 JPEG 压缩图用于快速预览和分享传播。from PIL import Image def compress_to_jpeg(png_path, jpeg_path, quality85): img Image.open(png_path) # 如果图片宽度过大先等比缩放 if img.width 2000: ratio 2000 / img.width new_height int(img.height * ratio) img img.resize((2000, new_height), Image.LANCZOS) # 将 RGBA 转 RGB避免保存 JPEG 出错 if img.mode RGBA: bg Image.new(RGB, img.size, (255, 255, 255)) bg.paste(img, maskimg.split()[3]) img bg img.save(jpeg_path, JPEG, qualityquality, optimizeTrue)RGBA 转 RGB 这一步经常被忽略但它是致命的——直接保存 JPEG 会报cannot write mode RGBA as JPEG。我一开始也栽在这个坑里后来想到加一层白色背景再合成问题就解决了。5.4 定时任务与增量归档手工跑python run_archiver.py只能算半自动。真正的归档器要能定时执行比如每周五下午五点自动跑一遍。Linux 服务器上最轻量的方案是 crontab# 每周五 17:00 执行归档任务 0 17 * * 5 cd /home/user/doc_snapshot_archiver /home/user/doc_snapshot_archiver/venv/bin/python run_archiver.py logs/archiver.log 21Windows 环境下可以用任务计划程序设置触发器为“按周”选择周五操作里指定venv\Scripts\python.exe run_archiver.py。增量归档的判断逻辑是如果目标页面在指定时间范围内没有更新过可以通过页面里的“上次更新时间”字段或 ETag 判断就跳过截图。这个逻辑看起来简单实际价值却很大——能大幅减少冗余快照和存储开销。6. 常见问题与排查技巧实录6.1 页面内容加载不全长图底部一片空白这个问题出现的频率最高几乎每个刚上手的朋友都会遇到。原因基本是两种一是滚动速度太快页面还没来得及执行懒加载逻辑就滚到底了二是页面底部有异步请求的推荐内容需要额外等待。我的排查口诀是先调大scroll_pause再看 Network 面板里有没有 pending 请求。如果滚到底部后还有 xhr 请求在 pending 状态就在滚动循环结束后再加一个额外的等待时间。实测中把scroll_pause从 0.3 秒调到 0.6 秒配合滚到底后再等 2 秒懒加载图片的加载成功率能从七成提到接近一百。6.2 中文文件名乱码或文件路径过长Windows 上文件路径最长不能超过 260 个字符一些超长标题的页面会在这里报错。我的处理方法是限制文件名为前 40 个字符超出部分截断同时用 MD5 后缀保证唯一性。如果你在 Linux 平台干活中文字符没问题但建议也做同样的长度限制防止文件系统 inode 性能和个人阅读习惯的问题。6.3 页面内有代码块的缩进或者表格列错位长图归档时代码块的渲染效果和站点的 CSS 强相关。如果你发现截图里的代码块字体太小、行号被裁掉可以先在浏览器里设置缩放。更靠谱的办法是在截图前用 Selenium 执行一段 JS临时修改页面中代码块的字体大小和行高让截图效果更接近“适合传播的阅读版”。driver.execute_script( document.querySelectorAll(pre, code).forEach(el { el.style.fontSize 16px; el.style.lineHeight 1.6; }); )这个小技巧对代码类文档特别有效。归档目的如果是内部留档原样截图就行如果还要对外传播这种“截图前微调样式”的做法几乎百分百能提升成品质量。6.4 长图文件过大内存直接爆掉一次性截取高度超过 2 万像素的页面时Chrome 和 Pillow 的内存占用都会直线上升。我遇到过一个极端案例某个 API 参考页面加上所有注释块总高度达到了 30000 像素截图进程差点被系统 OOM 杀掉。解决办法是把超高页面切成两段截然后再拼起来或者用 CDP 的clip参数分别截取上半部和下半部。另外一个非常实用的技巧是截完图马上用 Pillow 转成 JPEG然后立即释放原图对象和driver的页面占用避免一直持有大尺寸图片数据。6.5 常见问题速查表问题现象主要原因快速排查方法解决办法截图后图片区域有空白懒加载未触发检查滚动循环是否执行、等待时间是否足够增大scroll_pause、滚动到底后再等 2 秒页面只截到首屏CDP 参数未启用检查captureBeyondViewport是否为True设置该参数为 True并传入 clip 参数中文标题乱码编码未识别打印resp.encoding和apparent_encoding强制指定编码或在 Selenium 里读取真实标题浏览器报 SessionNotCreatedExceptionChromeDriver 版本不匹配查看报错中要求的版本使用 webdriver-manager 自动匹配长图拼接处有黑线或缝隙截图范围有重叠或遗漏检查相邻截图在 y 轴上是否连续手动保留 10 像素重叠再在拼接时裁剪掉Pillow 保存 JPEG 报错RGBA 模式不支持 JPEG打印图片 mode先转成 RGB再加白底合成6.6 排查时必用的三类调试工具一是 Selenium 的driver.save_screenshot(debug.png)在滚动、等待、截图这三个关键节点各存一张能直观看到浏览器执行过程中的实际渲染状态。二是打开浏览器开发者工具录制 Network 日志用 Selenium 的 performance log 可以实现但更简单的方法是先在浏览器里手动跑一遍页面看看哪些资源是滚动后才请求的。三是给每个归档任务生成一个 debug 页面把所有失败链接的异常堆栈和当时的页面 URL、标题、截图时间统一收集起来放在logs/error.log里。这个日志文件在批量归档几百个页面时特别有用能帮你快速定位是哪几类页面反复出错。7. 项目扩展方向与经验沉淀项目做完之后我思考过它能怎么继续发展这里把我的经验和思路一并分享出来。第一个扩展方向是存储迁移。本地目录归档只是起点一旦快照量上到几百上千张就应该考虑接对象存储。脚本里只需要把save_meta之后的部分替换成上传逻辑用 minio-py 或 boto3 的接口就能把快照传到私有桶里再配合 CDN 域名对外访问。我遇到的坑是上传前一定要检查文件名是否包含中文和空格有些对象存储桶策略不允许这些字符提前做 URL 编码能省不少事。第二个扩展方向是差异对比。归档的意义不只是保存“曾经的样子”还想快速知道“这次和上次比到底改了哪些地方”。可以给每个页面生成一个基于标题、正文关键段落哈希的指纹文件归档时对比指纹差异只针对发生变化的部分生成新的快照。更进阶的玩法是用 OpenCV 或 perceptual hash 算法比较两张长图的相似度把差异超过阈值的图片自动打标作为人工复核的依据。第三个方向是与消息通知联动。定时任务跑完之后往飞书群、企业微信群里推一条消息附上本次归档的页面数、失败数、耗时以及一张最新的长图预览。这个联动对运营和项目管理场景特别有价值能让大家在不登录服务器的情况下掌握归档系统的健康状况。再强调一点无论扩展方向怎么选整个归档器的地基永远是基于爬虫的页面采集能力、基于浏览器自动化渲染的截图能力、以及基于 Pillow 的图片处理能力。这三者的稳定性和配合默契程度决定了整套系统能走多远。8. 实操心得与最后的小技巧这个项目我从前到后做了大概三天中间踩过的坑足够写满一屏幕但最值得沉淀下来的其实就三条心得。第一条是关于“等待”的哲学。页面加载、懒加载触发、图片解码每一步都需要时间贪快的结果就是截图缺东西。我最后把所有时间参数都抽出来放进了配置文件每个站点单独调优。implicitly_wait、page_load_timeout、scroll_pause、post_scroll_wait这四个参数就像炒菜的火候不同站点、不同页面结构要配不同的档位。宁可让一个页面多等 3 秒也不要存下一张缺胳膊少腿的快照。第二条是“日志即生命”。没有日志的自动化任务就是一个黑箱出错了根本无从下手。我现在养成的习惯是每个关键步骤都要print或写日志标注当前 URL、步骤名、耗时和执行结果。归档完顺手把logs/archiver.log扔进同一个快照目录里这样就形成了一份完整的任务自述书。第三条是关于“截图后必须验证尺寸”。很多人截完图就直接归档了从不检查生成的文件是否真的是完整长图。我的习惯是在capture_full_page函数后立刻用 Pillow 读一遍图片尺寸和 CDP 返回的contentSize比对误差超过 20 像素就报警重截。这个验证习惯帮我拦下了无数次“看似成功实则截断”的失败任务。最后再分享一个实用的小技巧如果你想快速核实一张快照是不是完整页面不用打开图片慢慢拖直接用 Pillow 把图片的高度和宽度打印出来再和页面里的文档字数做粗略对比。如果一篇 5000 字的文档高度只有 800 像素那几乎可以断定截图出了问题。换个角度说我们在排查归档问题时往往不需要火眼金睛只要用数字说话问题就像秃子头上的虱子一样藏不住的。这套方案现在已经稳定跑了几个月每周自动归档一次服务器上沉淀了一整面墙的历史版本。每次站点改版后翻出旧快照对比或者运营那边要一张长图素材我都觉得当初花在细节调试上的时间没有白费。如果你也遇到类似的文档归档需求照着这个方案搭一版遇到问题欢迎按表排查——毕竟我这篇文章能写出来的坑几乎都是你即将要踩的坑。