☰
网站整站下载器实战:wget镜像、链接本地化与增量校验
2026/10/25 23:14:53 网站建设 项目流程

简介:面向需要离线备份、迁移或研究站点结构的开发者与运维人员,这套网站整站下载器用于抓取并保存整站资源,适合对ASP、JSP、PHP等常见站点做批量页面与素材归档,减少逐页手工保存的成本。压缩包体积约540KB,轻量易传输;上游未提供文件总数及文件类型明细,具体构成以实际包内为准,下载后可先核对目录与说明再使用。资源页显示已有2187人学习或下载,具备一定参考热度。其价值在于提供整站抓取思路与现成工具入口,便于在研究页面层级、备份静态素材或搭建离线镜像时快速入手,也可结合附加数据码与评论反馈判断适用性;对需要批量收集页面、图片等资源的场景较友好,初学者亦可借此了解站点资源组织方式,但不建议用于未授权站点。

1. 整站下载器搬走的是三样东西:文档、资源、引用关系

搜「网站整站下载器」的人,脑子里大多有一个具体画面:地址粘进去,进度条跑完,本地双击 index.html 就能点着看。真跑一遍往往不是这样——样式没了,图片裂一半,点第二层链接又跳回线上。问题不在工具,而在于整站下载要同时完成三件事:把 HTML 文档拉下来、把文档引用的静态资源补齐、把资源之间的引用关系改成本地能解析的路径。前两件决定存不存得下,第三件决定跑不跑得起来,而第三件恰恰是一键式工具最容易糊弄过去的地方。这份资源适合改版前要给老站点留可浏览快照的前端、做内容盘点和迁移的运维、需要离线查看站点结构的从业者。前提只有一个:自有站点,或已拿到明确授权的站点。

2. 抓取边界怎么划:入口 URL、深度、域名白名单

递归抓取的本质是「从入口出发,沿着 href 和 src 一直走」。走多远、走不走外链、哪些目录不进去,全在参数里。边界没划清,轻则抓回一堆重复页面把磁盘吃满,重则把整个站点的日历页、搜索结果页、打印页全部展开,URL 数量指数级膨胀。这一章把边界拆成三件事:站点结构判断、命令行参数、URL 池构建。

2.1 先把站点分成三种结构,再决定抓多深

同样是「整站」,结构差别极大,抓法完全不同。

目录型站点最友好,URL 形如/docs/guide/install.html,层级就是物理目录,-l 3基本能覆盖主干。参数型站点最麻烦,URL 形如/list.php?cat=3&page=2,递归会顺着分页和筛选条件无限展开,-l设多少都不够,只能靠 URL 池精确列举。单页应用型的递归深度参数几乎失效,因为路由在#后面,服务端永远只返回同一个index.html,抓一千次也是一份。

结构类型识别特征建议深度常见翻车点
目录型路径分层、无?参数-l 4到-l 6附件目录被一起拖走
参数型大量?id=、&page=不用递归,改 URL 池分页组合爆炸
单页应用路由带#、首屏 HTML 几乎为空递归无效,需预渲染抓回来全是空壳
混合型主站静态、列表页走接口静态部分递归 + 接口直取列表内容整块丢失

判断方法很土但有效:随便点开三层页面,看地址栏有没有?,再右键查看源码,搜索页面上肉眼可见的一段文字,搜得到就是服务端渲染,搜不到就是客户端渲染。这一步花两分钟,能省掉后面两小时的重抓。

另一个常被忽略的边界是文件类型。整站快照要的是「能看」,不是「能跑业务」,后台接口、上传目录、日志、备份包都不该进来。常见做法是在--reject里按扩展名挡掉zip|rar|sql|log|bak|exe,同时用--exclude-directories挡掉/admin、/api、/search、/tmp这类路径。

2.2 一组可抄的 wget 参数与逐项解释

命令行工具里wget的镜像能力最完整,参数组合也比较透明。下面这组是我反复用下来比较平衡的一份,改三处就能直接跑:域名、排除目录、输出目录。

