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告诉你当前跑的是哪个 Pythonpywebcopy.__file__告诉你模块从哪个site-packages里加载。两个路径不在同一个环境树下说明你的 shell 还没激活虚拟环境或者 IDE 里配的解释器跟终端不是同一个。参数上没什么可调的真正要注意的是不要在系统 Python 里直接pip install尤其是 macOS 和部分 Linux 发行版系统自带的 Python 受包管理保护装出来的东西位置很飘。2.2 三行调用与四个必须想清楚的参数最省事的用法就三行但参数一个都不能瞎填。下面这份是我平时用的模板全部走关键字传参from pywebcopy import save_webpage save_webpage( urlhttps://example.com/docs/index.html, # 入口页带上具体路径别只给域名 project_folder/data/mirror, # 镜像根目录必须已存在且可写 project_nameexample_docs, # 子目录名产物落在 project_folder/project_name overwriteTrue, # 重跑时是否覆盖已有文件 delay0.5, # 每个请求之间的间隔秒数 timeout30, # 单请求超时秒数 )四个语义要拆开看。url是入口最好给到具体页面而不是站点根因为入口页的目录层级决定了后续相对路径的解析基准。project_folder是父目录库不会帮你创建它目录不存在时大概率直接在写文件阶段报错而不是在参数校验阶段报错所以错误信息看着跟目录无关很容易误判。project_name是真正的产物目录名重复跑同一个项目名就是往同一个目录里叠加。overwrite决定重跑行为True会重新下载并覆盖适合页面更新了要刷新False会跳过已经存在的文件适合抓一半断了之后续跑省流量也省时间。提示save_webpage的位置参数顺序在不同版本之间调整过混用位置参数和关键字参数很容易出现“url 传给了目录”这种错位。全部用关键字传参是成本最低的兼容手段。delay和timeout看着像可选项实际是两个安全阀。delay0在对端看来就是一串没有间隔的连续请求小型站点很容易被打到限流甚至直接拒绝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(levellogging.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(parentsTrue, exist_okTrue) # 父目录自己建别指望库帮你 target root_path / name params dict( urlurl, project_folderstr(root_path), project_namename, overwriteFalse, delay0.5, timeout30, user_agentMozilla/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(parentsTrue, exist_okTrue)把父目录的创建责任拿回自己手里避免因为目录缺失报出一个跟目录无关的错。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( urlhttps://example.com/docs/, project_folder/data/mirror, project_nameexample_site, overwriteFalse, delay1.0, # 整站抓取间隔必须比单页更保守 timeout30, allowed_domains[example.com, static.example.com], allowed_file_ext[html, css, js, png, jpg, svg, woff2], )allowed_domains里要写全主站和静态资源域名。很多站点的图片和 CDN 样式挂在独立的子域上只写主域会导致 HTML 抓全了、资源全丢产物打开一片空白而日志里又没有任何报错这是最典型的一种“看起来成功了”的翻车。反过来白名单给得太宽比如把某个公共图片托管域名也放进去递归就会顺着图片链接往外爬抓到一堆跟目标站无关的东西。allowed_file_ext是扩展名白名单作用是过滤掉压缩包、视频、安装包这类大文件。默认行为通常已经排除了大部分二进制资产但站点如果有?download1这种带查询串的下载链接扩展名判断可能失效。要不要放开取决于你镜像的目的做文档归档html/css/js/图片/字体这五类足够做完整站点备份就得把压缩包也纳进来同时准备好磁盘空间。3.3 递归规模与断点重跑整站抓取的时间成本跟页数近似线性抓几千页的文档站在delay1.0下就是小时级。这个过程中网络抖一下、目标站重启一下、本机休眠一下中断是常态而不是意外。所以overwriteFalse在整站场景里几乎是必选项它让重跑时跳过已经落盘的文件从断点接着往下走而不是从第一页重新来过。# 第一次跑中断了没关系 python mirror_site.py # 改了 allowed_domains 之后重跑已下载的不会重复拉 python mirror_site.py 21 | tee mirror_run2.log # 判断这次补了多少对比两次的目录体积 du -sh /data/mirror/example_sitetee是为了留下完整日志整站抓取出问题时不可能靠翻终端回滚必须有文件。体积对比是判断“补抓有没有效果”的最快指标如果第二次跑完体积几乎没变说明断点要么在被跳过、要么上次其实已经抓完了这时候再去看日志里有没有大量 404比继续重跑有意义。一个必须提前想清楚的问题是磁盘。整站镜像的体积很容易超出预期图片和字体是大头一些站点的字体文件单个就几百 KB几十个字体文件就是几十 MB。我一般会在跑之前先手动抓一次首页估一下单页体积乘以预估页数留三倍余量再决定要不要开抓。3.4 递归深度这件事别用猜的有些版本提供深度控制参数有些版本靠链接层级隐式控制具体名字和默认值我建议用签名确认而不是凭记忆写。如果当前版本确实没有深度参数退一步的做法是把入口 URL 定在你要的那一层靠allowed_domains和扩展名白名单收口不要给站点根。这么做的代价是范围控制不够精细好处是不会因为一个不存在的参数导致整次调用失败。真要精细控制就绕开整站接口自己用requests拿列表页、解析出目标链接、再逐条调save_webpage。多写二十行代码换来的是完全可控的边界和可恢复的断点抓超过几百页的时候这笔账是划算的。4. 配置层压测setup_config 的全局开关与 WebPage 类的手动挡4.1 全局配置和单次传参的关系pywebcopy 有两层配置一层是模块级的全局配置通过setup_config写入影响之后所有调用另一层是每次调用时传的关键字参数优先级更高只对这一次生效。搞清楚这个层级能避免一类很典型的问题在脚本里setup_config设了overwriteFalse结果单次调用里又传了overwriteTrue行为跟你以为的完全相反。from pywebcopy.config import setup_config, get_config # 写入全局默认值 setup_config( project_folder/data/mirror, overwriteFalse, delay1.0, timeout30, user_agentMozilla/5.0 (compatible; ArchiveBot/1.0), zip_projectFalse, # 不要每次抓完都打包大站点打包很慢 ) # 打印当前生效的配置确认写进去了 for k, v in sorted(get_config().items()): print(f{k:20s} {v})我习惯在脚本入口处先setup_config打印一遍把它当成启动自检。原因是全局配置项很多其中一部分有隐式默认值写错了不会报错只会在产物上体现出来。打印一次的成本是几秒能省掉后面半小时的“为什么跟上次结果不一样”。4.2 关键配置项与调参方向下表是我实际会动的几项其余保持默认配置项作用调大的后果建议值overwrite重跑是否覆盖已有文件每次都重新下载流量翻倍整站False单页按需delay请求间隔秒数总耗时线性增加单页 0.30.5整站 1.0 起timeout单请求超时卡在慢资源上更久2030弱网站点给到 60allowed_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( urlhttps://example.com/docs/index.html, project_folder/data/mirror, project_nameexample_manual, delay0.5, timeout30, ) wp.save_html() # 只落 HTML先看看解析和改写的结果对不对 wp.save_assets() # 再补资源资源失败不影响 HTML 已经落地 wp.save_complete() # 最后做一次完整保存把缺的补齐分步调用的价值在于排错。save_complete一把梭的时候资源下载失败和 HTML 改写失败混在一起报错信息指向不明。拆成三步之后你可以先只跑save_html打开产物看引用被改成了什么样确认解析逻辑没问题再去跑资源。资源阶段挂了至少 HTML 结构是在的不用从头再来。4.4 延时与超时的下限delay不是越大越好。整站抓取时把delay设成 3 秒一千页就是五十分钟纯等待中途一断还得重来。我的经验值是对自有站点或内网站点0.20.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(rbmeta[^]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, errorsreplace) text re.sub(rcharset[\]?[\w-], charsetutf-8, text, flagsre.I) path.write_text(text, encodingutf-8)这段逻辑是“先信声明、声明不可信再猜、最后统一转 UTF-8 并改写 meta”。errorsreplace是为了不让个别坏字节中断整批处理坏字节会变成替换符但至少能看出位置。跑之前先备份或者先在一份拷贝上验证别直接对原始产物动手。5.4 现象单页应用抓下来是空壳现象HTML 只有几十行div idapp/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(rurl\(\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(encodingutf-8, errorsignore) 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, encodingutf-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)脚本干的事分两类判定。typeremote表示这条引用还是绝对地址说明改写阶段没覆盖到离线打开会指向原站typemissing表示引用已经被改成本地相对路径但目标文件不存在通常是下载失败或过滤规则拦掉了。两类问题的处理方式完全不同前者要补改写规则后者要补下载混在一起看会误判。几个实现细节值得说。ref.split(?)[0].split(#)[0]是必须的带查询串或锚点的引用直接拼路径永远找不到文件这是最常见的假阳性来源。unquote是为了处理 URL 编码的中文文件名不解码的话本地路径对不上。errorsignore让脚本能扛住个别编码坏掉的页面不至于整批中断。扫描范围覆盖html和css两类因为样式表里的背景图和字体是最容易漏的。跑完拿到 CSV按type分列排序missing行数除以总引用数就是这次镜像的完整度。我的经验线是低于 2% 可以接受逐个手补超过 10% 就别补了回头查allowed_domains和allowed_file_ext是不是配窄了改完重跑一遍比手工补快得多。吃过一次亏之后我现在每次镜像都会强制走一遍这个扫描不管产物看起来多正常。那次是一个文档站的字体全丢肉眼完全看不出来——浏览器用了后备字体排版只是“稍微有点不一样”直到交付给使用方人家在离线环境里打开才发现整站字重全错。从此扫描脚本就成了固定动作写完先跑扫描再看页面。希望帮到你。本文还有配套的精品资源点击获取