ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

pywebcopy网页镜像:单页与整站抓取、配置及完整性验收

pywebcopy网页镜像:单页与整站抓取、配置及完整性验收

简介:PyWebCopy 是一款用 Python 编写的网页与整站镜像归档库,面向需要保存网页资产、开展爬虫归档或构建离线阅读材料的开发者,也适合作为学习网页解析与资源重映射的实践项目。压缩包共32个文件,以19个py源码和8个md文档为主,辅以yml配置、txt说明及许可文件等,整体约65KB,源码与文档结构较完整,便于阅读模块实现和接口用法。已有572人学习下载,可见其在网页归档场景中较受关注。读者能了解一次调用保存包含样式表、脚本和图片的网页、重新映射资产链接、克隆完整站点,以及遵守robots.txt等设计思路;示例、测试与说明文档可辅助理解基础调用、进阶定制与配置,对抓取、解析、链接重写与异常处理也有参考价值,适合改造成下载归档脚本或教学案例。

1. 网页另存为只留下一堆裂图:pywebcopy 到底补的是哪一环

把一份网页镜像下来这件事,很多人第一次做都栽在同一个地方:浏览器里右键另存为,HTML 文件是拿到了,拷到离线机器一打开,版式全散、图片全是破图图标、点任何链接都跳回原站。原因不难理解,一个现代页面早就不止一个 HTML 文件,样式表、脚本、字体、背景图分散在相对路径和绝对地址里,另存为只搬走了壳子,引用关系一个都没改。

pywebcopy 这个 Python 库补的正是这一环:给它一个 URL,它用 HTTP 把页面和依赖资源抓回本地,做一遍 HTML 解析,把src、href、CSS 里的url()改写成指向本地文件的相对路径,最后按项目名落进一个目录。单页走save_webpage,按链接递归走save_website。它适合做内部资料归档、离线演示包、给测试环境造静态样本的人,不适合指望它跑 JavaScript 抓单页应用的人,这条边界后面会反复提到。

2. 单页镜像的最小闭环:save_webpage 的四个必填语义与产物自检

2.1 环境隔离与依赖确认

这类抓取库对 requests、解析器、编码库的版本比较敏感,装进全局环境很容易跟别的项目打架。我的习惯是一律先起虚拟环境,装完立刻打印一次模块路径,确认装到的到底是哪个解释器下面——这一步能省掉后面一半“明明装了却 import 不到”的玄学问题。

python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install --upgrade pip pip install pywebcopy # 确认装到了当前环境,而不是别的解释器 python -c "import sys, pywebcopy; print(sys.executable); print(pywebcopy.__file__)"

逻辑上很简单:sys.executable告诉你当前跑的是哪个 Python,pywebcopy.__file__告诉你模块从哪个site-packages里加载。两个路径不在同一个环境树下,说明你的 shell 还没激活虚拟环境,或者 IDE 里配的解释器跟终端不是同一个。参数上没什么可调的,真正要注意的是不要在系统 Python 里直接pip install,尤其是 macOS 和部分 Linux 发行版,系统自带的 Python 受包管理保护,装出来的东西位置很飘。

2.2 三行调用与四个必须想清楚的参数

最省事的用法就三行,但参数一个都不能瞎填。下面这份是我平时用的模板,全部走关键字传参:

