☰
网站整站下载器:从爬取策略到离线镜像的完整实现
2026/10/29 1:10:32 网站建设 项目流程

简介:这份网站整站下载器面向需要批量抓取站点资源的开发者与运维人员,解决手动逐页保存网页、图片、脚本等文件效率低下的问题。工具支持ASP、JSP、PHP等常见服务端环境,可对目标网站进行整站资源采集,适合建站备份、离线浏览、素材收集等场景。压缩包为rar格式,体积约540KB,轻量易携带,解压后即可使用,无需复杂配置。目前已有2183人学习下载,说明其在同类工具中具备一定实用性与口碑。使用者可借助它一次性获取整站页面与关联资源,省去逐页另存的操作成本,同时便于分析站点目录结构与资源组织方式,为后续迁移、镜像或本地调试提供便利。对于需要快速留存网页资料或研究他人站点实现思路的读者,这份工具能提供直接可用的采集能力,配合评论反馈可进一步判断其兼容性与稳定性。

1. 网站整站下载器:从“另存为”到整站镜像,差的不只是耐心

你可能遇到过这种情况:某个技术文档站改版了,旧版链接全部失效;或者一个开源项目的官网突然下线,配套的示例页面和静态资源再也找不回来。这时候才想起来“当初应该把整个站扒下来”,但浏览器右键的“另存为”只能保存当前页面,CSS、JS、图片全在原来的服务器上,离线打开就是一堆白板。网站整站下载器要解决的就是这个问题——它把目标站点当作一个可遍历的图结构,从入口 URL 出发,顺着超链接、静态资源引用、样式表嵌套一路抓取,最终在本地还原出一套可以离线浏览的目录树。适合谁用?做技术调研需要归档参考文档的工程师、做竞品分析要留存页面快照的产品同学、以及需要把老旧内部系统静态页面批量导出的运维人员。这件事听起来简单,但真跑起来,递归深度、资源去重、动态渲染这三座大山会挨个教你做人。

2. 整站下载的核心机制:爬取策略与资源识别

2.1 广度优先还是深度优先:遍历顺序决定了你的磁盘和带宽

整站下载器的第一个决策点是遍历策略。常见做法是广度优先搜索(BFS):先把入口页面上所有链接抓出来放进队列,再逐层往下。这种策略的好处是能快速摸清站点的大致结构,不会因为某个深层目录里的死循环把整个任务卡死。深度优先(DFS)则适合目标明确的场景,比如你只想沿着文档目录一路抓到底,不关心侧边栏的其他入口。

我一般会选 BFS,原因很实际:BFS 天然带层级信息,抓取过程中可以随时看到“当前在第几层”,方便设置深度上限。DFS 在遇到分页参数或者日历归档这类无限生成的链接时,很容易一头扎进去出不来。

具体实现上,需要一个队列来存待抓取的 URL,一个集合来记录已访问的 URL 做去重。去重这一步是整站下载器的生命线,没有它,一个带?page=1到?page=9999的分页就能把你的磁盘塞满。

from collections import deque from urllib.parse import urljoin, urlparse def crawl_bfs(start_url, max_depth=3, same_domain=True): """ BFS 整站爬取骨架 start_url: 入口地址 max_depth: 最大递归深度,防止无限翻页 same_domain: 是否限制同域,避免爬到外站 """ parsed_start = urlparse(start_url) queue = deque([(start_url, 0)]) # (url, depth) visited = set([start_url]) results = [] while queue: url, depth = queue.popleft() if depth > max_depth: continue # 这里调用实际的下载函数,后面章节展开 html = fetch_page(url) results.append((url, html)) # 提取页面内所有链接 for link in extract_links(html, url): if link in visited: continue if same_domain and urlparse(link).netloc != parsed_start.netloc: continue visited.add(link) queue.append((link, depth + 1)) return results