# 整站镜像:递归 + 页面必需资源 + 链接本地化 wget \ --mirror \ --page-requisites \ --convert-links \ --adjust-extension \ --no-parent \ --no-host-directories \ --restrict-file-names=windows \ --domains=example.com \ --exclude-directories=/admin,/api,/search \ --reject 'zip,rar,sql,log,bak,exe,iso' \ --wait=1 --random-wait --limit-rate=800k \ --tries=3 --timeout=20 \ --user-agent="Mozilla/5.0 (compatible; SiteArchiver/1.0)" \ --directory-prefix=./mirror \ "https://example.com/"

参数说明,重点是后四个:

  • --mirror是组合开关,等价于-r -N -l inf --no-remove-listing,即无限深度递归加时间戳比对。-l inf意味着深度不受限,所以必须靠--exclude-directories和--reject兜底,否则参数型站点会一直爬下去。
  • --page-requisites负责把 HTML 引用的 CSS、JS、图片一起拉下来。它只认 HTML 里写死的引用,CSS 里@import再嵌套的资源可能漏,后面 3.3 的脚本会补一次扫描。
  • --convert-links做链接本地化,把https://example.com/a.html改成a.html。注意它在抓取全部结束后统一处理,中途中断不会改写,所以别指望抓一半就能打开看。
  • --adjust-extension给没有扩展名的 URL 补.html。少了这个参数,/about/这种地址存成无后缀文件,双击会被系统当成未知格式,而不是用浏览器打开。
  • --no-parent限制不往上级目录跑,--no-host-directories去掉最外层的example.com/目录壳,本地打开路径更短。
  • --restrict-file-names=windows把: * ? " < > |这类在 Windows 上非法的字符替换掉。跨平台交付快照时这个参数不加,解压那一刻就一堆报错。
  • --wait=1 --random-wait让每次请求间隔 0.5 到 1.5 秒随机,--limit-rate=800k限制总带宽。这两个参数决定了对面站点会不会把你当成压力源。

注意:不要用-e robots=off去绕过站点在 robots.txt 里声明的目录。那些目录本来就不该进快照,绕过只是把风险转移给自己。

抓取前先做一次干跑,确认边界对不对:

# 只打印不下载,用来核对 URL 池规模 wget --spider --force-html -i urls.txt -r -l 1 --domains=example.com 2>&1 | \ grep -E "^--" | wc -l

--spider只发 HEAD 请求不发正文,-i urls.txt从文件读取入口,输出行数大致等于首层可发现的 URL 数量。上千条就说明边界太松,得回去收紧--exclude-directories。

2.3 用 sitemap 和列表页反推 URL 池

参数型站点和内容站,靠递归不如靠清单。站点地图通常放在/sitemap.xml,有的是索引文件套一层子地图,写法不一样但结构固定。

import re import urllib.request SEED = "https://example.com/sitemap.xml" UA = {"User-Agent": "Mozilla/5.0 (compatible; SiteArchiver/1.0)"} def fetch(url): req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=20) as r: return r.read().decode("utf-8", "ignore") def parse(xml): # 索引型 sitemap 返回子地图地址 maps = re.findall(r"<sitemap>.*?<loc>(.*?)</loc>", xml, re.S) if maps: return "map", [m.strip() for m in maps] urls = re.findall(r"<url>.*?<loc>(.*?)</loc>", xml, re.S) return "url", [u.strip() for u in urls] kind, items = parse(fetch(SEED)) if kind == "map": pool = [] for m in items: pool += parse(fetch(m))[1] else: pool = items # 只保留同域、去重、去掉明显的附件 pool = [u for u in pool if u.startswith("https://example.com/")] pool = [u for u in dict.fromkeys(pool) if not re.search(r"\.(zip|pdf|rar)$", u)] open("urls.txt", "w").write("\n".join(pool)) print(len(pool), "urls written")

逻辑说明:先读入口 XML,判断是索引型还是直列型,索引型就再往下钻一层;然后按域名前缀过滤,用dict.fromkeys保序去重,最后落成一行一个的urls.txt。

参数调整点有三个:SEED换成目标站点的地图地址;附件过滤正则按站点实际情况增删,如果站点的 PDF 是核心内容就别挡;超时timeout=20在网络差的环境调到 40 更合适。

拿到清单之后用wget -i批量取,配合-p补资源:

