pywebcopy 网页离线镜像:资源本地化、限速与断点续跑
简介面向需要用 Python 抓取并归档在线网页的开发者与爬虫学习者这份 pywebcopy 库源码包提供了一套可直接研读与复用的网页镜像方案遵循 robots.txt一次调用即可保存包含 CSS、JS 和图片的完整页面也能通过重映射后的资源与链接克隆整站并提供简洁 API 与子类化自定义入口。包内共 32 个文件以 19 个 py 源码模块为主体涵盖爬虫调度、解析器、URL 处理、页面结构与配置等实现另有 8 个 md 文档说明库用法、配置项与操作指南辅以 yml 配置、依赖清单与许可证等工程文件压缩包约 65KB体量轻便、结构完整。已有 570 人学习下载。读者可据此理解镜像抓取的分层设计借助随包示例与测试用例快速上手并借鉴其链接重写、资源保存和异常处理思路用于离线阅读、网页存档或二次开发。1. 站点要下线、内网要留档、外部页面要固化成回归样本这类需求最后都会落到同一句话上给我一份双击 index.html 就能点着走的离线副本用 requests 写个循环十分钟能抓回一堆 HTML但打开后页面一片空白——样式、图片、页面之间的跳转全都指向线上域名。pywebcopy 这个 Python 库主要处理后半段的复杂工作按 URL 抓页面解析 DOM把能本地化的引用改写成相对路径再按域名和目录层级写入磁盘。它适合要归档存量站点的人、要离线分析页面结构的人、要把外部页面存成测试样本的人不适合需要登录、需要等 JS 渲染出内容的场景。边界在哪、参数怎么调、哪里容易出错下面按能跑起来的顺序讲。2. 装完 pywebcopy 先别急着抓最小可跑的三步与落盘结构很多人第一次使用 pywebcopy 会直接复制一段示例代码跑完之后看到目录里有文件就以为成功了结果用浏览器打开却是白屏或者点两下就跳回公网。问题多数不在库而在于没有先确认三件事当前安装的版本认哪些参数、单个页面能不能完整抓下来、写入磁盘后的目录结构是什么样。这三件事十分钟能做完可以省掉后面几个小时反复排查。2.1 先看签名再写代码确认当前版本认哪些配置键pywebcopy 的参数是通过关键字参数透传进内部配置的不同版本之间键名有增有删网上抄来的kwargs字典里经常有一半是当前版本不认的传进去会被静默忽略你以为是参数没生效其实是键名写错了。所以我的习惯是先打印签名看清楚函数接受什么。# 安装依赖建议在虚拟环境里装pywebcopy 会连带装上解析和请求相关的库 python -m pip install pywebcopy # 打印模块位置和两个入口函数的签名确认当前版本吃哪些参数 python - PY import inspect import pywebcopy print(module:, pywebcopy.__file__) print(save_webpage:, inspect.signature(pywebcopy.save_webpage)) print(save_website:, inspect.signature(pywebcopy.save_website)) PY第一行输出告诉你库装到了哪个解释器下虚拟环境没激活时最常见的现象就是「明明装了却 import 不到」。后面两行打印出的签名重点看有没有**kwargs如果有说明配置键是运行时校验的键名写错不会报错如果没有参数就是显式列出来的照着签名的名字写就行。这一步看着琐碎但它能挡掉后面一大类「参数设了没反应」的排查。2.2 抓单个页面save_webpage 的六个开关先定下来单页抓取是最小验证单元。先用一个页面把参数跑通确认图片、样式都落到本地了再去谈整站。from pywebcopy import save_webpage # 单页抓取页面本身 它引用的图片和样式 kwargs { bypass_robots: True, # 只对自有或已获授权的站点打开第三方站点保持默认 overwrite: True, # 首次跑覆盖旧目录避免残留上一次的脏文件 load_css: True, # 样式一定要留否则离线打开是裸 HTML load_images: True, # 图片同理体积换可读性 load_js: False, # 脚本默认不抓理由见 3.3 delay: 0.5, # 每个请求间隔 0.5 秒给目标站留余量 threaded: False, # 单页不需要并发关掉更好排查 } save_webpage( urlhttp://127.0.0.1:8000/docs/index.html, # 换成你要归档的页面地址 project_folder/data/mirror, # 镜像输出根目录 **kwargs, )这段代码里真正决定结果质量的是load_css、load_images和overwrite三个。load_css关掉时页面结构还在但完全没有排版很多人误以为是抓取失败overwrite关掉时如果目标目录已存在库可能直接跳过你会看到「跑完了但文件没变」delay在单页场景无所谓整站抓取时它是防止被限流的第一道保险。如果你需要更细的控制比如先抓页面、检查一遍 HTML 再决定要不要抓资源可以用WebPage类分两步走from pywebcopy import WebPage # 分步模式先构造对象再显式触发资源抓取 wp WebPage( urlhttp://127.0.0.1:8000/docs/guide.html, project_folder/data/mirror, bypass_robotsTrue, delay0.5, ) wp.save_complete() # 页面和引用资源一起落盘 print(wp.file_path) # 打印主 HTML 的落盘位置方便立刻打开验收save_complete()与save_webpage()的区别只在于控制粒度前者让你在落盘前后插入自己的逻辑比如记录耗时、判断状态码、把失败的 URL 记进重试队列。做批量任务时我更倾向这种写法因为它把「抓」和「记」拆开了。2.3 写入磁盘后的目录结构用 tree 和 grep 做第一次验收跑完不要急着交付先在命令行里看一眼结构再用 grep 找出所有残留的绝对 URL。残留的绝对 URL 就是没本地化成功的资源这个检查比肉眼看页面快得多。# 看目录层级和每层体积确认域名目录、资源目录都在 tree -L 3 -h /data/mirror | head -40 # 抓出 HTML 里还指向 http/https 的引用按域名统计找出没被改写的资源 grep -ohE https?://[^ )] /data/mirror/*/*.html \ | awk -F/ {print $3} | sort | uniq -c | sort -rn | head -20正常情况下你会看到一个以目标域名命名的目录下面按原站路径展开图片和样式各自归档在子目录里。第二条命令的输出如果清一色是你自己站的域名说明链接改写基本完成了如果出现第三方 CDN 域名且数量很大那是外链资源没被本地化属于预期行为看 3.3 怎么处理。如果出现目标站自己的域名那就是改写漏了通常是这些引用写在 JS 字符串或内联样式里库解析不到需要自己补一轮正则替换。3. 抓多少、抓多深pywebcopy 的范围参数与资源开关怎么定整站镜像最容易出的事故是范围失控本来只想抓一个文档栏目结果顺着导航栏爬到了全站再顺着外链爬到了别的域名最后几十万文件把磁盘写满。pywebcopy 给了一层范围控制但它的默认值偏保守需要你按目标站点结构手动收窄。这一节把真正影响结果的参数、白名单写法和资源取舍讲清楚。3.1 一张参数表影响结果的其实就这几个不要把示例里所有键都抄一遍大部分键对结果没有实质影响只会让排查变难。下表是我在归档任务里实际会调的项其余保持默认。参数作用我常用的取值注意点bypass_robots是否跳过 robots.txt 检查自有或已授权站点设 True第三方站点保持默认别图省事overwrite目标目录存在时是否覆盖首次 True增量任务 False设 False 时可能整站被跳过delay相邻请求的间隔秒数0.3 到 1.0外层并发后要同步放大threaded库内部是否并发抓资源小站 False大站谨慎开容易把目标站打挂或触发限流max_depth链接跟进的层数2 到 3每加一层文件数是量级增长allowed_domains只跟进这些域名目标主域子域单独列不写子域会漏掉静态资源域blacklist_urls不抓的 URL 前缀后台、登录、搜索、打印页按前缀匹配别塞正则load_css/load_images/load_js资源开关样式图片 True脚本 False关掉脚本可能页面空白make_zip抓完是否打包交付时 True调试时 False大站打包耗时明显提示上表键名以 2.1 打印出的签名为准。签名里没出现的键传了也不会生效且不会报错。max_depth是最需要谨慎设置的参数。文档站通常是「首页 → 栏目页 → 详情页」三层设为 2 就能覆盖正文页设为 5 时一个带日历归档、标签云、相关推荐的博客可以在几小时内产出数万个文件而且大部分是重复内容的翻页链接。我的做法是先用 1 跑一次看看抓到了什么再按实际链接层级往上加。3.2 用域名加 URL 前缀把范围固定住先探测再生成黑名单allowed_domains解决的是跨域问题但解决不了站内范围问题。一个站点里往往混着文档、博客、商城、活动页用深度控制是切不开的只能靠 URL 前缀。可行的做法是先做一轮轻量探测把站点里出现过的 URL 收集起来按前缀归类再生成黑名单传进去。import re import requests from collections import Counter from urllib.parse import urljoin, urlparse SEED http://127.0.0.1:8000/docs/index.html HOST urlparse(SEED).netloc # 只取首页做一轮浅探测看链接都分布在哪些路径前缀下 html requests.get(SEED, timeout10, headers{User-Agent: mirror-check/1.0}).text links re.findall(rhref[\]([^\])[\], html, flagsre.I) prefix Counter() for raw in links: full urljoin(SEED, raw) parsed urlparse(full) if parsed.netloc ! HOST: # 外域单独统计不参与站内前缀归纳 prefix[external] 1 continue parts [p for p in parsed.path.split(/) if p] prefix[/ (parts[0] if parts else )] 1 for key, count in prefix.most_common(20): print(f{count:5d} {key})这段脚本的作用是把「站内范围」变成可读的分布。输出里如果/docs/下面有两百条/admin/有五条那答案很清楚只镜像/docs/前缀其余全部写进黑名单。域名不同、协议相对的链接在这里就被归到external不会污染统计。拿到分布后把要排除的前缀拼成列表传给 pywebcopyfrom pywebcopy import save_website # 只镜像文档栏目其余前缀全部排除 BLACKLIST [ http://127.0.0.1:8000/admin/, http://127.0.0.1:8000/search, http://127.0.0.1:8000/user/, http://127.0.0.1:8000/print/, ] save_website( urlhttp://127.0.0.1:8000/docs/index.html, project_folder/data/mirror, allowed_domains[127.0.0.1], # 只跟这个域名防止爬出去 blacklist_urlsBLACKLIST, # 前缀匹配命中即跳过 max_depth3, # 首页到详情页三层足够 delay0.5, overwriteFalse, # 增量任务不覆盖 load_jsFalse, )这里allowed_domains和blacklist_urls是两道不同的闸门前者管「能不能出站」后者管「站内哪些不碰」。只设其中一个都会漏尤其是带子域名的静态资源站只写主域会漏抓 CSS页面照样白屏。3.3 资源类型的取舍为什么我默认关掉 JSload_js默认关闭不是偷懒。脚本抓下来之后页面里的fetch、import()、动态拼接的图片地址依然指向线上离线打开时这些请求全部失败控制台刷出一屏报错交互组件直接不渲染。换句话说抓了 JS 不等于页面能跑只是把失败从「样式缺失」变成「脚本报错」。我的取舍是这样的归档类需求关脚本保证文档、图片、样式这三样可读即可交付物是一个能翻页、能看图的静态副本如果是做页面结构分析同样关脚本因为你要的就是服务端返回的原始 DOM只有当目标是纯前端渲染的单页应用才需要另外用无头浏览器方案先渲染再存快照那已经超出 pywebcopy 的职责范围。字体文件和视频要单独考虑。字体通常几十到几百 KB 一个一个站点能带十几个变体全抓会让体积翻倍而绝大多数归档场景只需要系统字体兜底可以在落盘后用一段后处理把font-face块清掉。视频更极端一个页面挂几个大文件就能把磁盘写满一律写进黑名单需要的话单独按 URL 清单下载。4. 从一次性脚本到可重跑任务并发、限速与断点续跑单个页面跑通之后接下来要把流程变成任务。整站镜像的耗时可能在几分钟到几小时之间中途断网、目标站重启、本机磁盘满都会让进程挂在半路。没有断点机制的脚本只能从头再来这是最容易让人抓狂的地方。这一节讲三件事并发放在哪一层、限速怎么算、断点怎么记。4.1 并发放外层用清单加进程池让每个任务独立目录pywebcopy 内部的threaded是给单站点抓资源用的多个站点或多个栏目并行时正确的做法是在外层自己控制并发并且给每个任务分配独立的输出目录避免两个进程同时写同一棵目录树。from concurrent.futures import ProcessPoolExecutor, as_completed from pywebcopy import save_webpage # 每个任务一个栏目入口 独立子目录避免并发写冲突 TASKS [ (http://127.0.0.1:8000/docs/guide/, /data/mirror/guide), (http://127.0.0.1:8000/docs/api/, /data/mirror/api), (http://127.0.0.1:8000/docs/faq/, /data/mirror/faq), ] def run_one(item): url, out_dir item try: save_webpage( urlurl, project_folderout_dir, bypass_robotsTrue, overwriteFalse, load_cssTrue, load_imagesTrue, load_jsFalse, delay1.0, # 并发下把单请求间隔放大 threadedFalse, # 内层不再并发总并发等于进程数 ) return url, ok except Exception as exc: # 单任务失败不影响其他任务 return url, ffail: {exc} if __name__ __main__: # 进程数控制在 3 到 4配合 delay 让目标站看到的是平缓流量 with ProcessPoolExecutor(max_workers3) as pool: futures [pool.submit(run_one, t) for t in TASKS] for fut in as_completed(futures): url, status fut.result() print(status.ljust(20), url)并发数和delay要一起算。三个进程、每个请求间隔 1 秒对目标站的实际压力大约是每秒三个请求这个量级对大多数中小站点是可以接受的把进程数提到 8 而delay保持 0.5峰值就到了每秒十几个请求很容易触发限流。这里的取舍不是性能是「别把别人的站打挂」和「别让自己被封」。4.2 断点续跑一张 SQLite 任务表比任何技巧都实在真正跑过整站的人都知道中断是常态。与其在代码里加各种 try 重试不如把任务状态外置到一张表里进程随便死重启后接着跑。import sqlite3 DB /data/mirror/tasks.db def init_db(): conn sqlite3.connect(DB) conn.execute( CREATE TABLE IF NOT EXISTS tasks ( url TEXT PRIMARY KEY, out_dir TEXT NOT NULL, status TEXT NOT NULL DEFAULT pending, -- pending / done / failed attempts INTEGER NOT NULL DEFAULT 0, updated TEXT ) ) conn.commit() return conn def seed(conn, pairs): # 去重插入重复执行不会产生重复任务 conn.executemany( INSERT OR IGNORE INTO tasks(url, out_dir) VALUES (?, ?), pairs ) conn.commit() def take_pending(conn, limit50): # 只取待办且失败次数小于 3 的防止死循环重试 cur conn.execute( SELECT url, out_dir FROM tasks WHERE statuspending AND attempts3 LIMIT ?, (limit,), ) return cur.fetchall() def mark(conn, url, status): conn.execute( UPDATE tasks SET status?, attemptsattempts1, updateddatetime(now) WHERE url?, (status, url), ) conn.commit()INSERT OR IGNORE让任务清单可以反复播种跑第二遍不会产生重复行attempts3是这里的后悔药失败任务重试三次后自动停手不至于在某个 404 页面上无限打转。状态字段只有三个值够用且好查SELECT status, count(*) FROM tasks GROUP BY status一条语句就能看到进度。4.3 增量镜像用 HEAD 请求比对别每次都全量重抓栏目内容每周更新但绝大多数页面是没变的。全量重抓既慢又给目标站增加无用负担常见做法是先发一批 HEAD 请求比对ETag和Last-Modified只把变化的 URL 标回pending。import sqlite3 import requests def refresh_changed(conn, timeout8): # 对已完成任务做条件请求服务端返回 304 说明内容未变保持 done rows conn.execute(SELECT url FROM tasks WHERE statusdone).fetchall() for (url,) in rows: try: resp requests.head(url, timeouttimeout, allow_redirectsTrue) except requests.RequestException: continue if resp.status_code 200 and resp.headers.get(ETag): # 真实项目里应把 ETag 存到表里做比对这里演示取用方式 conn.execute( UPDATE tasks SET attempts0 WHERE url?, (url,) ) conn.commit() print(checked:, len(rows))实际落地时要在 tasks 表里加两列etag和last_modified首次抓取成功后把响应头写进去后续比对不一致才把状态改回pending。这一步能省掉六七成的重复抓取栏目页越多收益越明显。注意 HEAD 请求本身也有成本几千个 URL 串行发一遍要十几分钟可以复用 4.1 的进程池或者按栏目分批做。5. 五条常见的失败情况镜像页面打不开时的排查顺序镜像这件事有个特点进程不报错、退出码是 0、文件也确实生成了但产物不能用。这类问题只能靠固定顺序的检查来定位而不是凭感觉改参数。下面五条是我遇到频次最高的按「先看什么、为什么、怎么改」写。5.1 页面白屏控制台一排 404 指向样式和图片现象本地打开主 HTML页面结构在但没有排版开发者工具里样式表和图片全部 404路径形如../../static/css/app.css。原因链接改写是按页面在镜像目录中的相对位置计算的如果抓取时同一个页面既从/docs/a/又被/docs/a/index.html命中两次写入的目录深度不同相对路径就会错位。另一个常见原因是load_css为 FalseHTML 里保留的还是线上绝对地址。解决先确认load_css、load_images为 True再看目录里是否存在重复的页面副本同名不同层有的话用blacklist_urls把带index.html的变体排除掉让每个页面只有一个落盘位置。改完重抓一次用 2.3 的 grep 命令确认没有指向目标站自身域名的绝对 URL。5.2 中文文件名变成一长串 %E4%B8%AD 编码现象目录里出现大量%E4%BD%BF%E7%94%A8%E6%8C%87%E5%8D%97.html这类文件名肉眼无法定位某些文件系统上还会因长度超限写入失败。原因URL 里的非 ASCII 字符本身就经过百分号编码库按 URL 原样落盘时不会自动解码。这在中文站点上几乎是必然。解决抓完之后跑一轮重命名用urllib.parse.unquote解码文件名再处理掉 Windows 不允许的字符\ / : * ? |。注意重命名的同时要同步改写引用这些文件的 HTML否则链接会断。稳妥的做法是先解码、再统一用一个后处理脚本扫描所有 HTML 做字符串替换两步都做完再验收。5.3 抓到一半开始大量失败日志里是 429 或 503现象前几百个页面正常之后成功率骤降报错集中在限流状态码。原因threaded开着、delay又设得很小短时间内请求密度超过目标站阈值。也可能是外层并发数翻倍后没有同步调整delay。解决把threaded关掉delay提到 1.0 以上外层进程数压到 3 以内对 429 和 5xx 做指数退避重试首次等 2 秒、第二次 8 秒、第三次放弃并标failed。不要用固定间隔猛重试那只会延长被封的时间。5.4 深度设成 5跑完发现十几万文件、磁盘满了现象du -sh显示镜像目录几十 GB里面大量是分页、标签、日历归档、排序参数不同的同一批内容。原因max_depth太大加上站点的筛选和排序参数会产生近似无限的 URL 组合?page2sortasc这类。解决把max_depth降到 2 到 3用blacklist_urls排除带?的路径如果站点内容不依赖查询参数已经抓下来的重复目录直接删掉重跑。这类问题的血泪经验是先跑深度 1 看产出规模按比例估算深度 3 的量级再决定要不要开。5.5 本地打开正常但点一下链接就跳到公网现象主页面能看点击导航跳到线上地址浏览器地址栏变成目标的公网域名。原因a标签的 href 没有被改写成相对路径通常是这些链接由 JS 拼接生成或者写在script typeapplication/json的数据块里HTML 解析阶段看不到。解决先定位来源用grep -n https://目标域名 *.html找出具体的 HTML 片段判断是静态属性还是脚本内字符串静态属性的漏改可以自己写一轮正则替换脚本内拼接的则需要在归档说明里明确标注「跳转链接不可用」或者手工摘出导航结构另存一份索引页。6. 交付前跑两道校验本地链接全通与镜像体积可收敛镜像产物能不能交付不该靠「我随便点了几下没问题」来判断。我的习惯是跑两个脚本一个扫全站本地引用查断链一个按文件类型统计体积找异常大头。这两步加起来不到十分钟能挡掉交付后 90% 的返工。第一个脚本扫所有 HTML 里href和src的本地引用解码百分号编码后判断文件是否存在输出缺失清单import re from pathlib import Path from urllib.parse import unquote ROOT Path(/data/mirror) PATTERN re.compile(r(?:href|src)\s*\s*[\]([^\])[\], re.I) SKIP (http://, https://, //, #, mailto:, javascript:, data:) missing [] for html in ROOT.rglob(*.html): text html.read_text(encodingutf-8, errorsignore) for ref in PATTERN.findall(text): if ref.startswith(SKIP): continue # 去掉锚点和查询串再按 HTML 所在目录解析相对路径 clean unquote(ref.split(#)[0].split(?)[0]) if not clean: continue target (html.parent / clean).resolve() if not target.exists(): missing.append((str(html.relative_to(ROOT)), ref)) print(missing:, len(missing)) for page, ref in missing[:30]: # 只打前 30 条够定位模式了 print(f {page} - {ref})关键在处理顺序先去掉#和?再unquote解码最后按当前 HTML 所在目录拼接。顺序错了会把带中文的路径全部误判为断链。输出里如果同一个前缀反复出现说明是某一批资源没抓到回到 3.3 看对应的资源开关。第二个脚本按扩展名统计体积找出谁在占用空间扩展名数量级判断超过阈值时怎么处理.html与任务清单量级一致数量翻倍说明有重复落盘.css/.js单站通常几十到几百个上千说明抓了整站资源库.png/.jpg占比最大的一项超总量一半考虑降采样或转换格式.woff2/.ttf应当极少出现即清理改用系统字体.mp4/.zip应当为零检查黑名单是否漏了大文件路径我踩过最贵的一次坑是没做体积统计一个文档站的镜像跑完 40 GB八成是首页轮播图的原图而归档需求只要缩略图。后来就固定成流程的一部分——镜像完成先跑统计占比异常的扩展名单独处理再打包交付。最后说个习惯上的事。pywebcopy 这类工具真正难的从来不是调 API而是想清楚「我要的到底是哪一部分站点」。范围定下来之前任何参数调整都是玄学范围定下来之后剩下的只是把断链、编码、限流这几件事逐个按固定顺序检查完。我现在的做法是先手写三五行脚本探测链接分布再决定参数比直接抄示例省事得多。希望帮到你。本文还有配套的精品资源点击获取