这段代码的关键参数是max_depth和same_domain。max_depth设成 3 到 5 之间比较稳妥,再深就容易碰到自动生成的归档页。same_domain建议开启,否则一个友情链接就能把你的爬虫带到完全无关的站点上去。visited集合用内存存就行,几万个 URL 对现代机器毫无压力。

2.2 静态资源怎么找:从 HTML 解析到 CSS 嵌套引用

光抓 HTML 页面不算整站下载,真正的重头戏是把页面依赖的 CSS、JS、图片、字体全部拉下来。HTML 里的资源引用相对好办,用解析库提取src和href属性即可。麻烦的是 CSS 文件内部的url()引用——背景图、字体文件、甚至嵌套的@import样式表,这些不会出现在 HTML 里,但缺了它们页面样式就崩。

常见做法是两轮抓取:第一轮抓 HTML 和其中直接引用的资源,第二轮专门解析已下载的 CSS 文件,把里面的url()路径提取出来再抓一遍。注意 CSS 里的路径是相对于该 CSS 文件所在目录的,拼接时要用 CSS 文件的 URL 做基准,不能用页面 URL。

import re from urllib.parse import urljoin def extract_css_resources(css_content, css_url): """ 从 CSS 内容中提取 url() 引用的资源 css_url: 该 CSS 文件自身的 URL,用于拼接相对路径 """ # 匹配 url(...) 和 @import "..." url_pattern = re.compile(r'url\((["\']?)([^)"\']+)\1\)') import_pattern = re.compile(r'@import\s+(["\'])([^"\']+)\1') resources = [] for match in url_pattern.finditer(css_content): raw = match.group(2).strip() if raw.startswith('data:'): continue # base64 内联资源跳过 resources.append(urljoin(css_url, raw)) for match in import_pattern.finditer(css_content): resources.append(urljoin(css_url, match.group(2))) return resources

这里有个容易翻车的点:url()里的路径可能带查询字符串,比如font.woff2?v=1.2.3,拼接和保存文件名时要把查询串处理掉,否则本地文件名会带一堆问号和等号。另外data:开头的 base64 内联资源直接跳过,它们已经嵌在 CSS 里了,不需要单独下载。

2.3 动态渲染页面:什么时候必须上无头浏览器

如果目标站点是纯静态 HTML,上面两节的方法就够了。但现在越来越多的站点用前端框架渲染,HTML 源码里只有一个空的<div id="app">,所有内容靠 JavaScript 在浏览器里生成。这时候用requests抓回来的页面是空壳,整站下载器就变成了“整站空壳下载器”。

判断方法很简单:抓一个页面下来,看正文区域有没有实际内容。如果 HTML 长度很短且包含大量<script>标签,基本可以确定需要动态渲染。常见做法是引入无头浏览器,等页面加载完成后再取 DOM。代价是速度会慢一个数量级,内存占用也高,所以只对确认需要渲染的页面启用,静态页面继续走轻量抓取。

from playwright.sync_api import sync_playwright def fetch_rendered_page(url, wait_selector=None, timeout=15000): """ 用无头浏览器抓取动态渲染页面 wait_selector: 等待某个元素出现再取内容,比如 '.article-body' """ with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto(url, timeout=timeout, wait_until='networkidle') if wait_selector: page.wait_for_selector(wait_selector, timeout=timeout) html = page.content() browser.close() return html

wait_until='networkidle'表示等网络请求基本停止后再取内容,适合大多数内容型页面。如果页面有懒加载图片,还需要模拟滚动到底部再取。wait_selector是更精确的控制手段,指定正文容器的选择器,等它出现就说明内容渲染完了。注意无头浏览器对服务器资源消耗大,并发数控制在 3 到 5 个比较稳,开太多容易把内存打满。

3. 动手实现:从零搭一个可用的整站下载器

3.1 环境准备与依赖选择

先把工具链定下来。Python 生态里做整站下载,核心依赖就几个:requests负责 HTTP 请求,beautifulsoup4或lxml负责 HTML 解析,playwright负责动态渲染,urllib.parse处理 URL 拼接。不需要上 Scrapy 这种重型框架,整站下载的场景通常规模可控,自己写调度逻辑反而更灵活。