wget -i urls.txt \ --page-requisites --convert-links --adjust-extension \ --no-host-directories --restrict-file-names=windows \ --wait=1 --random-wait --limit-rate=800k \ --directory-prefix=./mirror

地图的局限要说清楚:它只列规范 URL,不含分页、标签页、筛选结果。列表页的分页规则得单独用正则补齐,比如page=\d+,抓前两页看规律,再写成循环生成,别硬递归。

2.4 限速、重试与请求头

抓取规模一旦上百页,对面站点是否配合、连接是否稳定,就直接决定成败。三个参数值得单独调:

--wait和--random-wait控制请求间隔。固定间隔容易形成规律特征,随机化后更像人类访问。--limit-rate限制总带宽,多人共用一个出口时尤其要加,不然你自己的其他工作也会被拖慢。

--tries=3配合--waitretry=5处理偶发超时,超过三次就跳过并在日志里留痕。别设太高,重试堆积比失败更麻烦。

--user-agent建议带一个能识别用途的标识,比伪装成浏览器更体面,也方便站点管理员在日志里认出你、放行你。真遇到 403,先去看是不是 CDN 按 UA 拦了,别急着换 UA 硬刷。

3. 落地成本地站点:链接改写、路径映射与编码

抓回来一堆文件,和「双击能看」之间还差一步:让每个引用都能在本地文件系统里找到对应文件。这一步最容易出错,因为它涉及四类链接形态、两类编码问题和一个文件名长度上限。

3.1 四种链接形态与各自的改写规则

链接形态例子本地化目标注意
绝对 URLhttps://example.com/a/b.htmla/b.html域名带端口时容易漏判
根相对/static/x.css../static/x.css相对深度取决于当前文件层级
文档相对./img/a.png保持不动目录结构没乱就不用改
协议相对//cdn.example.com/x.js需单独抓取不加域名判断会被跳过
带查询串/list.php?id=3list.php@id=3.html直接存会互相覆盖
哈希路由/#/about无法静态化必须预渲染,见第 4 章

--convert-links能处理前三类,第四类和第五类是漏网常客。CDN 上的 JS、字体、图标,因为域名不同被--domains挡在外面,页面结构在、样式全丢。补救办法是把 CDN 域名加进白名单,例如--domains=example.com,cdn.example.com,再跑一轮只抓资源的增量任务。

带查询串的 URL 是覆盖重灾区。/list.php?id=3和id=4在--restrict-file-names处理后可能落到同一个文件名上,后抓的把先抓的盖掉,最后只剩一页。稳妥做法是让工具保留查询串,或者干脆自己写映射表:把每个 URL 算一个短哈希,存成资源目录/哈希前两位/哈希.html,同时生成manifest.csv记录原始 URL 和本地路径的对应关系。多花十分钟,排查时能救命。

3.2 文件名冲突、超长名与 URL 编码

三个隐蔽问题,出了都很难一眼定位。

第一是大小写。Linux 上Logo.PNG和logo.png是两个文件,Windows 和 macOS 默认是同一个。抓取机是 Linux、查看机是 Windows 时,解压会提示「是否覆盖」,覆盖完就有一半图片引用失效。解决方法是在抓取阶段统一转小写,或者干脆在 Linux 上用本地静态服务器查看,别解压到 Windows。

第二是 URL 编码。中文路径在 URL 里是百分号编码,--restrict-file-names的不同档位会解码成不同结果。用windows档能保证跨平台,但中文会变成%E4%B8%AD这种形式,可读性差;用nocontrol档保留原始编码,可读性好但换平台可能出问题。我一般选windows,因为交付对象往往不是本机。

第三是文件名长度。部分站点把标题直接拼进 URL,中文标题转码后一个字符变九个字节,很容易突破 255 字节上限,工具会报File name too long然后跳过。这种情况只能靠后处理,把超长名截断并加哈希后缀,同时在 manifest 里回填映射。

提示:抓取前先在目标目录跑一次df -h,整站快照的体积往往是最初估计的三到五倍,图片站和视频站尤其夸张。

3.3 后处理脚本:扫缺失引用、出报告

