
“网站克隆”这个词听起来有点工作流拉满的意思但它本质上要做的事是把一个公开网站、博客、文档站或线上页面的静态资源完整拉取到本地让页面离线也能打开。它适合三种人做网站备份的维护者、想离线阅读文档的技术人、需要把线上页面保存下来做本地审阅的测试人员。我用过不少抓取和镜像工具最后沉淀下来的经验是稳定模板不是靠一条命令堆出来的而是靠输入清单、目录结构、日志和校验规则四件事撑起来的。这篇就按完整工作流模板的方式把我自己的做法拆开讲。先说最关键的判断网站克隆能不能成功不取决于下载速度多快而取决于三件事。第一目标站点是否允许程序化抓取是否要账号登录、有没有反爬限制第二资源 URL 是否完整可解析第三下载下来的 HTML 是否把 CSS、JS、图片、字体这些依赖都改成了本地相对路径。只要这三条理顺后面就是体力活。1. 先想清楚你要克隆的是“信息”还是“那个怕坏的旧站”很多人看到网站克隆模板第一反应是直接整站下载。但实际工作流里第一步不是下载而是确认目标。不同目标对应不同抓取策略策略错了后面全被资源结构和路径问题拖住。1.1 三种常见场景静态镜像、资源备份、渲染归档静态镜像适合内容型博客、文档站、企业官网。这类站点大部分内容是服务器端渲染完成的页面源码里就有完整内容和资源引用。用 wget 或 HTTrack 就能搞定产物体积小打开速度快。资源备份适合项目团队维护的旧站点。这里的重点不是把页面变成可浏览的目录而是把源文件附件、图片、字体、压缩包完整存档。要按文件类型分别归档而不是按页面归档。截图、图片目录、上传附件目录都要单独拉取。渲染归档适合单页应用、Vue/React 构建的文档站。源码下载下来可能只有一堆空壳HTML、CSS、JS 虽然都在但内容要靠浏览器执行 JS 才会出现。这时候必须用无头浏览器渲染完再保存或者抓取预渲染后的 HTML。三种场景的差异决定工作流模板里哪个阶段更重要。静态镜像重点看链接转换资源备份重点看磁盘结构和去重渲染归档重点看浏览器执行时间和输出完整性。1.2 哪些站不能碰也不能抄网站克隆类工具使用范围应当严格限定在公开信息页、自建站点、自己拥有权限的测试环境。不能把带有登录态的页面变成离线副本尤其是电商后台、个人中心、内部文档、管理后台。抓取这些页面往往会带出个人信息和访问控制信息这不是技术问题是权限边界问题。需要认证的内容站点也不建议做整站克隆。哪怕你有合法账号离线复制也会绕过站点的访问控制和时效控制这不属于合规的开发测试行为。另外明确声明不允许抓取的站点、有明确版权限制的作品集、未授权的商业模板都不要进工作流。工作流模板真正要解决的是公开信息的高效镜像不是钻空子。1.3 先确认目标站的公开性和抓取边界启动任何抓取任务之前至少花两分钟确认三件事。一是 robots.txt 规则。在浏览器里直接访问目标域名下的 /robots.txt看有没有禁止抓取目录。文档站的 static、assets、uploads 通常允许抓取后台和用户目录通常禁止。二是页面是否要求登录态。可以用无痕窗口访问几个深层页面。如果页面跳转到登录页或者显示 403、401说明内容不是完全公开的不要尝试绕过也不要加 Cookie 之后强行拉取。三是资源是否跨域。很多站点的图片、字体存在 CDN 域名HTML 页面本身能访问但直接按全站抓取可能会漏掉 CDN 上的文件。这个后期会造成页面图片全部丢失需要在 URL 过滤规则里预先把 CDN 域名加进白名单。注意这里不是鼓励绕过访问控制的“技术操作”而是通过确认公开边界来选对抓取策略。没有权限的站点直接停手就是最稳的做法。2. 把工作流模板拆成六个阶段一个完整工作流不是一句“把网站下载到本地”就完了。我把它拆成六个阶段每个阶段都有独立任务和验收标准。2.1 输入清单URL 的来源怎么写第一件事是确定种子 URL。种子 URL 是抓取器开始扫描的入口。输入清单可以是一个文本文件每行一个 URL例如https://example.com/ https://example.com/docs/ https://example.com/blog/也可以直接指定域名让工具自动沿着站内链接往下追。但自动追踪容易出现两类问题一是抓到本不该抓的标签页、排序页、API 返回页二是遇到类型繁多的外链时抓取范围被带偏。更稳的方式是维护一个最小 URL 清单。用脚本读取这个清单再给抓取工具设置域名白名单只允许在 example.com 站内抓取外部域名只下载资源文件不继续递归。做批量镜像时我习惯把 URL 清单放在单独目录和脚本、输出产物分开。这样后续增量更新可以直接对比清单不用每次全量抓一遍。2.2 任务执行抓取、下载、链接改写这一阶段是核心执行。抓取工具会递归发现页面里的新链接资源下载器负责把图片、CSS、JS、字体保存到本地链接改写则把页面里所有绝对 URL 变成相对路径。链接改写很关键。比如页面里有/assets/style.css下载到本地后必须改成assets/style.css或../assets/style.css。不改写的话离线打开只有 HTML样式全是 404。wget 的--convert-links参数会做这件事HTTrack 也会做。关键是在完成所有下载之后再改否则下载过程中后续页面拿到的链接还是原始的绝对路径可能导致漏抓。2.3 校验阶段输出是不是能离线打开很多模板不校验导致抓完一堆文件却打不开。校验阶段的验收标准很简单断网状态下从本地入口页面开始能不能连续打开 5 到 10 个页面且样式、图片、点击跳转都正常。实际检查时可以这样做看生成的 index.html 体积是否正常。如果只有几 KB大概率是 JS 渲染的空壳或抓取失败。看 CSS、JS 文件数量是否和源站大致符合。随机打开 3 到 5 个页面按 F12 的 Network 标签看有没有本地资源 404。这个阶段的发现通常会把问题指向上一阶段链接改写没生效、CDN 资源没拉下来、目录层级太深导致相对路径算错。2.4 日志与失败重试抓取过程一定会遇到失败可能是网络抖动、服务端限流、超时。所以工作流模板里必须有日志。日志至少要包含三层内容成功下载的 URL 列表、失败的 URL 和失败原因、重试后的结果。用 wget 时可以输出 wget 日志文件并让脚本最后统计失败数量。失败重试要单独设计。不要对失败的 URL 直接重新全量跑而是把失败 URL 过滤出来单独生成一个 retry.txt用较少的并发和更长的超时再跑一轮。如果同一批 URL 连续重试两次仍然失败就停止重试记录到 failed.log。这些 URL 可能是已经删除的页面、动态接口生成的页面也可能是权限受限的地址手动抽查确认后再决定下一步。2.5 产物命名和归档默认情况下抓取工具会把 URL 路径转成目录结构。比如/docs/2024/report.html会变成docs/2024/report.html。这个结构合理适合直接浏览。但要注意 URL 末尾没有扩展名的情况。很多 CDN 资源地址是/upload/file?idxxx没有文件名后缀。下载到本地后可能是乱名文件浏览器无法识别。需要在规则里为这些 URL 补齐扩展名或改成固定命名。归档时建议打一个带日期的压缩包例如archive-2024-12-12.tar.gz。保留至少最近两份快照后续增量更新会依赖你的“上一份完整产物”。2.6 定时与增量更新网站内容会变化克隆也要更新。定时任务不需要每次全量下载可以只抓新增和变化页面。wget 有一个-N参数通过对比远程文件和本地文件的时间戳来决定是否重新下载。配合定时脚本可以在固定时间执行增量同步。还可以在输入清单文件里维护“上次成功处理到哪一页”的标记。脚本每次启动时读取标记处理完一批后更新标记。这样中途断掉也不会从头再来。3. 环境准备与工具选型3.1 我为什么把 wget 作为默认主力wget 长于命令行、可脚本化、支持递归抓取、支持链接转换、资源占用小。对大多数公开博客和文档站它就是最合适的工具。wget 的几个关键行为要提前知道默认不会抓取 CSS 里带出的图片需要额外处理。递归深度、等待时间、重试次数都要手动指定。域名过滤通过-A接受列表和-R拒绝列表控制。日志输出建议用-o保存不要只打印到屏幕。3.2 HTTrack 适合什么场景HTTrack 是图形化网站镜像工具单机使用很方便。它会把整个站点镜像到本地自动生成索引页面适合不熟悉命令行的新手和快速搭建离线镜像。但它有两个问题。一是多项目、多批次的自动化能力弱想要把它纳入定时任务会比较费劲。二是遇到复杂 JS 站点同样无能为力渲染后的内容仍需无头浏览器方案。所以我的建议是学习、快速备份用 HTTrack正经做工作流模板、要做增量更新和日志控制用 wget遇到单页应用则再加 Playwright。3.3 需要安装的基础依赖基础环境推荐在 Linux 或 macOS 下执行Windows 环境用 WSL 最方便。需要准备的工具和系统库大致有这些用途工具/依赖安装方式示例下载镜像wgetLinux 自带或apt install wget图形化镜像httrackapt install httrack链接检查lynx / python3按系统包管理器安装HTML 解析BeautifulSouppip install beautifulsoup4渲染 JSPlaywrightpip install playwright安装依赖时要注意强烈建议用虚拟环境装 Python 包避免把系统 Python 环境改坏。Playwright 还需要单独下载浏览器内核这一步体积较大但一次性完成。4. 可落地的目录模板和脚本骨架4.1 目录结构我第一次做整站克隆时把脚本、临时文件、输出产物全放在一个目录里结果归档时一团乱。后来形成了固定结构site-clone/ ├── config/ │ └── url-list.txt # 种子 URL 清单 ├── scripts/ │ ├── crawl.sh # 抓取主脚本 │ ├── check.sh # 输出校验脚本 │ └── retry.sh # 失败重试脚本 ├── logs/ │ ├── crawl.log │ └── failed.log ├── tmp/ │ └── retry-list.txt # 临时重试清单 └── output/ └── example.com/ # 镜像产物根目录 ├── index.html └── assets/config 只放输入配置scripts 只放可执行脚本logs 放日志tmp 放中间文件output 放最终产物。这样一来清理临时文件不会误删产物查看日志也不会被下载输出刷屏。4.2 一条最小可用的 wget 镜像命令以下是镜像一个公开静态站点的常用命令模板参数解释放在注释之后wget \ --mirror \ --convert-links \ --adjust-extension \ --page-requisites \ --no-parent \ --wait2 \ --random-wait \ --limit-rate2m \ --user-agentMozilla/5.0 (compatible; SiteMirrorBot/1.0) \ --directory-prefixoutput \ -e robotson \ -o logs/crawl.log \ -i config/url-list.txt参数解读--mirror开启递归镜像模式等同于递归加时间戳选项。--convert-links把所有 HTML 里的链接转换为本地相对路径。--adjust-extension为没有扩展名的 HTML 文件补上.html。--page-requisites下载页面所需的所有资源包括样式、图片、脚本。--no-parent禁止向更上级目录爬取防止逃出目标域名。--wait2和--random-wait控制请求间隔避免给目标服务器造成压力。--limit-rate限制下载速度对后台备份友好。-e robotson遵守 robots 协议这一项默认开启但要确认没被覆盖。注意这个命令只适合服务器端渲染的公开页面。JS 渲染的站点wget 拿到的问题不大但主页往往是空壳。4.3 生成 URL 清单并用循环处理如果一次要镜像多个栏目建议先自动生成 URL 清单。比如某个文档站的主页里有 100 个文章链接可以先用脚本抽出这些链接再喂给 wget。from bs4 import BeautifulSoup import requests url https://example.com/blog/ html requests.get(url, timeout10).text soup BeautifulSoup(html, html.parser) links set() for a in soup.select(a[href^/blog/]): href a.get(href) if href and href.endswith(.html): links.add(https://example.com href) with open(config/url-list.txt, w, encodingutf-8) as f: f.write(\n.join(sorted(links))) print(f共生成 {len(links)} 个 URL)这段代码的作用是先把文章列表页面里的链接提取成清单再让 wget 按清单抓取。好处是抓取范围可控不会误抓到分类页、标签页和作者页。这里有个细节写脚本时要注意编码。很多中文博客页面是 UTF-8但如果源站是 GBK直接用 requests.text 可能乱码。建议用response.encoding response.apparent_encoding再做解析。4.4 用 Python 做二次链接检查和资源补齐wget 抓完一轮后还要做一次二次检查。因为 wget 的页面必需资源下载对某些动态路径支持不佳可能出现“HTML 下载成功CSS 里的图片全部 404”的情况。可以写一个简单脚本扫描本地 HTML找出所有仍然指向远程地址的资源。import re from pathlib import Path root Path(output/example.com) remote_pattern re.compile(r(?:src|href)(https?://[^])) for html_file in root.rglob(*.html): try: text html_file.read_text(encodingutf-8) except UnicodeDecodeError: text html_file.read_text(encodinggbk, errorsignore) external remote_pattern.findall(text) if external: print(html_file, external[:5])这种脚本的价值在于把“不完整”变成可量化的状态。输出为空说明本地资源引用已经全部改写可以继续归档输出有内容就要回到抓取阶段补资源。5. 输出验证正确结果长什么样5.1 文件数量和体积预期抓完以后不要急着打开页面先看产物整体数量。静态博客镜像通常会在几百到几千个文件之间体积从 10MB 到 200MB 不等。纯文档站通常更轻大量图片或字体文件的站点可能到 GB 级。判断文件数是否合理可以和源站做个粗对比。比如线上新闻列表有 500 篇文章本地产物里 article 目录下的 HTML 至少应该有接近 500 个。如果只抓到一个首页说明递归没生效或 URL 清单不全。5.2 检查坏链和空白页坏链检查用脚本最方便。可以扫描每个 HTML 里引用的本地图片、CSS、JS看文件是否存在。find output/example.com -name *.html -exec grep -oE (src|href)[^] {} \; \ | sed s/.*//;s/$// \ | grep -v ^http \ | while read -r path; do if [ ! -f output/example.com/$path ]; then echo 缺失: $path fi done这个检查能发现三类问题相对路径算错了、资源根本没下载、HTML 里有动态拼接 URL 导致无法静态扫描。空白页检查主要针对渲染类站点。可以检查文件体积HTML 小于 1KB 的页面大概率是空壳或跳转页。重点抽查这些页面判断是本来就少内容还是抓取失败了。5.3 用无头浏览器渲染抽查 JS 站点如果目标站点是单页应用纯静态检查远远不够。要用无头浏览器打开本地文件看页面能不能正常渲染。from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(file:///absolute/path/output/example.com/index.html, wait_untilnetworkidle) print(page.title()) print(page.content()[:500]) browser.close()这个步骤基本上能把 JS 站点的渲染问题暴露出来。如果本地打开后页面空白但线上正常就要检查相对路径、API 请求、跨域资源读取。6. JS 站点的进阶处理方式6.1 为什么纯 wget 拿到的不是你要的纯 wget 拿到的是服务器返回的原始 HTML。对于传统模板渲染的站点这个 HTML 就是完整内容对于现代单页应用这个 HTML 只是一个带脚本的壳。一个很典型的 Vue 文档站首页源码里通常只有div idapp/div和一段 JS。wget 下载后离线打开会看到一个空白页面因为内容需要浏览器执行 JS 后才生成。这类站点不能走纯 wget 路线要引入浏览器内核做渲染。6.2 用 Playwright 渲染和保存页面Playwright 可以模拟真实浏览器打开页面等网络请求完成后再保存渲染后的 HTML。脚本思路大致如下from playwright.sync_api import sync_playwright from pathlib import Path url https://example.com/docs/page.html out_path Path(output/example.com/docs/page.html) with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page(viewport{width: 1280, height: 800}) page.goto(url, wait_untilnetworkidle, timeout60000) html page.content() out_path.parent.mkdir(parentsTrue, exist_okTrue) out_path.write_text(html, encodingutf-8) browser.close()这里的要点是wait_untilnetworkidle等待网络空闲后再保存。做归档前最好先跑两三个页面确认阅读进度条、弹窗、懒加载图片是否影响核心内容。如果内容在滚动时加载还要加上滚动动作让页面触发懒加载for _ in range(5): page.mouse.wheel(0, 800) page.wait_for_timeout(500)这种方法对文档站、博客中的图表展示、视频封面这类懒加载内容很有效但不适合对海量页面做全量渲染因为太慢。6.3 处理懒加载和动态数据懒加载是 JS 站点最常见的坑。图片、评论、文章列表可能都要滚动到底部才会加载。如果直接保存后续内容全是空白。建议在渲染脚本里加入统一的“滚动加载”逻辑并在保存前判断关键区域是否出现。比如页面里有一个.article-content元素就等它出现后再截图或保存。动态数据的另一个问题是接口返回。很多前端站点不直接把内容写进 HTML而是通过 API 拿数据。本地离线打开时如果页面里的 JS 还要请求远程 API同时 API 又限制跨域或需要签名就会出现“页面结构正常数据区域空白”的情况。这种情况很难完全离线保存。通常的做法是保存渲染后的静态快照不再追求交互功能。也就是只保存内容不保存接口调用逻辑。7. 常见失败、规避方法与边界7.1 权限、路径、编码和服务端限流工作流跑多了就会发现多数失败不是因为工具不会用而是因为环境细节没处理好。第一是权限问题。输出目录不可写、脚本没有执行权限、临时目录空间不足都会让任务在前 5 分钟内失败。启动前先执行df -h看磁盘ls -l看目录权限。第二是路径问题。Windows 下写相对路径时反斜杠和正斜杠混用会直接导致文件找不到。建议统一用正斜杠或者用 Python 的Path模块做路径拼接。第三是编码问题。网页编码不统一时HTML 下载下来会有乱码。处理方法是抓取时让 wget 记录源站编码解析脚本里同时兼容 utf-8 和 gbk。第四是服务端限流。并发拉取太快会被服务器临时封禁。解决方式是控制请求间隔随机等待不要用固定间隔。7.2 动态生成文件名和哈希目录问题有些站点用内容指纹做文件夹比如 CSS/JS 文件在assets/posts/2024/12/xxxx-12345/main.js。这类路径在递归抓取时通常没问题但链接转换后可能出现大量../..相对路径一旦目录层级太深部分浏览器对相对路径层级会产生解析问题。处理方法有两个。一是抓取时限制递归深度让产物目录保持扁平。二是下载完成后用脚本把所有资源文件重命名为扁平结构并同步更新 HTML 里的引用。扁平化结构更适合把整个站点打包成 zip 分享或备份缺点是源站 URL 和本地文件对应关系会变模糊。归档时必须保留一份“原始 URL 到本地路径”的映射表。7.3 什么时候不要继续做全站克隆有些情况不应该继续做。比如站点内容更新频率特别高、页面爬取 500 错误率超过 30%、目标站有验证码或明显反爬拦截这时候继续加并发、加重试不仅浪费时间还可能加剧对目标站的干扰。需要登录才能访问的站点不要尝试用 Cookie 绕过。受版权保护的电子书、视频课程、付费文档不要做离线复制。对外不得分发抓取产物。技术工作流应该把这些边界写进流程里而不是依赖个人自觉。如果线下确实需要保存某个受保护或复杂的站内数据正确做法是走站点导出接口、联系站点管理员获取权限或者只在本地开发环境用授权数据集测试。注意整个工作流模板的长久可用性靠的是对抓取边界、robots 规则和输出验证始终保持敏感。这是一套工程习惯不是单纯几条命令的技巧。7.4 归档、清理和下次更新镜像完成后先归档。归档时不要把整个 output 目录直接 copy 成两份那样太占空间。用压缩包保留带日期的快照并建立软链接指向“最新版”。清理时只清理 tmp 和 logs 中的旧日志不要清理 output 里的产物。把每次失败的 URL 留存在 logs/failed.log方便下次增量更新时手动判断。到这里一个完整工作流模板的核心内容就拆完了。从输入清单、环境准备、抓取执行、二次检查、JS 渲染到归档和增量更新每一环都可以独立运行也能串成一条链。自己做的时候不要一开始就把所有功能都铺满先把 wget 单条命令跑通再慢慢补上校验脚本和渲染逻辑。能用小站点验证整套流程再拿到目标站点上跑会省掉很多无意义的排错时间。