pip install requests beautifulsoup4 lxml playwright playwright install chromium

playwright install chromium这一步会下载浏览器内核,大概一百多兆,国内网络环境下可能需要多试几次。如果目标站点全是静态页面,playwright可以不装,省掉这部分开销。

目录结构建议这样组织:一个downloader.py放核心逻辑,一个config.py放配置参数,下载结果按域名分目录存放。每个站点的资源放在output/<domain>/下面,HTML 文件保持原有的路径结构,静态资源统一放到assets/目录里,方便后续替换链接。

3.2 核心下载循环:请求、解析、保存、改写

整站下载器的核心循环做四件事:发请求拿内容、解析出新的待抓 URL、把内容存到本地、把 HTML 里的绝对路径改写成相对路径。最后这一步经常被忽略,但不做的话离线打开页面时,所有资源链接还指向原始域名,照样加载不出来。

import os import requests from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse class SiteDownloader: def __init__(self, start_url, output_dir='output', max_depth=3): self.start_url = start_url self.domain = urlparse(start_url).netloc self.output_dir = os.path.join(output_dir, self.domain) self.max_depth = max_depth self.session = requests.Session() self.session.headers.update({ 'User-Agent': 'Mozilla/5.0 (compatible; SiteArchiver/1.0)' }) self.visited = set() def url_to_local_path(self, url): """把 URL 映射成本地文件路径""" parsed = urlparse(url) path = parsed.path if path.endswith('/') or path == '': path += 'index.html' # 去掉查询串,避免文件名非法 local = os.path.join(self.output_dir, parsed.netloc, path.lstrip('/')) return local def download_page(self, url, depth=0): if url in self.visited or depth > self.max_depth: return self.visited.add(url) try: resp = self.session.get(url, timeout=15) resp.raise_for_status() except requests.RequestException as e: print(f'[FAIL] {url} -> {e}') return content_type = resp.headers.get('Content-Type', '') local_path = self.url_to_local_path(url) os.makedirs(os.path.dirname(local_path), exist_ok=True) if 'text/html' in content_type: soup = BeautifulSoup(resp.text, 'lxml') # 提取并递归下载页面内资源 for tag, attr in [('link', 'href'), ('script', 'src'), ('img', 'src')]: for node in soup.find_all(tag): raw = node.get(attr) if not raw: continue abs_url = urljoin(url, raw) if urlparse(abs_url).netloc == self.domain: self.download_asset(abs_url) with open(local_path, 'w', encoding='utf-8') as f: f.write(str(soup)) else: with open(local_path, 'wb') as f: f.write(resp.content) print(f'[OK] {url} -> {local_path}') def download_asset(self, url): """下载静态资源,不递归解析""" if url in self.visited: return self.visited.add(url) try: resp = self.session.get(url, timeout=15) resp.raise_for_status() except requests.RequestException: return local_path = self.url_to_local_path(url) os.makedirs(os.path.dirname(local_path), exist_ok=True) with open(local_path, 'wb') as f: f.write(resp.content)

这段代码里url_to_local_path是核心映射函数,它把 URL 的路径部分直接映射到本地目录结构。注意parsed.netloc也拼进了路径,这样多个域名的资源不会混在一起。download_page和download_asset分开是为了控制递归:页面需要继续解析里面的链接,而图片、CSS 这类资源下载完就结束,不再往里挖。

3.3 链接改写:让离线页面真正能打开

上面保存的 HTML 里,资源链接还是绝对 URL,离线打开时浏览器会去请求原始域名,结果就是加载失败。需要在保存前把href和src改写成相对路径。

