ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

pywebcopy 网页离线镜像:资源本地化、限速与断点续跑

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( url="http://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( url="http://127.0.0.1:8000/docs/guide.html", project_folder="/data/mirror", bypass_robots=True, delay=0.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, timeout=10, headers={"User-Agent": "mirror-check/1.0"}).text links = re.findall(r'href=["\']([^"\']+)["\']', html, flags=re.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>,不会污染统计。

拿到分布后,把要排除的前缀拼成列表传给 pywebcopy:

from 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( url="http://127.0.0.1:8000/docs/index.html", project_folder="/data/mirror", allowed_domains=["127.0.0.1"], # 只跟这个域名,防止爬出去 blacklist_urls=BLACKLIST, # 前缀匹配,命中即跳过 max_depth=3, # 首页到详情页三层足够 delay=0.5, overwrite=False, # 增量任务不覆盖 load_js=False, )

这里allowed_domains和blacklist_urls是两道不同的闸门:前者管「能不能出站」,后者管「站内哪些不碰」。只设其中一个都会漏,尤其是带子域名的静态资源站,只写主域会漏抓 CSS,页面照样白屏。

3.3 资源类型的取舍:为什么我默认关掉 JS

load_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( url=url, project_folder=out_dir, bypass_robots=True, overwrite=False, load_css=True, load_images=True, load_js=False, delay=1.0, # 并发下把单请求间隔放大 threaded=False, # 内层不再并发,总并发等于进程数 ) return url, "ok" except Exception as exc: # 单任务失败不影响其他任务 return url, f"fail: {exc}" if __name__ == "__main__": # 进程数控制在 3 到 4,配合 delay 让目标站看到的是平缓流量 with ProcessPoolExecutor(max_workers=3) 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, limit=50): # 只取待办,且失败次数小于 3 的,防止死循环重试 cur = conn.execute( "SELECT url, out_dir FROM tasks WHERE status='pending' AND attempts<3 LIMIT ?", (limit,), ) return cur.fetchall() def mark(conn, url, status): conn.execute( "UPDATE tasks SET status=?, attempts=attempts+1, updated=datetime('now') WHERE url=?", (status, url), ) conn.commit()

INSERT OR IGNORE让任务清单可以反复播种,跑第二遍不会产生重复行;attempts<3是这里的后悔药,失败任务重试三次后自动停手,不至于在某个 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, timeout=8): # 对已完成任务做条件请求,服务端返回 304 说明内容未变,保持 done rows = conn.execute("SELECT url FROM tasks WHERE status='done'").fetchall() for (url,) in rows: try: resp = requests.head(url, timeout=timeout, allow_redirects=True) except requests.RequestException: continue if resp.status_code == 200 and resp.headers.get("ETag"): # 真实项目里应把 ETag 存到表里做比对,这里演示取用方式 conn.execute( "UPDATE tasks SET attempts=0 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为 False,HTML 里保留的还是线上绝对地址。

解决:先确认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 组合(?page=2&sort=asc这类)。

解决:把max_depth降到 2 到 3;用blacklist_urls排除带?的路径(如果站点内容不依赖查询参数);已经抓下来的重复目录直接删掉重跑。这类问题的血泪经验是:先跑深度 1 看产出规模,按比例估算深度 3 的量级,再决定要不要开。

5.5 本地打开正常,但点一下链接就跳到公网

现象:主页面能看,点击导航跳到线上地址,浏览器地址栏变成目标的公网域名。

原因:<a>标签的 href 没有被改写成相对路径,通常是这些链接由 JS 拼接生成,或者写在<script type="application/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(encoding="utf-8", errors="ignore") 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,而是想清楚「我要的到底是哪一部分站点」。范围定下来之前,任何参数调整都是玄学;范围定下来之后,剩下的只是把断链、编码、限流这几件事逐个按固定顺序检查完。我现在的做法是先手写三五行脚本探测链接分布,再决定参数,比直接抄示例省事得多。希望帮到你。

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

返回列表