工具跑完不等于结束。写个脚本把 HTML 里的引用全部抽出来,逐个检查本地文件是否存在,缺失的记下来,这份报告就是后面补抓的工单。

import os import re import csv from urllib.parse import urlparse, unquote ROOT = "./mirror" REF = re.compile(r'(?:src|href)\s*=\s*["\']([^"\'#]+)["\']', re.I) missing, checked = [], 0 for dirpath, _, files in os.walk(ROOT): for f in files: if not f.endswith((".html", ".htm")): continue page = os.path.join(dirpath, f) try: html = open(page, encoding="utf-8", errors="ignore").read() except OSError: continue for ref in REF.findall(html): # 跳过外链、锚点、data URI if ref.startswith(("http://", "https://", "//", "data:", "mailto:")): continue target = unquote(urlparse(ref).path).lstrip("/") if not target: continue checked += 1 # 相对引用按当前页面目录解析 p = os.path.normpath(os.path.join(dirpath, target)) if not os.path.exists(p): missing.append([page, ref, p]) with open("missing.csv", "w", newline="", encoding="utf-8-sig") as fp: w = csv.writer(fp) w.writerow(["page", "ref", "resolved"]) w.writerows(missing) print(f"checked={checked} missing={len(missing)}")

逻辑说明:遍历所有 HTML,用正则捞出src和href,过滤掉外链、锚点和 data URI,剩下的按「当前页面所在目录 + 相对路径」解析成绝对本地路径,os.path.exists判存在与否。urlparse去掉查询串,unquote还原中文,两步少一个都会产生大量假缺失。

参数调整:ROOT指向镜像目录;正则只抓src和href,srcset、poster、内联style里的url()要另加规则,直接扩成(?:src|href|poster|srcset)\s*=再写个小函数拆srcset的逗号列表。输出用utf-8-sig是为了 Excel 直接打开不乱码。

跑完看两个数:checked太少说明 HTML 文件没抓到或编码识别失败,missing里如果全是同类资源(比如整目录的字体文件),基本就是 CDN 域名被挡了,回 3.1 加白名单重抓一轮。

4. 抓不到的那部分:渲染型页面与接口数据的补抓

用wget抓单页应用,结果通常是几十个字节数相近的 HTML,打开一片空白。这不是配置错了,是内容根本不在服务端返回的 HTML 里。这一章解决「抓回来是空壳」的问题,思路是先判断、再补抓、最后合并。

4.1 三步判断页面是不是客户端渲染

不用装任何工具,三条命令就能定性:

# 1. 看原始 HTML 体积 curl -sL "https://example.com/list" | wc -c # 2. 找页面上肉眼可见的一段文字 curl -sL "https://example.com/list" | grep -c "某条真实标题" # 3. 找框架痕迹 curl -sL "https://example.com/list" | grep -oE '(id="app"|id="root"|__NUXT__|__NEXT_DATA__)' | head

体积小于 5KB、关键词计数为零、命中id="app"或id="root",三条里中两条,基本可以断定是客户端渲染。反过来,如果第 2 步命中,说明服务端已经吐出内容,wget那一套就够用,不必上无头浏览器——渲染一次的开销是纯 HTTP 请求的几十倍,能省则省。

还有一类「半渲染」站点:首屏 HTML 里有内容,滚动加载的部分没有。判断方法是把页面滚到底,看 DOM 里节点数是否显著增加。这类站点用递归抓只能拿到首屏,后面的得靠接口。

4.2 无头浏览器补抓的最小实现

确认要渲染之后,用无头浏览器逐个访问 URL 池,等网络空闲再取 DOM。下面这段是能直接跑的最小版本:

from playwright.sync_api import sync_playwright import pathlib, time URLS = [u.strip() for u in open("urls.txt") if u.strip()] OUT = pathlib.Path("./mirror") with sync_playwright() as p: browser = p.chromium.launch(headless=True) ctx = browser.new_context( user_agent="Mozilla/5.0 (compatible; SiteArchiver/1.0)", viewport={"width": 1440, "height": 900}, ) page = ctx.new_page() for url in URLS: try: page.goto(url, wait_until="networkidle", timeout=30000) # 触发懒加载:滚到底再回顶 page.evaluate("window.scrollTo(0, document.body.scrollHeight)") time.sleep(1.5) page.evaluate("window.scrollTo(0, 0)") name = url.rstrip("/").split("/")[-1] or "index" (OUT / f"{name}.html").write_text(page.content(), encoding="utf-8") except Exception as e: print("fail", url, e) browser.close()

