㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐⭐ (进阶)
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟

  我长期专注 Python 爬虫工程化实战,主理专栏 《Python爬虫实战》:从采集策略反爬对抗,从数据清洗分布式调度,持续输出可复用的方法论与可落地案例。内容主打一个“能跑、能用、能扩展”,让数据价值真正做到——抓得到、洗得净、用得上

  📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间:如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。
  
💕订阅后更新会优先推送,按目录学习更高效💯~

0️⃣ 前言(Preface)

一句话交底:今天我们将使用 Python + Playwright,针对现代开源文档站点(如 VitePress、Docusaurus 生成的 SPA),自动化执行“全页渲染 → HTML 提取 → 高清长截图 → 差异比对与压缩归档”,最终产出一个带有版本追踪能力的本地静态快照库。
读完这篇硬核笔记,你将获得:

  1. 掌握现代单页应用(SPA)文档的长截图与完整 DOM 提取技术。
  2. 学会构建一套包含“版本目录、内容指纹检测、自动压缩归档”的工程化管道。
  3. 拥有一套可以直接接入定时任务(Cron/Airflow)的自动化文档备份模板。

1️⃣ 摘要(Abstract)

本文聚焦于开源文档型站点的自动化快照归档。利用 Playwright 强大的无头浏览器渲染能力,我们模拟真实用户访问,解决文档站点的动态加载与懒加载问题。系统提取页面标题、保存全量 HTML 源码、截取物理长图,并通过计算 HTML Hash 实现增量 Diff 检测。所有产出均按时间戳/版本号进行目录隔离,并通过 manifest.json 统筹状态,最终支持 ZIP 压缩归档,实现工业级的数据沉淀。

2️⃣ 背景与需求(Why)

为什么要爬?
开源界的法则是“拥抱变化”,但对于开发者来说,今天还能跑通的 API,明天可能就在官方文档里被悄悄删除了。构建私人或团队的文档归档器,可以:

  • 离线防灾:防止原站点宕机、被墙或改版导致的信息丢失。
  • 变更追溯:结合 Diff 检测,找出官方文档“暗改”的细节。
  • 高质语料:清洗后的 HTML 和截图是极佳的多模态大模型(RAG / Vision LLM)训练语料。

目标清单(核心字段):

  • url:原始文档链接。
  • page_title:页面 <title><h1>
  • crawl_time:抓取标准时间(ISO 8601)。
  • html_path:本地落盘的 HTML 文件相对路径。
  • screenshot_path:本地落盘的全尺寸高清截图路径。
  • hash:基于页面核心 DOM 计算的 MD5 指纹(用于 Diff 检测)。

3️⃣ 合规与注意事项(必写)

在构建这套强大的归档器时,我们必须坚守底线:

  • 遵循 robots.txt:开源社区通常极其开放,但扫站前依然要确认是否允许爬虫访问 /docs 目录。
  • 克制且优雅的并发:不要用 DDoS 的姿态去抓别人的静态托管站(如 GitHub Pages)。每次页面跳转间务必加入 1~3 秒随机休眠。
  • 合规边界:本工具仅限对公开可见的技术文档进行个人/内部备份,绝不用于绕过任何付费墙或抓取具有版权争议的敏感商业机密。保持技术中立。

4️⃣ 技术选型与整体流程(What/How)

选型策略:属于“动态渲染(Dynamic Rendering)”类别。
现代文档站(VitePress, MkDocs-Material 等)严重依赖 JS 渲染左侧导航和右侧目录树。如果用传统的 requests,你大概率只能拿到一个 <div id="app"></div> 的骨架。因此,Playwright 是不二之选,它能完美触发页面的懒加载图片和高亮代码块渲染。

系统流水线图(Pipeline):
(注:根据国际规范,图表英文优先展示)

Changed / New

Unchanged

Scheduler / Cron Job

Launch Playwright

Load Document URL

Scroll & Wait for Network Idle

Extract DOM & Save HTML

Take Full-Page Screenshot

Calculate Hash MD5

Diff with Previous Manifest

Save Artifacts to Version Folder

Skip or Link to Old

Update manifest.json

Compress to Archive.zip

5️⃣ 环境准备与依赖安装(可复现)