def rewrite_links(soup, page_url, domain): """把页面内的绝对链接改写为相对路径""" for tag, attr in [('link', 'href'), ('script', 'src'), ('img', 'src'), ('a', 'href')]: for node in soup.find_all(tag): raw = node.get(attr) if not raw: continue abs_url = urljoin(page_url, raw) parsed = urlparse(abs_url) if parsed.netloc != domain: continue # 计算从当前页面到目标资源的相对路径 rel = os.path.relpath( parsed.path.lstrip('/'), os.path.dirname(urlparse(page_url).path.lstrip('/')) ) node[attr] = rel return soup

os.path.relpath负责计算相对路径,这样不管页面在哪个层级,资源引用都能正确指向本地文件。注意<a>标签的链接也要改写,否则点击站内链接会跳到线上地址。改写完成后,用浏览器直接打开本地 HTML 文件,样式和图片应该都能正常显示。

4. 避坑与排查:整站下载器最容易翻车的五个地方

4.1 现象:下载到一半磁盘满了,目录里全是重复文件

原因:URL 去重没做好,带查询参数的链接被当成不同页面反复下载。比如?id=1&page=2和?page=2&id=1参数顺序不同,但内容完全一样。另外分页链接如果没有深度限制,会无限生成。

解决:去重时对 URL 做规范化处理,把查询参数按 key 排序后再比较。同时设置max_depth硬上限,分页类链接单独识别并限制页数。常见做法是维护一个visited集合存规范化后的 URL,每次入队前先查重。

4.2 现象:CSS 和图片下载了,但页面样式还是乱的

原因:CSS 文件内部的url()引用没有递归下载,或者路径拼接时用了页面 URL 而不是 CSS 文件自身的 URL。字体文件、背景图这类嵌套资源最容易漏。

解决:对每个下载下来的 CSS 文件,单独跑一遍extract_css_resources,用 CSS 文件的 URL 做基准拼接路径。下载完成后,还要把 CSS 文件里的url()路径改写成相对路径,否则离线打开时字体和背景图照样加载不出来。

4.3 现象:动态页面抓下来是空白的,只有一堆 script 标签

原因:目标站点用前端框架渲染,requests拿到的 HTML 是初始模板,实际内容靠 JavaScript 生成。

解决:对确认需要渲染的页面启用无头浏览器。判断方法:抓一个页面看正文区域有没有实际文本,如果 HTML 长度小于 2KB 且包含id="app"或id="root"这类容器,基本可以确定。启用渲染后注意控制并发数,无头浏览器内存占用大,开太多容易把机器打挂。

4.4 现象:下载速度越来越慢,最后卡死不动

原因:没有设置请求超时,某个请求挂起后整个队列堵死。或者目标站点有反爬机制,连续请求后被限速甚至封 IP。

解决:每个请求都设timeout=15,超时后跳过继续下一个。请求之间加随机延迟,比如time.sleep(random.uniform(0.5, 1.5))。如果目标站点有robots.txt,先读一下确认允许抓取的范围。并发数控制在 5 以内,别把人家站点打崩。

4.5 现象:本地打开页面,中文全是乱码

原因:响应内容的编码判断错误。requests默认根据 HTTP 头猜编码,但有些站点头里写的是ISO-8859-1,实际内容是 UTF-8。

解决:优先用resp.apparent_encoding来判断编码,它基于内容特征检测,比 HTTP 头靠谱。保存文件时统一用encoding='utf-8',HTML 里的<meta charset>也要确认一下,必要时手动改成 UTF-8。

5. 进阶技巧:用站点地图和并发控制把效率拉满

5.1 先抓 sitemap.xml,再决定爬取范围

很多站点在根目录下放了sitemap.xml,里面列出了所有希望被搜索引擎收录的页面 URL。整站下载器启动前先拉一下这个文件,能直接拿到站点的完整页面清单,省去盲目遍历的时间。解析 sitemap 用xml.etree.ElementTree就行,注意 sitemap 可能是索引文件,里面嵌套了多个子 sitemap,需要递归展开。

