简介PyWebCopy 是一个遵循 MIT 协议的 Python 网页镜像与站点归档库面向需要批量保存网页、抓取静态资产或制作离线阅读副本的开发者与爬虫学习者。它支持一次调用保存包含 CSS、JS 和图像的完整页面也能通过重新映射资产与链接克隆整站并在流程中尊重 robots.txt适用于网页存档、资料备份、前端资源分析与轻量站点镜像等场景。压缩包共 32 个文件整体约 65KB以 19 个 Python 源码文件为主体覆盖核心 API、爬虫、解析器、页面结构与配置等模块另含 8 个 Markdown 文档、2 个 YAML 配置以及许可证、依赖和忽略规则文件便于安装、阅读源码和运行示例。包内还提供示例脚本与基础、进阶测试用例可辅助理解抓取、HTML 解析、资产下载和镜像流程当前已有 572 人学习适合希望用 Python 完成网页归档与轻量站点克隆的读者参考。1. pywebcopy 是什么把网页拆成可离线打开的本地目录pywebcopy 是一个用 Python 写的网页镜像库核心工作是把一个 URL 拆成 HTML、CSS、图片和脚本按目录落到本地让离线打开时链接还能对上。它属于 python 生态里偏 webpage mirror 的一类工具靠 html-parser 把页面拆成资源再按 archive-tool 的思路落盘适合做文档归档、页面留证、内网知识库离线包和课程资料备份。不适合强依赖 JS 渲染、登录态复杂、需要验证码的站点这类站用 pywebcopy 只能拿到首屏 HTML后面要另接浏览器。新手可以把它当成能写进脚本的命令行下载器替代熟手更该关注它的资源重写边界和递归控制。我按安装、单页、整站、资源本地化、避坑、流水线封装六步拆。2. 安装与最小可用镜像save_webpage 的四个关键参数2.1 安装与运行环境检查先把它装进独立虚拟环境别直接往系统 Python 里塞。pywebcopy 会拉取发请求和解析 HTML 的依赖和现有项目混装时最容易出现requests版本冲突表现是 import 正常、一跑就报属性错误。我一般会建一个专用目录虚拟环境、镜像输出、日志都放在里面后面排查时不用到处找路径。python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate python -m pip install --upgrade pip python -m pip install pywebcopy python -c import sys, pywebcopy; print(sys.version); print(pywebcopy.__file__)逻辑说明前三行建环境并激活第四行装库最后一行确认当前解释器到底加载了哪个 pywebcopy。很多人装完发现命令行能跑、脚本里不能跑原因是 IDE 选错了解释器打印pywebcopy.__file__能直接看到安装位置比猜快。参数说明--upgrade pip只是更新安装器不解决依赖冲突pywebcopy不锁版本时拿到的是当前发布版生产环境建议在requirements.txt里锁死版本避免下次重装行为变化。安装失败时先看报错里的包名再看 Python 版本是否过旧。公司网络受限时pip 可能连不上默认源这种情况找内部源配置别在代码里硬改下载逻辑。装完后跑一个最小 import确认没有缺lxml、cssutils一类解析依赖。缺依赖时 pywebcopy 的报错不一定直观常见是解析阶段抛ImportError所以这一步别省。提示虚拟环境激活后python -m pip和pip尽量统一用前者避免 PATH 里混进另一个 pip。2.2 save_webpage 的最小调用与输出结构单页镜像先用save_webpage跑通。它做两件事抓入口 HTML下载页面引用的资源并把引用改成本地路径。下面这段用绝对路径构造输出目录避免相对路径随工作目录漂移。from pathlib import Path from pywebcopy import save_webpage # 用绝对路径避免相对路径随工作目录变化 download_root Path(/data/mirror).resolve() download_root.mkdir(parentsTrue, exist_okTrue) save_webpage( urlhttps://example.com/docs/index.html, project_namedocs_home, download_rootstr(download_root), overwriteTrue, bypass_robots_txtFalse, ) # 看一眼输出结构别硬编码入口文件名 target download_root / docs_home for p in sorted(target.rglob(*))[:30]: print(p.relative_to(target))逻辑说明save_webpage只处理一个入口页面递归范围有限适合先验证目录结构和资源重写是否符合预期。project_name决定子目录名download_root是父目录两者拼起来才是最终落盘位置。最后几行用rglob打印前 30 个文件是为了确认入口 HTML 和资源目录的实际命名不同版本可能有差异不要照抄旧文章里的固定路径。参数说明url是入口地址带不带末尾斜杠会影响相对链接解析最好和浏览器地址栏保持一致download_root用Path.resolve()转绝对路径overwriteTrue在反复调试时省事但会覆盖同名项目目录生产任务别随手开bypass_robots_txtFalse表示尊重站点规则只有自己的站点或明确授权时才改。输出结构常见是一个入口 HTML 加一个资源目录资源目录里按域名或路径分层。判断入口时找包含link relstylesheet或img src的那个 HTML通常比找index.html可靠。如果页面打开是空白先看 HTML 里有没有内容再看 CSS 和图片路径是不是指向了不存在的本地层级。注意overwriteTrue不是后悔药它会清掉同名项目目录。路径里带重要数据时先改成False或换project_name。2.3 参数拆解project_name、download_root、overwrite、bypass_robots_txt四个参数里最容易翻车的是路径和覆盖开关。下面这张表是我自己的默认值按任务类型微调。参数作用建议值踩坑点project_name输出项目子目录名ASCII、下划线、短名中文、空格、斜杠会导致路径解析异常download_root输出父目录绝对路径、数据盘相对路径受启动目录影响定时任务里必错overwrite是否覆盖同名项目调试 True生产 False同名任务会清掉旧结果且不可恢复bypass_robots_txt是否跳过 robots 检查默认 False只在自己的站点或授权场景改 Trueproject_name不要用中文和空格不是文件名一定不支持而是后续脚本、日志、压缩包、跨平台拷贝时会多出一堆编码问题。我一般用站点简称_页面类型_日期比如docs_home_202501短且能排序。download_root放数据盘不要放代码仓库镜像任务动辄几个 GB混进 git 里清理很麻烦。overwrite在调试阶段开 True 能省去手动删目录但生产任务要关掉或者每次任务用新project_name。曾经有一次定时任务没改项目名第二天把前一天的留证页面覆盖了后来我习惯在任务入口先检查目标目录是否存在存在就换名或直接退出。bypass_robots_txt不是性能开关。默认 False 时库会看站点规则改成 True 只适合内网文档站、自己维护的站点、拿到书面授权的归档任务。对外部站点批量抓取时先看 robots 和站点条款再决定要不要继续。这个参数改 True 不会帮你绕过登录、验证码和访问频率限制别把它当万能钥匙。选型上pywebcopy 的优势是 Python 内嵌、输出目录稳定、能拿到对象做二次处理劣势是它不执行 JS复杂页面只能拿到首屏 HTML。做静态文档、帮助中心、博客归档它够用做 SPA 后台、需要登录的工单系统先找接口或渲染后的 DOM别硬套。3. 整站抓取save_website 的递归、线程与限速3.1 save_website 的调用骨架单页验证通过后再上save_website。它从入口开始跟进同域链接适合文档站、静态博客、帮助中心这类 HTML 链接清晰的站点。调用骨架和单页差不多区别是入口通常给栏目首页并且要先接受它会抓出一堆页面。from pathlib import Path from pywebcopy import save_website download_root Path(/data/mirror).resolve() download_root.mkdir(parentsTrue, exist_okTrue) save_website( urlhttps://example.com/docs/, project_namedocs_site, download_rootstr(download_root), overwriteTrue, bypass_robots_txtFalse, )逻辑说明save_website会从url出发按库内部的链接发现规则递归。它比save_webpage省事但递归边界不完全由你控制查询串、分页、标签页可能被重复抓或漏抓。第一次跑不要直接全量先拿一个只有十几页的栏目试观察日志里出现了哪些 URL。参数说明url末尾斜杠建议保留否则相对链接可能按文件名层级解析project_name不要和单页任务重名download_root仍然用绝对路径overwriteTrue只在小范围试跑时用。跑完用两条命令看页面数和体积判断有没有跑飞。find /data/mirror/docs_site -name *.html | wc -l du -sh /data/mirror/docs_site逻辑说明第一条统计 HTML 数量第二条看总占用。页面数在预期范围内、体积没有异常膨胀再考虑扩大入口。参数说明-name *.html只匹配 HTML有些站点用.htm或无扩展名需要按实际情况调整du -sh看的是目录总量资源多的时候涨得比页面快。如果save_website抓出来的页面结构很乱或者你发现它跟进到了不该抓的栏目别急着调库参数先换外层 BFS 控制。下面 3.2 的做法更笨但边界清楚。3.2 递归深度与同域过滤避免抓穿整个互联网递归抓取的第一原则是设边界域名、路径前缀、最大深度、最大页数四个至少设三个。save_website不一定暴露细粒度深度参数常见做法是自己用 BFS 收集 URL再逐个调用save_webpage。这样虽然多写几十行但抓多少、抓到哪里、什么时候停全在脚本里。from urllib.parse import urljoin, urlparse from collections import deque import requests from bs4 import BeautifulSoup from pywebcopy import save_webpage START https://example.com/docs/ ALLOWED_HOST example.com ALLOWED_PREFIX /docs/ MAX_PAGES 50 MAX_DEPTH 2 def same_scope(url): p urlparse(url) return p.netloc ALLOWED_HOST and p.path.startswith(ALLOWED_PREFIX) seen set() queue deque([(START, 0)]) pages [] while queue and len(pages) MAX_PAGES: url, depth queue.popleft() if url in seen or not same_scope(url): continue seen.add(url) pages.append(url) if depth MAX_DEPTH: continue try: html requests.get(url, timeout10).text soup BeautifulSoup(html, html.parser) for a in soup.find_all(a, hrefTrue): nxt urljoin(url, a[href]).split(#)[0] if nxt not in seen and same_scope(nxt): queue.append((nxt, depth 1)) except Exception as e: print(collect fail, url, e) for u in pages: save_webpage( urlu, project_namedocs_site_bfs, download_root/data/mirror, overwriteFalse, bypass_robots_txtFalse, )逻辑说明先 BFS 收集 URLseen去重MAX_DEPTH限制层数MAX_PAGES限制总量收集阶段只发轻量 GET不下载资源确认范围后再逐个save_webpage。这样即使某个栏目生成无限分页也会在 50 页处停住。split(#)[0]去掉锚点避免同一页面因#section被当成多个 URL。参数说明ALLOWED_PREFIX必须带尾斜杠/docs/不会误伤/docs-other/MAX_DEPTH2表示入口算第 0 层最多跟到第二层链接MAX_PAGES50是试跑值正式任务按站点规模调但别一上来就几千。overwriteFalse避免重复任务互相覆盖。收集阶段的requests.get也要加延迟和 UA下面 3.3 统一处理。这种 BFS 的缺点是只认a href漏掉 JS 生成的链接和表单分页优点是可解释出了问题是哪一层、哪个 URL 越界一眼能看出来。save_website适合快速打包一个已知规模的静态站BFS 适合需要严格控制边界的归档任务。3.3 线程、延迟与重试把请求压到对方能接受的范围并发能缩短时间但也会把对方站点打出 429。我的默认值是 2 个线程每个任务前随机睡 0.8 到 1.4 秒失败按指数退避重试。文档站可以放到 3 到 4 个线程生产站不要超过 2 个内网自建站另说。import time import random from concurrent.futures import ThreadPoolExecutor, as_completed from pywebcopy import save_webpage URLS [] # 来自 3.2 的 pages def mirror_one(url): last_err None for attempt in range(4): try: time.sleep(0.8 random.random() * 0.6) save_webpage( urlurl, project_namedocs_site_bfs, download_root/data/mirror, overwriteFalse, bypass_robots_txtFalse, ) return url, ok except Exception as e: last_err e time.sleep(2 ** attempt) return url, ffail: {last_err} with ThreadPoolExecutor(max_workers2) as ex: futures [ex.submit(mirror_one, u) for u in URLS] for f in as_completed(futures): print(f.result())逻辑说明每个任务先随机延迟降低同一秒集中请求的概率失败后2 ** attempt产生 1、2、4 秒退避最多试 4 次外层线程池只负责调度不共享 pywebcopy 的内部状态。as_completed让先完成的先打印方便观察哪类 URL 失败。参数说明max_workers2是保守值不是性能上限time.sleep(0.8 random.random() * 0.6)让请求间隔在 0.8 到 1.4 秒之间抖动range(4)是总尝试次数不是重试次数overwriteFalse避免并发任务覆盖同一个项目目录。注意 pywebcopy 内部可能也有并发行为外层线程数要更低如果日志交错严重直接改成串行。遇到 403 或 429先降线程、加延迟再看请求头。pywebcopy 的默认 UA 可能被某些站点拒绝常见做法是在外层用requests.Session预热或者查库的配置项改 UA不同版本配置方式不同用dir()和help()看别照抄旧代码。限速不是玄学记录每个 URL 的开始时间、耗时、状态跑两天就能看出对方能接受的节奏。提示先拿 5 个 URL 试跑观察耗时和失败率再放大。全量跑飞后清理磁盘比一开始慢一点更费时间。4. 资源重写与本地化CSS、JS、图片的路径怎么不翻车4.1 相对路径与 base 目录的对应关系浏览器解析src、href时基准是文档 URL 或base href。pywebcopy 下载后会尝试把引用改成本地相对路径但如果页面用了base、协议相对地址、带查询串的绝对路径重写就可能漏。镜像完先跑一个缺失检查别等打开页面才发现样式全丢。from pathlib import Path from bs4 import BeautifulSoup root Path(/data/mirror/docs_home) entry next(root.glob(*.html)) # 常见入口按实际情况改 soup BeautifulSoup(entry.read_text(encodingutf-8, errorsignore), html.parser) missing [] for tag, attr in [(img, src), (script, src), (link, href)]: for node in soup.find_all(tag): val node.get(attr) if not val or val.startswith((http://, https://, //, data:)): continue target (entry.parent / val).resolve() if not target.exists(): missing.append((tag, attr, val, str(target))) for item in missing[:50]: print(item) print(missing count, len(missing))逻辑说明遍历本地 HTML 的img/script/link跳过外链和 data URI把相对路径按入口 HTML 所在目录解析成绝对路径再检查文件是否存在。输出前 50 条是为了不刷屏最后打印总数判断严重程度。参数说明errorsignore防止编码问题中断检查entry.parent是相对路径基准如果入口在子目录要改成对应父目录root.glob(*.html)只找一层入口在深层时换rglob。常见问题是base href让基准变了脚本按入口目录算就会误报。遇到这种页面先解析base的 href再以它为基准。另一个常见问题是协议相对地址//cdn.example.com/a.js脚本跳过了但实际没下载这类资源要么补下载并重写要么在本地打开时接受它走网络。镜像归档的目标是离线可读外链资源越少越好。提示入口 HTML 不一定是根目录第一个找包含最多link relstylesheet和img的那个或者按文件大小排序取最大的。4.2 CSS 中 url() 与 import 的处理边界HTML 解析器只能看到link relstylesheetCSS 内部的url(...)和import是第二层引用。pywebcopy 对简单的url()会处理但链式import、字体文件、source map、CSS 变量里的 url 容易漏。下面这段正则扫描只做一件事把本地 CSS 里指向不存在文件的引用列出来。import re from pathlib import Path css_root Path(/data/mirror/docs_home) pat re.compile(rurl\(([^)])\)|import\s[\]([^\])[\]) for css in css_root.rglob(*.css): text css.read_text(encodingutf-8, errorsignore) for m in pat.finditer(text): ref (m.group(1) or m.group(2)).strip(\ ) if ref.startswith((http://, https://, //, data:)): continue target (css.parent / ref.split(?)[0]).resolve() if not target.exists(): print(css, -, ref)逻辑说明rglob(*.css)遍历所有 CSS正则同时匹配url(...)和import ...去掉引号和查询串后按 CSS 所在目录解析。只打印缺失项不改文件先看清范围再决定补下载还是重写。参数说明split(?)[0]去掉缓存参数data:跳过内联资源strip(\ )处理带引号的 url。import可能带媒体查询比如import print.css print;正则只取引号里的路径够用但不完整。复杂站建议用浏览器开发者工具导出资源清单或者抓 HAR 对照别只依赖正则。补下载时保留原目录层级改 CSS 时先备份按文件处理不要全局替换否则容易把注释和字符串里的内容改坏。CSS 里的字体文件经常被漏表现是页面能开、字体回退。检查font-face里的src: url(...)把缺失的 woff/woff2 补到对应目录。source map 对离线阅读不重要可以不管但如果要二次调试缺失的.map会在控制台报 404不影响页面。4.3 编码与文件名中文路径、查询串、超长文件名URL 里的中文和空格会被百分号编码直接落盘会得到%E4%B8%AD这样的名字查询串带?在 Windows 文件名里非法路径层级一深超过 260 字符就报错。文件名处理的原则是能读、不冲突、不超长。import hashlib from pathlib import Path from urllib.parse import urlparse, unquote def safe_name(url: str, keep_extTrue) - str: p urlparse(url) raw unquote(Path(p.path).name) or index raw raw.replace(/, _).replace(\\, _).replace(?, _) stem Path(raw).stem[:60] ext Path(raw).suffix[:10] if keep_ext else digest hashlib.sha1(url.encode(utf-8)).hexdigest()[:8] return f{stem}_{digest}{ext}逻辑说明先解码 URL 路径取文件名替换斜杠和问号等非法字符主文件名截到 60 字符给目录层级留余量用 URL 的 SHA1 前 8 位做后缀避免同名冲突。参数说明keep_ext控制是否保留扩展名图片和 CSS 建议保留[:60]不是绝对安全值路径总长还要看download_root[:8]哈希足够区分同目录文件冲突概率很低。改文件名后要同步改 HTML/CSS 里的引用不能只改磁盘文件否则页面还是 404。我一般只在补下载漏资源时用这个函数pywebcopy 自己生成的文件名不动避免重写范围扩大。Windows 上把download_root放到短路径比如D:\mirror比开长路径支持更省事。问题表现处理中文 URL文件名是%E4%B8%AD用unquote解码后生成安全名查询串文件名含?或过长去掉查询串用哈希做后缀超长路径File name too long缩短项目名和主文件名换短根目录同名资源后下载的覆盖前一个哈希后缀区分或按目录分层5. 避坑与排查pywebcopy 镜像最常翻车的五个现场镜像不是点一下就跑。血泪经验是大部分问题不在库本身而在资源边界和请求策略。下面五个现场按现象、原因、解决写每个都带可执行的排查动作。5.1 现象页面能下但样式全丢本地打开 HTML 是裸文本标题和图片在CSS 全 404。原因通常是link的 href 是绝对路径或带查询串重写没覆盖也可能是入口 HTML 放错层级相对路径整体偏移。先跑 4.1 的缺失检查看本文还有配套的精品资源点击获取