最近总在整理自己的收藏夹,发现一个很扎心的事实:很多当年认真保存的文章,链接已经打不开了;有些技术文档还活着,但页面被改版得面目全非,旧版本再也找不回来。我后来琢磨了一个Python爬虫小项目,把它叫做“开源数字博物馆”,本质上是一个文档站点快照与长图归档器。输入一个网址,它会用requests把页面抓下来,用lxml的XPath抽取正文,再通过playwright给整页拍一张“全景长图”,最后把所有文件按时间归档到本地目录。这个项目就是为了解决“网页会消失”这件事,适合做知识库沉淀、文档留存、页面改版对比,也适合刚入门的爬虫学习者练手。这篇文章会把手搭环境到跑通全流程完整讲一遍,代码都能直接复制使用。
1. 这个“开源数字博物馆”到底要解决什么问题
1.1 为什么需要网页快照:网页会“消失”
很多人都有一个错觉,觉得东西放在网上就等于永远存在。实际上网页消失的速度比你想象中快得多。我遇到过几次特别心疼的情况:一个技术论坛因为维护关闭,里面几百篇老帖子全没了;某个开源项目把文档站重构,老版本的接口说明直接下架;还有人写了好几年的博客,某天域名到期,文章全部404。
这些内容一旦失去,搜索引擎也救不回来,因为源头已经没有了。收藏链接只能算“记住了位置”,并不等于“拥有了内容”。真正可靠的方案是在内容还活着的时候,把页面以快照形式完整保存到本地。快照的意义不是“复制一份文字”,而是把某个时间点页面真实的样子固定下来。等将来网页变了、没了,你还能打开本地版本,看到当时的排版、当时的配图、当时的完整正文。
1.2 快照归档器的核心设计:分层保存,按需取用
我给这个工具定的核心设计理念,是“每个网站是一个分馆,每个页面是一件展品”。抓下来的内容不是简单堆在一个文件夹里,而是按域名、按时间组织成清晰的目录,每一层保存不同粒度的信息。
一个完整的快照包含三种形态:原始HTML、清洗后的纯文本、整页长图。设计成三种而不是一种,是因为用途完全不同。原始HTML用于“原样还原”,哪怕CSS丢失,也能看到最完整的页面结构;纯文本用于阅读和检索,去掉广告、导航、评论区噪音之后,正文可以很方便地复制进笔记软件;整页长图用于“打开即看”,不需要任何依赖,双击图片就能浏览整个页面的全貌。
这里的核心取舍是“保真”和“可用”并重。只存HTML虽然完整,但离线打开时样式和图片经常出问题;只存文本虽然清爽,但丢失了版式和视觉信息。让三种形态各司其职,归档才真正有价值。
1.3 技术选型:requests + lxml + playwright + Pillow
这个项目的技术栈不算新,但每一层都是仔细想过的。requests负责最基本的网络请求,足够稳定,也好理解。lxml负责解析HTML,它的XPath能力在文档类网站里特别好用,比正则表达式抗改版能力强太多。playwright负责两件事:一是无头浏览器渲染动态页面,二是按固定视口高度分段截图。Pillow负责把分段截图拼成一张长图。
对比过其他方案:Selenium虽然也能做渲染和截图,但依赖浏览器driver,安装配置相对繁琐;纯正则提取正文,遇到稍微复杂的页面就崩,维护起来像噩梦;直接用requests下载HTML不做渲染,碰上SPA站点就只有一个空壳。所以这个组合是在“简单够用”和“覆盖动态页面”之间找平衡。工具越简单越好,但渲染和截图这两步省不了。
2. 环境搭建与爬虫基础:先把“展品”抓下来
2.1 环境准备与依赖安装
我在本地用的是Python 3.9以上的版本。安装依赖这一步,建议不要图省事直接pip全局安装,先建一个虚拟环境,后面项目多了不打架。
python3 -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install requests lxml html2text playwright Pillow playwright install chromium这里有个细节值得说一下。playwright不像requests那样装完就能用,它还需要单独执行一条playwright install chromium来下载浏览器内核。如果不做这一步,后面调用浏览器时就会报错。很多初学者卡在这,不是代码写错,而是漏了这条命令。
2.2 目录结构与档案文件设计
整个归档目录我设计成下面这样:
museum/ ├── index.html ├── archives.json └── sites/ └── example.com/ ├── cover.jpg └── snapshots/ └── 20250114_152030/ ├── raw.html ├── article.md ├── meta.json ├── longpage.jpg └── assets/用域名做分馆目录,用时间戳做快照目录。时间戳精确到秒,这样同一个页面在不同时间抓取的版本不会覆盖彼此,天然形成了“时间线”。raw.html是原始页面文件,article.md是清理后的正文,longpage.jpg是长图,meta.json记录快照的元信息,assets存离线化的图片资源。
这种结构看着简单,但用起来非常顺手。想找历史版本,按时间戳排序就行;想批量处理,遍历sites目录下的snapshots就行。
2.3 网络请求基础:requests 的抓取细节
抓取页面的第一个函数是fetch_html。看起来只是调用requests.get,但里面有几个细节决定了成功率。
import time import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/125.0 Safari/537.36" } def fetch_html(url: str, timeout: int = 15, retries: int = 3) -> str: last_exc = None for attempt in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=timeout) resp.raise_for_status() if resp.encoding is None or resp.encoding.lower() == "iso-8859-1": resp.encoding = resp.apparent_encoding or "utf-8" return resp.text except requests.RequestException as exc: last_exc = exc time.sleep(2 ** attempt) raise RuntimeError(f"请求失败: {url} -> {last_exc}")这函数里我特别想强调的是编码问题。requests在没有charset信息时,会默认把encoding设成ISO-8859-1,中文网页用这个编码解码直接乱码。所以要在拿到响应后判断一下,如果encoding缺失或者被设置成ISO-8859-1,就用resp.apparent_encoding重新判断。apparent_encoding是根据内容字节自动检测的,大部分中文页面都能正确识别成UTF-8或GBK。
重试机制也很重要。网络请求总有偶发超时,直接失败会让整个归档任务中断。这里用2的指数退避,第一次失败等2秒,第二次等4秒,第三次等8秒,不激进也不拖沓。再加上完整UA伪装成正常浏览器,能避免相当一部分站点设置的简单拦截。
3. 正文提取与文本清洗:用 XPath 精准“策展”
3.1 为什么用 XPath 而不是正则
一提到“解析网页提取信息”,很多人第一反应是写正则表达式。正则适合精确匹配固定格式的数据,比如提取所有链接的href、提取电话号码,但对“提取一整块正文”这种场景非常不友好。因为HTML结构是嵌套的树,正文区域可能是一堆嵌套的div和p,正则很难表达“从这个div开始,到那个div结束”的边界。
XPath把HTML当成一棵树来查询,可以直接定位节点,也能按属性筛选。比如找一个article标签下的所有段落,用正则可能要写几十行,用XPath就是一句//article//p。这个项目里提到的python xpath爬虫text函数,正是正文提取的关键。文档类网站结构普遍规律,用XPath选正文容器再抽取文本,比正则稳得多,页面做小幅改版也不至于全盘崩溃。
3.2 用 lxml 提取页面标题与正文
提取标题和正文的代码,核心是“候选容器依次尝试”。不同网站的正文容器不同,有的用article标签,有的用main标签,有的用class=content,还有的用id=article。完全通用是不可能的,所以我把常见的选择器列成一个列表,逐个尝试,谁命中就用谁。
from lxml import html as lxml_html def parse_title_and_body(html_text: str): doc = lxml_html.fromstring(html_text) title_node = doc.xpath("string(//title)") title = title_node.strip() if title_node else "未命名页面" candidates = [ "//article", "//main", "//div[contains(@class,'article')]", "//div[contains(@class,'content')]", "//div[contains(@class,'post')]", "//div[contains(@id,'content')]", ] node = None for xp in candidates: found = doc.xpath(xp) if found: node = found[0] break article_text = "" if node is not None: article_text = extract_clean_text(node) return title, article_text这里有两个容易踩的坑。第一个,//title/text()返回的是一个列表,而string(//title)返回的是节点内的完整字符串。用text()提取标题时,如果title标签里还有嵌套标签,text()只会拿到直接文本节点,内容可能不完整。用string()更稳妥。第二个,候选容器找出来后,不要直接按长度判断哪个最合适,有些站点aricle标签里可能只有几行摘要。更好的做法是每个候选容器都提取一下文本,取文本最长的那个。实操中也可以简单点,先按列表顺序取第一个命中的,然后检查提取结果,少于100字就换下一个。
3.3 从 HTML 到干净文本:清洗链路的细节
提取文本时,我优先遍历p、h1到h4、li、blockquote、pre这些段落级标签。为什么不直接取整个容器里的所有文本?因为正文容器里经常混着脚本、样式、广告脚本、评论区内容,直接text_content()会把一堆噪音一起带出来。
def extract_clean_text(node) -> str: blocks = [] for tag in ("h1", "h2", "h3", "h4", "p", "li", "blockquote", "pre"): for el in node.iter(tag): text = el.text_content().strip() if text and len(text) >= 2: blocks.append(text) return "\n\n".join(blocks)lxml的iter(tag)方法会按照文档顺序遍历所有匹配的标签,所以段落顺序不会乱。这个函数没有再做复杂的去重,是因为日常归档已经够用了。如果想输出Markdown格式,可以在遍历时根据标签类型给文本加前缀标记,比如h1开头加#、h2开头加##、li开头加-,这样生成的文本直接就是一篇结构清晰的Markdown文档。
清洗后的文本写入article.md,就是给“展品”配的说明书。正文抽取这一步是整个项目里最需要手工调参的地方。我做了一个小小的改进:当提取结果不足100字时,自动把候选列表往后挪,继续尝试下一个选择器。这个阈值可以根据实际页面调整,但100字对绝大多数文档页面都适用。
4. 整页截图与长图生成:给每个页面拍一张“全景登记照”
4.1 两种长图生成方案对比
长图是整个归档器的灵魂。我在早期版本里尝试直接用playwright的full_page=True参数截整页,一行代码就能输出一张完整长图。但实际跑了几轮之后发现,页面高度超过两三万像素时,full_page截图的内存占用猛增,生成的PNG体积动辄几十MB,打开都费劲。
所以我改用“分段滚动截图 + Pillow拼接”的方案。思路很简单:把页面按固定视口高度切成若干段,浏览器每滚到一段就截一张,最后用Pillow把每张图按坐标粘到一张大画布上。对比下来各有优劣:
| 方案 | 优点 | 缺点 |
|---|---|---|
| full_page 单次截图 | 代码简单,无拼接缝隙 | 超高页面内存大,单张图片体积惊人 |
| 分段截图 + 拼接 | 内存可控,可加等待时间触发懒加载 | 步骤多,滚动和截取坐标要精确 |
我的建议是默认用分段拼接,因为它的可控性更强。懒加载图片需要时间渲染,full_page在页面尚未加载完时直接截,会发现图片区域是空白;分段滚动过程中可以插入等待,效果明显更稳。
4.2 Playwright 渲染页面并分段截图
分段截图的完整流程很长,我把其中最关键的一段拿出来说明。首先打开浏览器,设置视口宽度1280、高度900。900是经验值,太矮截图次数多,太高容易触发页面自身的懒加载策略。打开页面后,先直接滚到底部再回顶部,这一步是为了唤醒所有懒加载图片,让它们有时间请求并渲染。
import math from io import BytesIO from pathlib import Path from PIL import Image from playwright.sync_api import sync_playwright SCREENSHOT_WIDTH = 1280 SCREENSHOT_HEIGHT = 900 def capture_long_screenshot(url: str, output_path: Path): with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={ "width": SCREENSHOT_WIDTH, "height": SCREENSHOT_HEIGHT }) page.goto(url, wait_until="networkidle", timeout=30000) # 先滚到底再回顶,触发懒加载 page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight)") page.wait_for_timeout(800) page.evaluate("window.scrollTo(0, 0)") page.wait_for_timeout(500) total_height = page.evaluate("document.documentElement.scrollHeight") total_height = max(total_height, 1) segments = math.ceil(total_height / SCREENSHOT_HEIGHT) canvas = Image.new("RGB", (SCREENSHOT_WIDTH, segments * SCREENSHOT_HEIGHT), "white") for i in range(segments): y = i * SCREENSHOT_HEIGHT clip_h = min(SCREENSHOT_HEIGHT, total_height - y) page.evaluate(f"window.scrollTo(0, {y})") page.wait_for_timeout(400) shot = page.screenshot(clip={ "x": 0, "y": y, "width": SCREENSHOT_WIDTH, "height": clip_h }) im = Image.open(BytesIO(shot)).convert("RGB") canvas.paste(im, (0, y)) canvas.save(output_path, format="JPEG", quality=92, subsampling=2) browser.close()这段代码里有几个细节要特别讲。第一个是clip参数里的height。最后一段的剩余高度可能小于900,如果仍然传900,playwright会尝试截取超出页面范围的内容,结果可能报错,也可能截出透明区域。用min(SCREENSHOT_HEIGHT, total_height - y)做保护就稳了。第二个是滚动和截图之间要加400毫秒等待。滚动动画和图片加载都需要时间,如果滚完立即截图,画面会停留在半加载状态。第三个是Pillow的convert("RGB"),playwright截出来的图是RGBA格式,直接粘贴到RGB画布上会出现模式不匹配的警告,先转换再粘贴就没事了。
4.3 Pillow 拼接与超长页面内存控制
Pillow拼接的思路很简单:建立一张宽1280、高为页面总高度的画布,然后按顺序把每一段截图粘贴到对应的y坐标。由于roll坐标和clip坐标完全一致,只要没有页面自身动画干扰,拼接效果基本是无缝的。
这里有一个容易翻车的地方:有些页面设置了scroll-behavior: smooth,滚动不是瞬间到位,而是有一段动画。滚动还没结束时截图已经发生,就会出现两张图边缘重叠或错位。遇到这种情况,可以在打开页面后先注入一段JS,把平滑滚动强制覆盖成自动滚动:
page.add_script_tag(content="document.documentElement.style.scrollBehavior='auto';")另外,超长页面保存成JPEG而不是PNG,可以明显减少体积。PNG是无损压缩,一张5万像素高的长图可能到100MB,JPEG的quality=92肉眼几乎看不出差别,体积却能压到几MB以内。如果是本地归档,我通常还会生成一张封面缩略图,只截长图顶部300像素,用于展厅卡片展示,避免页面加载整张长图卡顿。
5. 离线化与展厅页面:把抓到的内容变成可以长期浏览的归档
5.1 离线化:下载页面图片到本地
用requests抓回来的raw.html,直接保存是能看的,但有一个问题:HTML里引用的图片、CSS、JS仍然是外链地址,一旦断网或者目标站点关闭,这些资源照样加载不出来。为了让快照真正“离线可用”,我在保存之前会把页面里的img标签的src批量下载到本地assets目录,并把HTML里的引用改成相对路径。
import hashlib from urllib.parse import urljoin def download_assets(html_text: str, base_dir: Path, site_url: str) -> str: doc = lxml_html.fromstring(html_text) assets_dir = base_dir / "assets" assets_dir.mkdir(exist_ok=True) for img in doc.xpath("//img[@src]"): src = img.get("src") if not src: continue full_url = urljoin(site_url, src) try: resp = requests.get(full_url, headers=HEADERS, timeout=10) if resp.status_code != 200: continue ext = Path(src).suffix or ".img" name = hashlib.md5(full_url.encode()).hexdigest() + ext local_path = assets_dir / name local_path.write_bytes(resp.content) img.set("src", f"assets/{name}") except Exception: continue return lxml_html.tostring(doc, encoding="unicode", pretty_print=True)这段代码有三个设计考量。第一,文件名用URL的MD5值而不是原始文件名,避免两个页面存在同名图片互相覆盖。第二,每个图片下载都包在try里,单个资源失败不阻塞整站归档。第三,urljoin用来处理相对路径,比如src="/images/a.png"时,可以拼出完整的绝对URL。对很多不懂HTML的读者来说,这一步做完,离线快照才算真的完整。
5.2 生成 meta.json 与 archives.json 索引
每生成一个快照,我都会写两个JSON文件。第一个是快照目录内的meta.json,记录单个页面的元信息;第二个是项目根目录的archives.json,聚合所有快照,方便程序整体扫描。
{ "title": "示例技术文档:如何使用XPath抽取正文", "url": "https://example.com/docs/xpath-guide", "domain": "example.com", "timestamp": "20250114_152030", "html_file": "raw.html", "text_file": "article.md", "image_file": "longpage.jpg", "html_size": 58234, "text_size": 8322 }archives.json则是这个对象的列表,每次新增快照就追加一条。有了这个全局索引,后续想扩展全文搜索、按时间线浏览、做页面变更对比,都不用再去遍历文件夹,直接读JSON就行。它为项目保留了扩展空间。
5.3 一键生成 index.html 展厅页面
直接让用户去翻sites目录下的文件不现实,所以我用一个很轻量的方式生成展厅页面:根目录的index.html会把所有快照渲染成卡片,每张卡片显示封面缩略图、标题、抓取时间、原文链接,点击后可以打开长图或正文。
def render_index_html(records: list[dict]) -> str: cards = [] for rec in records: cover = f"cover_{rec['domain'].replace('.', '_')}.jpg" cards.append(f""" <div class="card"> <img src="{cover}" loading="lazy" alt="cover"> <h3>{rec['title']}</h3> <p>{rec['timestamp']}</p> <a href="sites/{rec['domain']}/snapshots/{rec['timestamp']}/longpage.jpg">查看长图</a> <a href="sites/{rec['domain']}/snapshots/{rec['timestamp']}/article.md">阅读正文</a> </div> """) return f"""<!doctype html> <html lang="zh-CN"> <head><meta charset="utf-8"><title>我的数字博物馆</title> <style> body {{ font-family: sans-serif; max-width: 1200px; margin: 0 auto; padding: 24px; }} .grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }} .card {{ border: 1px solid #ddd; border-radius: 8px; padding: 12px; }} .card img {{ width: 100%; max-height: 220px; object-fit: cover; object-position: top; border-radius: 4px; }} .card a {{ display: block; margin-top: 6px; color: #0366d6; text-decoration: none; }} </style> </head> <body> <h1>我的数字博物馆</h1> <div class="grid">{"".join(cards)}</div> </body> </html>"""封面缩略图的生成很简单,在保存长图之后,用Pillow把长图顶部300像素crop出来,作为cover文件保存。这样展厅页面的卡片加载速度快,也不会因为长图尺寸过大导致浏览器卡顿。这套页面不需要任何服务端逻辑,双击index.html直接在本地就能看,非常符合“归档”的定位。
6. 常见问题与排查技巧实录
6.1 提取内容为空:先判断是XPath问题还是渲染问题
我在调试时遇到最多的情况是,parse_title_and_body返回的正文是空的。刚开始会以为是XPath表达式写错了,反复改选择器都没有效果。后来才发现,问题根本不在解析层,而是目标页面是纯JavaScript渲染的SPA,requests拿到的HTML里只有空壳div,根本没有正文内容。
排查顺序应该是:先用requests抓到的HTML里搜一下关键字,如果正文的关键词根本没出现,说明页面需要渲染;如果关键词出现了,才轮到XPath的问题。需要渲染的页面,可以在请求成功后额外调用一个渲染函数,用playwright打开页面,等networkidle之后取page.content()。这个渲染后的HTML再交给XPath解析,通常就能正常提取了。
6.2 长图拼接出现裂缝或重复
分段截图理论上不会出现裂缝,但如果页面存在固定定位的头部导航栏,滚动到每一段时导航栏都固定在顶部,最后拼出来的长图每隔900像素就会重复出现一次导航栏。这个不需要逐段去P图,可以在截图前用print样式模式弱化页面装饰:
page.emulate_media(media="print")很多站点为打印场景优化过样式,会隐藏固定导航、广告区域,正文区域反而更干净。如果站点不响应打印样式,也可以在截图前执行JS把fixed元素隐藏掉,但不建议对所有页面都这样处理,容易误伤正常布局。
6.3 超长页面截图内存爆炸
高度超过3万像素、又有很多高清图片的页面,即使分段截图,Pillow拼接时也会占用大量内存。除了把最终保存格式改成JPEG,我还会设置一个“最大高度”保护:当页面高度超过5万像素时,只截前3万像素,并且在meta.json里标记“已截断”。
这个妥协是不得已的。绝大多数文档站不会长到这个程度,但确实遇到过产品文档页面一次性加载了几百个模块,页面高度突破天际。归档的关键是保住主要内容,头部信息通常就是核心,截断尾部影响不大。等以后需要截全时再针对性调参数就行。
6.4 中文乱码的处理
乱码问题在上一部分已经提过,核心就是requests的encoding属性。resp.encoding为空时必须重置,否则中文内容会变成一堆问号。更稳的做法是直接用resp.content配合chardet检测再解码,但apparent_encoding已经能满足绝大多数场景。如果遇到个别页面声明charset错误,可以在fetch_html之后手动检查一个关键词,比如“正文”这两个字是否出现,没出现就尝试用其他编码重新解码。
6.5 定时归档:用快照做页面变更对比
数字博物馆不应该只拍一次照就完事,好东西会更新,页面会改版,定期补拍才能形成时间线。我后面给项目加了一个定时扫描功能,用系统定时任务每天跑一次归档脚本,每次抓完都用hash对比当前HTML和上次快照的差异,发现变化就生成一个diff文件。这样不仅能做备份,还能追踪页面内容的每次变更。这在跟踪线上文档更新时特别实用。
归档器的合规边界同样要讲清楚。这个工具适合归档自己有权访问的公开页面,比如技术文档、个人博客、允许爬取的开放内容。使用前最好看一下目标站点的robots.txt,尊重站点声明。遇到需要登录、有明确访问限制或设有反爬机制的页面,就应该停下来,不要尝试对抗或破解。我们的目标是把值得保存的公开内容保护下来,而不是和站点本身的访问控制较劲。
写在最后
用了半年多,这个“开源数字博物馆”已经攒下上百个快照,里面有不少是现在互联网上已经搜不到的旧内容。个人体会最深的一点是,归档的意义不在于收藏本身,而在于给内容留下了“第二次生命”。每次翻那些早期截图,我都庆幸当时多花了30秒启动脚本。
最后分享一个小技巧:归档时一定要在meta.json里把页面标题写清楚。我早期贪图省事,文件名只用了时间戳,三个月后根本记不住某个快照是什么内容。后来给每个快照自动生成“站点名+页面标题+时间”的可读目录名,翻起来舒服太多了。这个项目后续还有很多可以扩展的地方,比如全文检索、按关键词聚类、压缩旧快照,但核心思路始终不变——先把页面当下这一刻,完整地保存下来。