网站离线复刻 · 整站镜像
环境准备
pip install -r requirements.txt
依赖:numpy、Pillow(用于生成缩略图与资源处理)。
把一个在线网站抓成本地副本:双击即开、完全断网可用、源码原样可编辑。
> 现场实测基线(xueai.miyang.cn/slides/learn.html,466 节课的课程站):
> 634 文件 / 30.77 MB / 112 秒抓完;重跑 0.1 秒跳过 492 个文件且产物指纹不变(幂等 PASS);
> 7 个页面断网验收全部 0 外链 PASS;与在线页逐像素比对平均差 0.57(阈值 <6 为良好)。
触发条件
| 触发方式 | 示例 |
|---|---|
| 关键词 | "把整个网站抓下来离线"、"整站镜像"、"网站离线复刻"、"离线后还要能改"、"断网能打开"、"扒站"、"做个离线副本" |
| 场景 | 用户给出一个在线站点 URL,要求本地可打开且可编辑 |
不触发:只读单页正文 → WebFetch;有 .warc/.wacz 归档要还原 → wacz-to-editable-html;只想找历史快照 → Wayback。
适用边界(重要)
本技能面向静态 / 服务端渲染(SSR)站点:页面由 HTML + 服务端吐出的链接构成,或经无头浏览器渲染后 DOM 里能枚举出 <a href> 导航与框架运行时数据(Next 的 __NEXT_DATA__、Nuxt 的 __NUXT__、Redux 初始态、window.COURSE/__DATA__ 等,discover_links.mjs 已自动探测)。
> ⚠ 纯客户端 SPA 的边界:若站点所有页面内容都靠前端 JS 动态生成、且既不暴露上述 window 数据源、也不渲染出可点击的 <a href> 导航(例如入口只有一个空 #app 容器、路由完全由 JS 接管且无锚点),则本流程无法可靠抓全页面。此类"纯客户端渲染 SPA"请改用 site-clone-static(走 API 抓取 + mock 后端)来复刻,不要强行用本技能"承诺能抓任意网站"。
>
> 实践上:先跑 ① discover_links.mjs,看 PAGES 数量是否覆盖预期页面;若 PAGES 明显过少(远少于站点真实页面数),基本可判定为纯客户端 SPA,转 site-clone-static。
一、五步流程(顺序不可换)
① 渲染后枚举 → ② 整站下载改写 → ③ JS 资源收尾 → ④ 断网验收 → ⑤ 还原度量化
| 步 | 命令 | 在解决什么 |
|---|---|---|
| ① | node scripts/discover_links.mjs --url <入口> --out urls.txt --wait 9000 --same-origin-only |
静态 HTML 里看不到 JS 生成的导航,必须渲染后再枚举 |
| ② | python scripts/offline_mirror.py --entry <入口> --urls-file urls.txt --out <目录> --threads 12 |
下载全部依赖 + 绝对路径改写成相对路径 |
| ③ | (②自动执行,--no-rewrite-js 可关) |
JS 里硬编码的资源 URL,扫 src= 扫不到 |
| ④ | node scripts/verify_offline.mjs --url "file:///<绝对路径>/<目录>/<host>/<path>" --out 断网.png |
CDP 拦死所有 http(s),证明零外链 |
| ⑤ | python scripts/verify.py 在线.png 断网.png --profile --heat diff.png |
把"像不像"变成数字 |
为什么是这个顺序:①不先做,②就会漏掉整片页面(实测站静态 HTML 里只有 8 个链接,
渲染后是 959 个);④不做,你无法区分"看起来对"和"真的不联网"。
环境要求
- Python 3.9+(
offline_mirror.py只用标准库;verify.py需 Pillow + numpy) - Node 18+(用了内置
fetch/WebSocket,无需 npm install) - 本机装了 Edge 或 Chrome(④①靠 CDP 驱动;也可
--exe或BROWSER_EXE环境变量指定)
从零复现(可直接粘)
SK=<本技能目录>
PY=<python>
NODE=<node>
# 1) 渲染后枚举链接
$NODE "$SK/scripts/discover_links.mjs" \
--url "https://example.com/slides/learn.html" \
--out urls.txt --wait 9000 --same-origin-only
# 2) 整站镜像(下载 + 路径改写 + JS 资源本地化 + 状态清单 + 审计报告)
$PY "$SK/scripts/offline_mirror.py" \
--entry "https://example.com/slides/learn.html" \
--urls-file urls.txt --out ./离线副本 --threads 12
# 3) 断网验收
$NODE "$SK/scripts/verify_offline.mjs" \
--url "file:///D:/离线副本/example.com/slides/learn.html" \
--out 断网.png
# 4) 还原度量化(需 Pillow + numpy)
$PY "$SK/scripts/verify.py" 在线.png 断网.png --profile --heat diff.png
关于入口:--entry 必须给真实存在的路径。若用户给的 URL 带 hash(...#lesson-9.html),
先确认该 hash 解析出的页面真实存在——很多站查不到就静默回退到首页,
你以为在复刻 A,其实复刻的是 B(本次实测就踩了:learn-223.html 根本不存在,
站点回退到 roadmap.html)。
二、六个坑(都实测踩过,不是理论)
1. JS 生成的导航,静态扫描扫不到
learn.html 的 466 节目录是 buildTree() 用 window.COURSE 渲染的,静态扫 <a href> 只拿到 8 条。
解法:CDP 渲染 → 收集 a/iframe/link/script/img/srcset → 再递归遍历运行时数据源。
递归深度要给够——课程树是 parts → topics → lessons → item 四层嵌套,
深度限制成 4 时 lessons 那层正好够不着,一个都收不到。
2. hash 路由:同页 hash 会被"去重"吃掉
全站链接都是 learn.html#roadmap.html 这种形式,pathname 完全相同。
抹掉 hash 再做 URL 去重,466 条会塌成 1 条。
解法:同页 hash 链接保留 hash 参与去重。另外补一条通用启发式——
hash 形如 #xxx.html 时,把 xxx.html 当真实路径再试一次(相对当前页目录解析)。
纯锚点 #section-2 不带扩展名,不会误报。
3. 跨域资源落在"兄弟目录",导致改写不可重复施加
本地布局是 out/<host>/<path>。跨域资源(img.alice.miyang.cn/...)成了 out/ 下的
兄弟目录,相对路径必须"爬出"主机目录:../../img.alice.miyang.cn/...。
这种路径再喂一次 urljoin 会被解析成 xueai.miyang.cn/img.alice.miyang.cn/... → 404。
后果:重跑一次,产物就坏一次(实测指纹从 0963beab 变成 aca1b09d,多出 22 个假 404)。
解法:产物是给人改的,所以重跑必须原样跳过已处理文件。工具落一份
<out>/_mirror_state.json,重跑 0.1 秒跳过 492 个文件。
副作用正好是想要的:你对副本做的手工修改,重跑不会覆盖(要强制重抓用 --force)。
4. data URI 内层的 url(%23n)
background-image:url("data:image/svg+xml,...<rect ... filter='url(%23n)'/>...")
外层 url(data:…) 被正确跳过,但正则继续往后扫,撞上内层的 url(%23n)——
于是工具跑去抓一个根本不存在的 /slides/%23n。
解法:%23 是百分号编码的 #,把它和 # 一起列入"不下载前缀"。
5. 注入浏览器的代码,反斜杠要二次转义
discover_links.mjs 的采集代码是字符串形式塞进 Runtime.evaluate 的。
写成 JS 模板字符串时,[\w\-.%] 会被吃掉反斜杠变成 [w-.%] →
运行时 SyntaxError: Range out of order in character class。
教训:往浏览器注入的代码里,每一个反斜杠都要按字符串层再转义一次。
写完必须真跑一次——静态看不出来(这次就是这么发现的)。
6. JS 里硬编码的资源 URL
bar.innerHTML = '<img src="https://img.alice.miyang.cn/xueai/qrcode.jpg" alt="QR">'
HTML 里没有这个引用,扫 src= 扫不到。
解法:下载完成后做第二遍,用"已成功镜像"的清单反查 JS/内联脚本里的绝对 URL,
命中就换成本地相对路径(实测站 1411 处 / 473 个文件)。
只处理无 query 无 fragment 的 URL——带 query 的多是运行时拼接,静态替换必错。
三、验收标准(必须两个都做)
断网验收 —— 判定 PASS/FAIL
verify_offline.mjs 用 CDP 把 http://* / https://* 全部拦死,只放行 file://。
这比"拔网线"更严格也更好用:不依赖当前机器网络状态,结果可复现,且每个漏网的资源
都会被打上 URL + 类型。
判定 PASS ← 零外链,完全离线
还原度量化 —— 判定 良好/可接受
| 指标 | 良好 | 可接受 |
|---|---|---|
| 平均绝对差 | < 6 | < 12 |
| 中位差 | < 2 | < 5 |
| >32 像素占比 | < 3% | < 8% |
> 对比前必须让两边处于同一状态。 在线页常默认开着登录面板 / AI 侧栏,会挤压正文宽度。
> 实测:直接比得到 18.27 的假差异;用 --block alice 把在线页的侧栏请求也拦掉后降到 0.57。
> 用 --block <关键词> 可拦任意一类 URL,--allow-net 进入"在线对照模式"(不做离线判定)。
残余差异怎么归因:本次留下的只有三个小方块,全部是数据来源差异、不是复刻错误——
顶栏进度计数(在线走 /auth/progress 同步,离线只有 localStorage)、水印编号、二维码区。
--heat diff.png 出热力图,一眼看出差异是"结构错"还是"数据不同"。
四、不能自动化的部分(要如实告诉使用者)
| 类别 | 例子 | 处理 |
|---|---|---|
| 服务端功能 | 登录、全文搜索、进度云同步、AI 侧栏 | 离线不可用;页面有兜底,一般不崩 |
| 运行时拼接的远程 URL | img.src='https://cdn/badge?style=1&dark='+(dark?'true':'false') |
工具会报告出来,人工一行改掉即可 |
| 跨域 iframe 的脚本访问 | file:// 下 origin 是 null,frame.contentDocument 拿不到 |
页面照常显示,只是外壳脚本改不了 iframe 内部 |
最后一条的解法:要恢复同源能力,用本地 HTTP 服务打开即可——
python -m http.server 8080 --directory 离线副本
# 访问 http://127.0.0.1:8080/<host>/<path>
五、为什么产物是"可编辑"的
- 不打包、不混淆、不反编译 —— 输出就是原站源码,HTML/CSS/JS 原样保留。
- 按 URL 目录结构落盘 ——
<out>/<域名>/<路径>,一眼知道每个文件对应哪个页面。 - 重跑不覆盖修改 ——
_mirror_state.json记录已处理清单(见坑 3)。 - 改动有据可查 —— 工具改写(相对路径化、CDN 本地化)与手工修改混在一起,但
_report.json记录每一处失败及其来源文件,能溯源到"哪个文件哪条引用"。
六、工具清单
| 文件 | 作用 |
|---|---|
scripts/discover_links.mjs |
渲染后枚举同源链接;--eval 可跑任意通用探针;--json 出结构化结果 |
scripts/offline_mirror.py |
整站镜像:下载、路径改写、JS 资源本地化、状态清单、审计报告(纯标准库) |
scripts/verify_offline.mjs |
断网验收:CDP 拦死 http(s),报告外链 / JS 异常 / 控制台错误 / 截图 |
scripts/verify.py |
逐像素还原度比对(需 Pillow + numpy);--profile --heat --crop --dy-scan |
关键参数速查
# discover_links.mjs
--url <URL> --out <文件> --wait <ms> --same-origin-only
--eval "<JS表达式>" # 通用探针,输出 JSON,用来摸清页面结构
--exe <浏览器路径> # 找不到浏览器时指定
# offline_mirror.py
--entry <URL>(可多次) --urls-file <清单> --out <目录>
--threads 12 # 并发
--force # 忽略状态清单,强制重抓(会覆盖手工修改!)
--no-cross # 不下载跨域资源
--no-rewrite-js # 关闭 JS 资源本地化
--follow-pages # 顺带抓同源 <a href> 页面
# verify_offline.mjs
--url <file:///...> --out <png> --w 1600 --h 1000 --wait 9000
--eval "<JS>" # 截图前注入(如关弹窗),配 --post-delay 2500 等动画走完
--allow-net # 在线对照模式
--block <关键词> # 额外拦掉匹配的 URL,做可比对照
七、交付物清单(给用户看什么)
- 副本入口页
<out>/_打开这里.html(双击即开) - 断网验收截图 + 判定(PASS/FAIL)
- 还原度数字(平均差 / 中位差 / >32 占比)+ 热力图
- 工具会写
<out>/_report.json:成功/失败、外链残留(区分资源型与内容型)、
每个失败的来源文件
版本历史
- v1.0.0 (2026-09-27) — 首次发布。从
xueai.miyang.cn实测中提炼:五步流程、
六个坑(JS 导航 / hash 去重 / 跨域兄弟目录导致不可幂等 / data URI 内层 url() /
注入代码反斜杠 / JS 硬编码资源 URL)、断网 + 逐像素双层验收、可编辑性保障。