逻辑说明:wait_until="networkidle"表示等到网络请求静默 500ms 再取内容,比load更可靠;滚动到底是为了触发IntersectionObserver类的懒加载;page.content()拿到的是渲染后的完整 DOM,不是原始响应。

参数调整:timeout在弱网下调到 60000;headless=True改成False可以肉眼观察抓取过程,调试阶段很有用;如果需要登录态,用ctx.add_cookies()注入,别把账号密码写进脚本。

这套跑法的代价要提前算清:单页平均 3 到 5 秒,五百页就是半小时到四十分钟,而且吃内存。批量跑时把浏览器实例复用,别一页开一个。另外渲染出来的 HTML 里,样式表还是线上绝对地址,想离线看,得再用第 3 章的脚本做一次本地化。

4.3 接口直取:比渲染更省的一条路

渲染是最后手段。多数「半渲染」站点的数据来自几个固定接口,直接请求 JSON 比开浏览器快一个数量级,也更省资源。

找接口的方法:打开开发者工具的 Network 面板,筛 XHR/Fetch,刷新列表页,找返回结构化数据的那几个请求。看请求 URL 的规律,通常带page、size、category这类参数,改参数就能翻页。

# 循环拉取列表接口,落成编号 JSON for i in $(seq 1 20); do curl -s "https://example.com/api/list?page=${i}&size=20" \ -H "Accept: application/json" \ -H "Referer: https://example.com/list" \ -o "./data/list_${i}.json" sleep 1 done # 合并成一份,方便后续转静态页 python -c " import json, glob rows = [] for f in sorted(glob.glob('./data/list_*.json')): d = json.load(open(f, encoding='utf-8')) rows += d.get('data', d if isinstance(d, list) else []) json.dump(rows, open('./data/all.json','w',encoding='utf-8'), ensure_ascii=False) print(len(rows)) "

逻辑说明:接口通常校验Referer,缺了会返回 403,所以带上;sleep 1保持节奏,接口限流比页面更严格。合并那一步兼容两种返回结构——有的接口是{"data": [...]},有的直接返回数组。

拿到 JSON 之后有两条路:一是保留 JSON,另写一个本地模板页读取渲染,适合数据要二次处理;二是直接转成静态 HTML,适合只做浏览归档。两条都比无头浏览器快,维护成本也低。

最后一步是把三份产物合并:wget抓的静态页、渲染页、接口数据。做法是在 manifest 里按 URL 去重,优先级定为「渲染页 > 静态页 > 接口生成页」,避免同一个 URL 出现三个版本,打开时自己都分不清哪个是准的。

5. 避坑与排查:五类翻车现场的现象、原因与解决

下面这几条都是我在实际交付快照时踩过的,每条的排查顺序是固定的:先看现象,再定位原因,最后给动作。照着顺序走,比漫无目的地改参数快得多。

5.1 抓回来一堆重复页面,磁盘被吃满

现象:镜像目录里出现大量文件名相似、内容几乎一致的文件,几百 MB 起步,du -sh一路涨。

原因:站点用查询串区分筛选和排序,?sort=asc、?sort=desc、?from=xxx排列组合,递归把它们当成不同页面全抓了;或者是分页链接里带会话参数,每次访问参数都变,被当成新 URL 反复入队。

解决:第一步在--reject-regex里挡掉排序和分享类参数,'(\?|&)(sort|order|share|replytocom|utm_)=|/print/'这条能挡掉大部分。第二步改用 URL 池模式,别递归。第三步抓完跑一次去重,按文件内容哈希分组,同组只留路径最短的那份。

5.2 中文文件名变乱码,或者整批 404

现象:本地目录里出现%E4%B8%AD%E6%96%87.html这类名字,双击打不开;或者 HTML 里明明是中文路径,本地对应文件却不存在。