from pywebcopy import save_webpage save_webpage( url="https://example.com/docs/index.html", # 入口页,带上具体路径,别只给域名 project_folder="/data/mirror", # 镜像根目录,必须已存在且可写 project_name="example_docs", # 子目录名,产物落在 project_folder/project_name overwrite=True, # 重跑时是否覆盖已有文件 delay=0.5, # 每个请求之间的间隔秒数 timeout=30, # 单请求超时秒数 )

四个语义要拆开看。url是入口,最好给到具体页面而不是站点根,因为入口页的目录层级决定了后续相对路径的解析基准。project_folder是父目录,库不会帮你创建它,目录不存在时大概率直接在写文件阶段报错,而不是在参数校验阶段报错,所以错误信息看着跟目录无关,很容易误判。project_name是真正的产物目录名,重复跑同一个项目名就是往同一个目录里叠加。overwrite决定重跑行为:True会重新下载并覆盖,适合页面更新了要刷新;False会跳过已经存在的文件,适合抓一半断了之后续跑,省流量也省时间。

提示:save_webpage的位置参数顺序在不同版本之间调整过,混用位置参数和关键字参数很容易出现“url 传给了目录”这种错位。全部用关键字传参,是成本最低的兼容手段。

delay和timeout看着像可选项,实际是两个安全阀。delay=0在对端看来就是一串没有间隔的连续请求,小型站点很容易被打到限流甚至直接拒绝;timeout不设或者设得太小,遇到响应慢的静态资源就会抛异常中断整次保存,而中断的位置往往在页面主体抓完之后,产物看起来“有东西”,实际缺资源。

2.3 产物目录怎么验收

跑完之后别急着打开 HTML,先看目录结构和体积。三个命令足够判断这次抓得干不干净:

# 1. 看落盘结构,确认资源目录是否生成 find /data/mirror/example_docs -maxdepth 2 -type d # 2. 看总体积,几百 KB 通常意味着只有 HTML 没有资源 du -sh /data/mirror/example_docs # 3. 看 HTML 里还剩多少指向原站的绝对地址 grep -o 'https://example.com[^"]*' /data/mirror/example_docs/index.html | wc -l

第一条看目录层级,正常情况下除了 HTML 还会有存放图片、样式、脚本的子目录或平铺文件,具体形态取决于库的落盘策略。第二条看体积,一个带图的页面抓下来只有几十 KB,基本可以判定资源没下来,这时候去翻日志比翻 HTML 快。第三条是关键:改写成功的引用应该是相对路径,如果 HTML 里还残留大量指向原站的绝对地址,说明这些资源要么被过滤规则拦了,要么改写阶段没覆盖到这类标签,离线打开必然裂图。

2.4 包一层可复用的函数

直接把save_webpage写在脚本顶层,第一次能用,第二次就想改。我一般包一层,把日志和异常收进去:

import logging from pathlib import Path from pywebcopy import save_webpage logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") def mirror_page(url: str, root: str, name: str, **overrides) -> Path: root_path = Path(root) root_path.mkdir(parents=True, exist_ok=True) # 父目录自己建,别指望库帮你 target = root_path / name params = dict( url=url, project_folder=str(root_path), project_name=name, overwrite=False, delay=0.5, timeout=30, user_agent="Mozilla/5.0 (compatible; ArchiveBot/1.0)", ) params.update(overrides) # 允许调用方覆盖任意配置 logging.info("start mirror %s -> %s", url, target) save_webpage(**params) files = list(target.rglob("*")) logging.info("done, %d entries, %.1f KB", len(files), sum(f.stat().st_size for f in files if f.is_file()) / 1024) return target if __name__ == "__main__": mirror_page("https://example.com/docs/index.html", "/data/mirror", "example_docs")

几处设计意图说明一下。mkdir(parents=True, exist_ok=True)把父目录的创建责任拿回自己手里,避免因为目录缺失报出一个跟目录无关的错。params.update(overrides)让调用方可以临时改overwrite或delay而不必复制整个函数。user_agent显式指定,是因为默认 UA 在一些站点的反爬策略里会被直接拒,改成一个能自报家门的标识,既是礼貌也减少 403。结尾的统计是为了留下可追溯的日志:下次发现产物不对,可以先翻日志看体积,再决定要不要重抓,而不是打开目录一个个数文件。

3. 整站递归的边界:save_website 的域名白名单、扩展名过滤与重跑策略

3.1 先确认函数签名,再动手

整站抓取和单页抓取不是同一个量级的事情,单页出错顶多重跑一次,整站出错可能几个小时白烧,还可能把对端拖垮。所以在写第一行调用之前,我建议先做一件很土但很有效的事:把函数签名打出来。

import inspect from pywebcopy import save_website, save_webpage print(inspect.signature(save_website)) print(inspect.signature(save_webpage))

理由很实在:这类库的递归参数在不同版本之间增删过,网上抄来的示例未必跟你装到的版本对得上,位置参数一错位,depth可能被当成目录名传进去,报错信息还指向别处。inspect.signature打出来的是当前环境里真实接受的参数,比任何文档都快。看到签名之后再决定哪些走位置、哪些走关键字,我一般还是全关键字。

3.2 域名白名单:递归到底跟到哪儿

整站镜像最容易失控的地方是链接边界。一个页面上有导航、有外链、有评论区的头像、有统计脚本的回调地址,如果全跟,抓取范围会从“一个文档站”膨胀成“半个互联网”。控制边界的核心配置是ALLOWED_DOMAINS,它限定哪些域名下的链接会被继续跟进。

from pywebcopy import save_website save_website( url="https://example.com/docs/", project_folder="/data/mirror", project_name="example_site", overwrite=False, delay=1.0, # 整站抓取,间隔必须比单页更保守 timeout=30, allowed_domains=["example.com", "static.example.com"], allowed_file_ext=["html", "css", "js", "png", "jpg", "svg", "woff2"], )

allowed_domains里要写全主站和静态资源域名。很多站点的图片和 CDN 样式挂在独立的子域上,只写主域会导致 HTML 抓全了、资源全丢,产物打开一片空白,而日志里又没有任何报错,这是最典型的一种“看起来成功了”的翻车。反过来,白名单给得太宽,比如把某个公共图片托管域名也放进去,递归就会顺着图片链接往外爬,抓到一堆跟目标站无关的东西。

allowed_file_ext是扩展名白名单,作用是过滤掉压缩包、视频、安装包这类大文件。默认行为通常已经排除了大部分二进制资产,但站点如果有?download=1这种带查询串的下载链接,扩展名判断可能失效。要不要放开,取决于你镜像的目的:做文档归档,html/css/js/图片/字体这五类足够;做完整站点备份,就得把压缩包也纳进来,同时准备好磁盘空间。

3.3 递归规模与断点重跑

整站抓取的时间成本跟页数近似线性,抓几千页的文档站在delay=1.0下就是小时级。这个过程中网络抖一下、目标站重启一下、本机休眠一下,中断是常态而不是意外。所以overwrite=False在整站场景里几乎是必选项:它让重跑时跳过已经落盘的文件,从断点接着往下走,而不是从第一页重新来过。

# 第一次跑,中断了没关系 python mirror_site.py # 改了 allowed_domains 之后重跑,已下载的不会重复拉 python mirror_site.py 2>&1 | tee mirror_run2.log # 判断这次补了多少:对比两次的目录体积 du -sh /data/mirror/example_site

tee是为了留下完整日志,整站抓取出问题时不可能靠翻终端回滚,必须有文件。体积对比是判断“补抓有没有效果”的最快指标:如果第二次跑完体积几乎没变,说明断点要么在被跳过、要么上次其实已经抓完了,这时候再去看日志里有没有大量 404,比继续重跑有意义。

一个必须提前想清楚的问题是磁盘。整站镜像的体积很容易超出预期,图片和字体是大头,一些站点的字体文件单个就几百 KB,几十个字体文件就是几十 MB。我一般会在跑之前先手动抓一次首页,估一下单页体积,乘以预估页数,留三倍余量再决定要不要开抓。

3.4 递归深度这件事,别用猜的

有些版本提供深度控制参数,有些版本靠链接层级隐式控制,具体名字和默认值我建议用签名确认,而不是凭记忆写。如果当前版本确实没有深度参数,退一步的做法是:把入口 URL 定在你要的那一层,靠allowed_domains和扩展名白名单收口,不要给站点根。

这么做的代价是范围控制不够精细,好处是不会因为一个不存在的参数导致整次调用失败。真要精细控制,就绕开整站接口,自己用requests拿列表页、解析出目标链接、再逐条调save_webpage。多写二十行代码,换来的是完全可控的边界和可恢复的断点,抓超过几百页的时候这笔账是划算的。

4. 配置层压测:setup_config 的全局开关与 WebPage 类的手动挡

4.1 全局配置和单次传参的关系

pywebcopy 有两层配置:一层是模块级的全局配置,通过setup_config写入,影响之后所有调用;另一层是每次调用时传的关键字参数,优先级更高,只对这一次生效。搞清楚这个层级,能避免一类很典型的问题:在脚本里setup_config设了overwrite=False,结果单次调用里又传了overwrite=True,行为跟你以为的完全相反。

from pywebcopy.config import setup_config, get_config # 写入全局默认值 setup_config( project_folder="/data/mirror", overwrite=False, delay=1.0, timeout=30, user_agent="Mozilla/5.0 (compatible; ArchiveBot/1.0)", zip_project=False, # 不要每次抓完都打包,大站点打包很慢 ) # 打印当前生效的配置,确认写进去了 for k, v in sorted(get_config().items()): print(f"{k:20s} = {v}")

我习惯在脚本入口处先setup_config打印一遍,把它当成启动自检。原因是全局配置项很多,其中一部分有隐式默认值,写错了不会报错,只会在产物上体现出来。打印一次的成本是几秒,能省掉后面半小时的“为什么跟上次结果不一样”。

4.2 关键配置项与调参方向

下表是我实际会动的几项,其余保持默认:

配置项作用调大的后果建议值
overwrite重跑是否覆盖已有文件每次都重新下载,流量翻倍整站False,单页按需
delay请求间隔秒数总耗时线性增加单页 0.3~0.5,整站 1.0 起
timeout单请求超时卡在慢资源上更久20~30,弱网站点给到 60
allowed_domains递归域名白名单抓取范围外溢主域 + 静态资源域,别多写
allowed_file_ext资源扩展名白名单大文件混入,磁盘吃紧按归档目的裁剪
user_agent请求标识部分站点会直接拒绝默认 UA带联系方式的合规标识
zip_project抓完是否打包大站点打包耗时且占双份空间默认关,交付前再手动压

表格里最容易被忽略的是delay和zip_project。前者关系到对端站点的承受能力,一个几百页的站用 0.1 秒间隔去抓,对端日志里就是一串密集请求,轻则限流重则封 IP,抓一半失败还查不出原因。后者关系到磁盘,抓完自动打包意味着同一份数据在磁盘上存了两份,站点大的时候直接把盘塞满。

4.3 用 WebPage 类做手动挡

save_webpage和save_website是自动挡,WebPage类更像手动挡:先构造一个页面对象,再分步调用保存方法,中间可以插手做检查。

from pywebcopy import WebPage wp = WebPage( url="https://example.com/docs/index.html", project_folder="/data/mirror", project_name="example_manual", delay=0.5, timeout=30, ) wp.save_html() # 只落 HTML,先看看解析和改写的结果对不对 wp.save_assets() # 再补资源,资源失败不影响 HTML 已经落地 wp.save_complete() # 最后做一次完整保存,把缺的补齐

分步调用的价值在于排错。save_complete一把梭的时候,资源下载失败和 HTML 改写失败混在一起,报错信息指向不明。拆成三步之后,你可以先只跑save_html,打开产物看引用被改成了什么样,确认解析逻辑没问题,再去跑资源。资源阶段挂了,至少 HTML 结构是在的,不用从头再来。

4.4 延时与超时的下限

delay不是越大越好。整站抓取时把delay设成 3 秒,一千页就是五十分钟纯等待,中途一断还得重来。我的经验值是:对自有站点或内网站点,0.2~0.3 秒足够;对公网中小站点,1 秒是安全线;不确定的时候,先用 20 个页面跑一遍小样本,看目标站有没有返回 429 或 503,再决定正式跑的间隔。

timeout的下限比上限重要。设成 5 秒,遇到稍慢的图片就抛异常,异常一旦冒到顶层,整次保存就中断了,而中断点通常在主体内容之后,产物是“半成品”但看着像完整的。设成 30 秒,代价是遇到真挂掉的资源要等满 30 秒,但换来的是不会因为一张图丢掉整个页面。两害相权,我选后者。

5. 五类翻车现场:从 CSS url() 丢图到 robots 拦截的排查表

5.1 现象:HTML 完整,图片全裂

现象:产物 HTML 能打开,文字和结构都在,所有图片显示为破图,<img>标签的src仍然指向原站的绝对地址。

原因:链接改写主要覆盖 HTML 标签属性,写在 CSS 里的背景图(background-image: url(...))不在这个范围里;另外<img>用的是>import re from pathlib import Path from charset_normalizer import from_bytes META = re.compile(rb'<meta[^>]+charset=["\']?([\w-]+)', re.I) def fix_encoding(path: Path) -> None: raw = path.read_bytes() m = META.search(raw) enc = m.group(1).decode("ascii", "ignore") if m else None if not enc: best = from_bytes(raw).best() enc = best.encoding if best else "utf-8" text = raw.decode(enc, errors="replace") text = re.sub(r'charset=["\']?[\w-]+', 'charset="utf-8"', text, flags=re.I) path.write_text(text, encoding="utf-8")

这段逻辑是“先信声明、声明不可信再猜、最后统一转 UTF-8 并改写 meta”。errors="replace"是为了不让个别坏字节中断整批处理,坏字节会变成替换符,但至少能看出位置。跑之前先备份,或者先在一份拷贝上验证,别直接对原始产物动手。

5.4 现象:单页应用抓下来是空壳

现象:HTML 只有几十行,<div id="app"></div>里面空空如也,内容是 JS 渲染出来的。

原因:pywebcopy 走的是 HTTP 请求加 HTML 解析,不执行 JavaScript。所有依赖前端渲染的页面,抓到的都是首屏之前的模板。

解决:这类页面不要硬抓,两条路。一是找接口,用浏览器开发者工具看 XHR 请求,直接拿 JSON 数据,比抓 HTML 干净得多。二是先用无头浏览器把渲染后的 DOM 导出为 HTML 文件,再对本地的 HTML 做资源补全——这时候用的是解析和改写能力,不是抓取能力。判断页面是不是 SPA 很快:curl一下看返回体里有没有实际内容,没有就属于这一类。

5.5 现象:请求被拒,返回 403 或空内容

现象:单页抓取直接失败,日志里是 403;或者整站跑到一半,后面全是被拒。

原因:默认 UA 被识别为爬虫,或者请求频率触发了对端限流。

解决:先改user_agent,写一个能表明用途和联系方式的标识,不要伪装成普通浏览器,伪装只会让对端更严。再调delay,从 1 秒起试。至于BYPASS_ROBOTS这个开关,我的态度很明确:只对自己有权限的站点用,对第三方站点默认尊重robots.txt是底线,绕过它带来的问题不是技术问题。遇到明确的拒绝,先确认自己有没有抓取权限,没有就换数据源,别在技术层面硬绕。

6. 镜像产物验收:写一个引用完整性扫描脚本

抓取跑完不等于镜像可用。最可靠的验收方式不是肉眼点开几个页面看,而是把产物里所有引用扫一遍,找出指向本地但本地不存在的那些,形成一份缺失清单。这份清单直接告诉你补抓哪些文件、改写哪里出了偏差。

import csv, re from pathlib import Path from urllib.parse import unquote, urlparse # HTML 属性引用 + CSS url() 两类,覆盖绝大多数资源引用 ATTR = re.compile(r"""(?:src|href|data-src)\s*=\s*["']([^"']+)["']""", re.I) CSSURL = re.compile(r"""url\(\s*["']?([^"')]+)["']?\s*\)""", re.I) SKIP = ("data:", "javascript:", "mailto:", "tel:", "#") def scan(root: str, report: str = "missing.csv") -> int: base = Path(root) rows = [] for f in list(base.rglob("*.html")) + list(base.rglob("*.css")): text = f.read_text(encoding="utf-8", errors="ignore") pattern = ATTR if f.suffix.lower() == ".html" else CSSURL for m in pattern.finditer(text): ref = unquote(m.group(1)).strip() if not ref or ref.startswith(SKIP): continue if urlparse(ref).scheme in ("http", "https"): rows.append((str(f.relative_to(base)), ref, "remote")) # 未改写的外链 continue target = (f.parent / ref.split("?")[0].split("#")[0]).resolve() if not target.exists(): rows.append((str(f.relative_to(base)), ref, "missing")) # 本地缺失 with open(base / report, "w", newline="", encoding="utf-8") as fh: w = csv.writer(fh) w.writerow(["source_file", "reference", "type"]) w.writerows(rows) return len(rows) if __name__ == "__main__": n = scan("/data/mirror/example_site") print(f"发现 {n} 条待处理引用,详情见 missing.csv")

脚本干的事分两类判定。type=remote表示这条引用还是绝对地址,说明改写阶段没覆盖到,离线打开会指向原站;type=missing表示引用已经被改成本地相对路径,但目标文件不存在,通常是下载失败或过滤规则拦掉了。两类问题的处理方式完全不同:前者要补改写规则,后者要补下载,混在一起看会误判。

几个实现细节值得说。ref.split("?")[0].split("#")[0]是必须的,带查询串或锚点的引用直接拼路径永远找不到文件,这是最常见的假阳性来源。unquote是为了处理 URL 编码的中文文件名,不解码的话本地路径对不上。errors="ignore"让脚本能扛住个别编码坏掉的页面,不至于整批中断。扫描范围覆盖html和css两类,因为样式表里的背景图和字体是最容易漏的。

跑完拿到 CSV,按type分列排序,missing行数除以总引用数就是这次镜像的完整度。我的经验线是:低于 2% 可以接受,逐个手补;超过 10% 就别补了,回头查allowed_domains和allowed_file_ext是不是配窄了,改完重跑一遍比手工补快得多。

吃过一次亏之后,我现在每次镜像都会强制走一遍这个扫描,不管产物看起来多正常。那次是一个文档站的字体全丢,肉眼完全看不出来——浏览器用了后备字体,排版只是“稍微有点不一样”,直到交付给使用方,人家在离线环境里打开才发现整站字重全错。从此扫描脚本就成了固定动作,写完先跑扫描,再看页面。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进