实战开始,请确保你的终端能丝滑运行以下命令:

  • Python 版本:推荐 3.10+,因为我们要用到优雅的异步或高级文件操作。

  • 核心依赖库

    pip install playwright loguru beautifulsoup4
    playwright install chromium
    
  • 高可用工程目录结构

    doc_archiver/
    ├── main.py                 # 入口引擎
    ├── config.json             # 待抓取的文档 URL 列表
    └── archives/               # 归档总库
        ├── 20231027_v1/        # 自动生成的版本目录
        │   ├── htmls/
        │   ├── screenshots/
        │   └── manifest.json   # 当前版本的元数据清单
        └── archive_20231027_v1.zip # 最终打包文件
    

6️⃣ 核心实现:请求层(Fetcher)

无头浏览器的初始化必须稳如泰山。我们要伪装 UA,设置合理的超时,并注入隐身模式。

from playwright.sync_api import sync_playwright
from loguru import logger
import time

def init_browser_context(p):
    """初始化浏览器上下文,配置防反爬 Headers 和视口"""
    browser = p.chromium.launch(headless=True)
    # 模拟常见的大屏显示器,保证文档响应式布局不乱版
    context = browser.new_context(
        viewport={'width': 1920, 'height': 1080},
        user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        java_script_enabled=True
    )
    # 全局超时设置为 45 秒,文档站有时加载国外 CDN 会卡顿
    context.set_default_timeout(45000)
    return browser, context

7️⃣ 核心实现:解析层(Parser)

这一层是归档器的心脏。我们要解决懒加载HTML剥离两个大坑。

import hashlib
from bs4 import BeautifulSoup

def process_page(page, url):
    logger.info(f"正在渲染页面: {url}")
    
    try:
        # 1. 访问并等待网络空闲 (确保 JS, CSS, 图片加载完毕)
        page.goto(url, wait_until="networkidle")
        
        # 2. 核心:平滑滚动到底部,触发所有懒加载图片!
        # 很多文档站的巨型架构图不在视口内就不加载,截图会是空白的
        page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
        page.wait_for_timeout(2000) # 给图片一点下载时间
        page.evaluate("window.scrollTo(0, 0)") # 滚回顶部,准备截图
        
        # 3. 提取标题 (容错:如果没有标题,拿 URL 的 slug 凑数)
        title = page.title()
        if not title:
            title = url.split('/')[-1] or "untitled_doc"
            
        # 4. 获取完整 HTML 源码
        raw_html = page.content()
        
        # 5. 清洗并计算 Hash:移除经常变动的随机类名或时间戳标签,防止假 Diff
        soup = BeautifulSoup(raw_html, 'html.parser')
        # 暴力移除可能影响 hash 的 script 和 iframe
        for script in soup(["script", "noscript", "iframe", "style"]):
            script.decompose()
        clean_text = soup.get_text(separator=' ', strip=True)
        page_hash = hashlib.md5(clean_text.encode('utf-8')).hexdigest()
        
        return {
            "title": title,
            "raw_html": raw_html,
            "hash": page_hash
        }
    except Exception as e:
        logger.error(f"处理 {url} 失败: {str(e)}")
        return None

8️⃣ 数据存储与导出(Storage)

工程点的核心来了:建立带时间戳的版本目录,存 HTML、存截图,最后写一份统揽全局的 manifest.json

import os
import json
import shutil
from datetime import datetime
import re

def sanitize_filename(name):
    """过滤文件名非法字符"""
    return re.sub(r'[\\/*?:"<>|]', "", name).replace(" ", "_")

def archive_artifacts(page, url, parse_result, base_dir="archives"):
    # 创建版本目录 (例如:20231027_1530)
    version_tag = datetime.now().strftime("%Y%m%d_%H%M")
    work_dir = os.path.join(base_dir, version_tag)
    html_dir = os.path.join(work_dir, "htmls")
    shot_dir = os.path.join(work_dir, "screenshots")
    
    os.makedirs(html_dir, exist_ok=True)
    os.makedirs(shot_dir, exist_ok=True)
    
    safe_title = sanitize_filename(parse_result["title"])
    file_prefix = f"{safe_title}_{parse_result['hash'][:8]}"
    
    # 落盘 HTML
    html_path = os.path.join(html_dir, f"{file_prefix}.html")
    with open(html_path, "w", encoding="utf-8") as f:
        f.write(parse_result["raw_html"])
        
    # 落盘长截图
    shot_path = os.path.join(shot_dir, f"{file_prefix}.png")
    page.screenshot(path=shot_path, full_page=True)
    
    # 构建 Manifest 记录
    manifest_record = {
        "url": url,
        "page_title": parse_result["title"],
        "crawl_time": datetime.now().isoformat(),
        "html_path": html_path,
        "screenshot_path": shot_path,
        "hash": parse_result["hash"]
    }
    
    return manifest_record, work_dir