原因:URL 里的百分号编码和文件系统编码不一致。抓取时没解码,本地文件就带着%XX;解码了但 HTML 里的引用没同步改,引用和实际文件名对不上。

解决:统一在抓取阶段用--restrict-file-names=windows,让工具自己做编码转换,别手工掺和;后处理脚本里用unquote还原再比对。如果已经抓完,用第 3.3 的脚本出一次missing.csv,按报告批量改名,比肉眼找快得多。

5.3 CSS 里的字体和背景图整批丢失

现象:页面结构完整,但字体回退成系统默认,按钮背景、图标全变空白。控制台里一堆 404。

原因:--page-requisites只解析 HTML 里的直接引用,CSS 里url()写的背景图和@font-face的字体文件是二级引用,工具默认不跟。另外字体文件常放在 CDN 域名,被域名白名单挡了。

解决:把 CDN 域名加进--domains,再对 CSS 文件单独跑一轮抓取:从 CSS 里抽出url(...),拼成完整地址写进urls.txt,用wget -i批量取。或者干脆让后处理脚本把 CSS 里的url()也纳入扫描范围,和第 3.3 的缺失检测合并成一份报告。

5.4 本地双击打开白屏,控制台报跨域

现象:file://协议打开 HTML,控制台一片红,提示跨域或模块加载失败,页面什么都不显示。

原因:现代前端打包产物多用 ES Module,file://协议下浏览器出于安全策略拒绝加载模块;另外部分脚本读location.origin做判断,本地协议下取到null直接抛错。

解决:别用双击,起一个本地静态服务器。python -m http.server 8000一行就够,然后访问http://localhost:8000/。这个动作要写进交付说明,不然对方打开就是白屏,还会以为你抓漏了。

5.5 抓到一半开始返回 403 或 429

现象:前几百个请求正常,之后连续失败,日志里全是 403、429,或者连接被重置。

原因:请求频率触发了站点或 CDN 的限流阈值,也可能是 UA 被识别、Referer 缺失、Cookie 过期。还有一种情况是并发开太高,短时间打满了对方的连接数配额。

解决:先把--wait调到 3 到 5 秒,--limit-rate降到 200k 以下,重跑失败清单;仍然失败就看响应头里的Retry-After,按它给的时间等待;再不行就换时间段、分批跑。千万别在这个阶段加并发硬刷,那是把「暂时被限」变成「永久被封」的最快方式。

6. 增量更新与结果校验:把一次性抓取变成可重复的流程

第一次抓完就交付,是最容易出事的地方。第二次要更新时,你会发现wget -N的时间戳比对在动态站点上几乎失效——页面每次请求都返回当前的Last-Modified,等于全量重下。我后来的做法是自己维护一份清单。

# 抓取后立即生成基线:URL、大小、内容哈希 find ./mirror -type f -name "*.html" -printf "%p\t%s\n" > baseline.tsv find ./mirror -type f -name "*.html" -exec md5sum {} + >> baseline.tsv

下次抓取前先跑同样一份,diff两个文件,变化的行就是需要复核的页面。这比信任服务端时间戳可靠,因为哈希只认内容。

校验清单我固定跑四项:

校验项命令要点判读标准
空文件find ./mirror -size 0数量应为 0,非 0 说明抓取被截断
未改写的外链grep -rl "example.com" --include=*.html少量可容忍,成片说明--convert-links没跑完
断链第 3.3 脚本的missing.csv缺失率超过 5% 就要补抓
体积分布du -sh ./mirror和文件数与干跑时的 URL 数对不上就查排除规则

有一个细节值得单独说:--convert-links是在整个任务成功结束后才统一改写的,中途按 Ctrl+C 或者遇到未处理异常,镜像目录里的链接还是线上绝对地址。所以我习惯在抓取完成后先跑一遍grep -rl "https://example.com",确认数量降到个位数以下,再进入校验环节。这一步花不到十秒,能提前发现「抓完了但没改写」这种最难排查的状态。

从那以后我每次交付快照前,都强制走一遍「空文件 → 外链残留 → 断链报告 → 体积核对」这四步,不管多小的站点都不跳。跳过的那次,后来在客户那边打开是白屏,返工成本远高于这四步。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询