import xml.etree.ElementTree as ET def parse_sitemap(url, session): """解析 sitemap,返回所有页面 URL 列表""" resp = session.get(url, timeout=15) root = ET.fromstring(resp.content) ns = {'sm': 'http://www.sitemaps.org/schemas/sitemap/0.9'} urls = [] # 处理 sitemap 索引 for sitemap in root.findall('sm:sitemap/sm:loc', ns): urls.extend(parse_sitemap(sitemap.text, session)) # 处理普通 sitemap for url_node in root.findall('sm:url/sm:loc', ns): urls.append(url_node.text) return urls

拿到 URL 列表后,直接按列表下载,不再需要从页面里提取链接。这样既快又全,还避免了漏抓。如果站点没有 sitemap,再退回 BFS 遍历。

5.2 并发下载:线程池的甜点区在 5 到 8 之间

单线程下载一个几百页的站点要等很久,上并发是自然的想法。但并发数不是越高越好,目标站点的承受能力、本地磁盘 IO、网络带宽都是瓶颈。我一般用ThreadPoolExecutor,并发数设在 5 到 8 之间,再高收益就不明显了,反而容易触发限速。

from concurrent.futures import ThreadPoolExecutor, as_completed def batch_download(urls, downloader, max_workers=6): """并发下载一批 URL""" with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {executor.submit(downloader.download_page, url): url for url in urls} for future in as_completed(futures): url = futures[future] try: future.result() except Exception as e: print(f'[ERROR] {url}: {e}')

max_workers=6是个比较稳的默认值。如果目标站点响应快且没有限速,可以试到 10;如果频繁出现超时或 429 状态码,降到 3 以下。注意requests.Session不是线程安全的,每个线程最好用自己的 session 实例,或者用连接池来管理。

5.3 增量更新:只下载变化的部分

第一次全量下载完成后,后续再跑的时候没必要把所有文件重新拉一遍。常见做法是记录每个 URL 的Last-Modified或ETag,下次请求时带上If-Modified-Since头,服务器返回 304 就跳过下载。这样增量更新一个站点只需要几分钟,比全量快一个数量级。

def download_with_cache(self, url, cache): """带缓存校验的下载""" headers = {} if url in cache: headers['If-Modified-Since'] = cache[url] resp = self.session.get(url, headers=headers, timeout=15) if resp.status_code == 304: return None # 未变化,跳过 cache[url] = resp.headers.get('Last-Modified', '') return resp

缓存信息存成一个 JSON 文件放在输出目录里,每次跑完更新一次。这个技巧对定期归档技术文档站特别有用,第一次全量之后,后面每次只拉新发布的页面和改动的资源。

5.4 一个容易忽略的细节:URL 规范化

最后说一个血泪经验。整站下载器跑多了会发现,同一个页面可能通过多个 URL 访问:带www和不带www、带尾部斜杠和不带、HTTP 和 HTTPS、带默认端口和不带。如果不做规范化,同一个页面会被下载好几遍,visited集合也形同虚设。

规范化规则很简单:统一转小写、去掉默认端口、去掉尾部斜杠(目录除外)、统一协议。写成一个函数,每次 URL 入队前先过一遍。这个细节不起眼,但能省下大量重复下载的时间和磁盘空间。

def normalize_url(url): """URL 规范化,用于去重""" parsed = urlparse(url.lower()) netloc = parsed.netloc if netloc.startswith('www.'): netloc = netloc[4:] if netloc.endswith(':80') or netloc.endswith(':443'): netloc = netloc.rsplit(':', 1)[0] path = parsed.path.rstrip('/') or '/' return f'{parsed.scheme}://{netloc}{path}'

这套组合拳打下来,一个中等规模的静态站点,从零到完整离线镜像,大概十几分钟就能跑完。动态渲染的站点会慢一些,但配合 sitemap 和增量更新,后续维护成本很低。我自己习惯是每个季度把常看的技术文档站重新归档一次,增量模式下几分钟就搞定,比在线翻文档还快。希望帮到你。

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

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

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

立即咨询