9️⃣ 运行方式与结果展示(必写)

运行引擎:

def main():
    urls_to_archive = ["https://playwright.dev/python/docs/intro"]
    
    with sync_playwright() as p:
        browser, context = init_browser_context(p)
        page = context.new_page()
        
        manifest_data = []
        current_work_dir = ""
        
        for url in urls_to_archive:
            result = process_page(page, url)
            if result:
                record, current_work_dir = archive_artifacts(page, url, result)
                manifest_data.append(record)
                
        # 写入 manifest.json (要求使用英文文件名)
        if current_work_dir:
            manifest_path = os.path.join(current_work_dir, "manifest.json")
            with open(manifest_path, "w", encoding="utf-8") as f:
                json.dump(manifest_data, f, ensure_ascii=False, indent=4)
            logger.success(f"归档完成!清单已保存至: {manifest_path}")
            
            # 工程点:将整个版本目录压缩为 ZIP
            archive_zip_name = f"{current_work_dir}_snapshot"
            shutil.make_archive(archive_zip_name, 'zip', current_work_dir)
            logger.success(f"版本包已压缩归档: {archive_zip_name}.zip")

        browser.close()

if __name__ == "__main__":
    main()

展示结果:
运行完毕后,你的 archives/ 目录下会出现完美的 manifest.json

[
    {
        "url": "https://playwright.dev/python/docs/intro",
        "page_title": "Installation | Playwright Python",
        "crawl_time": "2023-10-27T15:30:12.456Z",
        "html_path": "archives/20231027_1530/htmls/Installation_Playwright_Python_a1b2c3d4.html",
        "screenshot_path": "archives/20231027_1530/screenshots/Installation_Playwright_Python_a1b2c3d4.png",
        "hash": "a1b2c3d4e5f607890123456789abcdef"
    }
]

🔟 常见问题与排错(强烈建议写)

不要以为有了 Playwright 就能横着走,面对庞大的开源文档,这些坑你必须懂:

  1. 长截图报错 Timeout 或图片被截断?
    对策:超长页面(高度>10000px)很容易把浏览器的显存撑爆。遇到这种情况,在 page.screenshot 传参时加入 animations="disabled",并适当降低 viewport 的宽度。
  2. 截到的页面全是被遮挡的“Cookie 授权同意框”?
    对策:外网文档 100% 有这个横幅!在 process_page 里加一行注入代码:page.evaluate("document.querySelectorAll('.cookie-banner, #onetrust-banner-sdk').forEach(el => el.remove())"),截图前强行干掉它!
  3. 编码/乱码如何处理?
    对策:提取 HTML 时,务必显式声明 encoding="utf-8"。在解析 Title 时如果出现奇怪的 &amp;,用 Python 标准库的 html.unescape() 清洗一下。

1️⃣1️⃣ 进阶优化(可选但加分)

这份归档器要上生产线,还需要这两剂猛药:

  • 无缝对接 Airflow / Cron 自动化:将这个脚本部署到 Linux 服务器上,通过 crontab -e 设置 0 2 * * * python3 main.py(每天凌晨2点执行)。你只需隔段时间去服务器拉取 .zip 压缩包即可。
  • 增量 Diff 策略升级:每次抓取前,先加载上一次的 manifest.json。对比当前页面的 Hash,如果一致,则直接在新的 manifest 里软链接(Symlink)指向上一个版本的图片路径。这样可以节省 80% 硬盘空间!

1️⃣2️⃣ 总结与延伸阅读

恭喜你!🎉 通过组合 Playwright 的长截图机制与 Python 的文件管理能力,我们成功搭建了一座“文档避难所”。这套架构不仅能应对开源仓库,同样可以降维打击各种对外的产品操作手册站。

未来,你可以进一步把它改造成一个 Git 追踪库:直接让爬虫把抓下来的 HTML 提交到本地的 Git 仓库里。这样一旦文档更新,你连 Diff 报告都不用自己写,直接 git diff 就能看清官方到底偷偷改了哪几个单词!简直完美!

🌟 文末

好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

✅ 专栏持续更新中|建议收藏 + 订阅

墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以“入门 → 进阶 → 工程化 → 项目落地”的路线持续更新,争取让每一期内容都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?

评论区留言告诉我你的需求,我会优先安排实现(更新)哒~


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


✅ 免责声明

本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。

使用或者参考本项目即表示您已阅读并同意以下条款:

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 “谁使用,谁负责” 。如不同意,请立即停止使用并删除本项目。!!!
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