简介:这是一款面向网站开发与运维人员的整站下载工具,用于将目标网站的全部资源完整抓取到本地,适合需要离线备份、迁移站点或研究他人页面结构的用户。工具兼容ASP、JSP、PHP等主流服务端环境,对动态站点同样具备良好的抓取能力,属于上手门槛不高的实用型软件。压缩包为rar格式,体积约540KB,轻量便携,解压后即可直接运行,无需复杂配置。目前已有2183人学习下载,说明其在同类工具中具备一定的实用口碑。借助它,读者可以一次性获取整站页面、图片、脚本等资源,省去逐页手动保存的繁琐,便于搭建本地镜像、排查线上问题或进行二次开发参考。对于需要批量采集与归档网站内容的场景,这份工具能提供直接可用的解决方案。
1. 整站下载器到底在扒什么:从一次离线归档需求说起
有个做企业内训的朋友找我,说他们买了一套在线课程平台,想把里面几百个 HTML 页面连同图片、CSS、JS 全部存到本地,方便断网环境下给新人做演示。他一开始用浏览器「另存为」,存了三十多页就崩溃了——链接全是绝对路径,换台机器打开一片空白。这就是整站下载器存在的意义:它不是简单地把页面存下来,而是把整个站点的资源依赖关系摸清楚,按目录结构重建一份能离线跑的副本。
网站整站下载器,核心工作就三件事:抓取页面、解析资源引用、按规则重写链接。它要处理 HTML 里的<img>、<link>、<script>,还要处理 CSS 里的url()、JS 里的动态请求,甚至 iframe 嵌套。适合谁用?做离线文档归档的、需要竞品页面留证的、给客户做静态演示的、以及想批量分析某个站点结构的开发者。但别指望它能把一个带登录态、全靠接口渲染的 SPA 完整扒下来——那是另一个维度的工具。
2. 整站下载器的抓取原理与资源解析链路
2.1 从种子 URL 到资源图谱:广度优先还是深度优先
整站下载器的起点是一个种子 URL,然后它需要决定「下一步抓什么」。常见做法是广度优先遍历:先把首页所有链接提取出来,放进队列,再逐层往下。这样做的好处是能快速覆盖站点的浅层结构,不会在某个深不见底的目录里迷路。深度优先则适合目标明确的场景,比如你只想扒某个文档目录下的所有页面。
我一般会设置一个最大深度参数,默认 3 到 5 层。超过这个深度的链接直接丢弃,避免爬进无限循环的日历页或者分页参数。另一个关键参数是「同域限制」——只抓当前域名下的链接,还是允许跨域。如果你在扒一个用了 CDN 的站点,跨域资源(比如cdn.example.com上的 JS)需要单独放行,否则离线副本会缺胳膊少腿。
资源图谱的构建依赖 HTML 解析器。Python 生态里BeautifulSoup配合lxml是常见组合,Node.js 那边cheerio用得比较多。解析器要能处理不规范标签,比如没闭合的<img>或者属性值没加引号的情况。解析出资源 URL 后,还要做一次归一化:把相对路径转成绝对路径,去掉锚点参数,统一编码格式。
from urllib.parse import urljoin, urlparse, urldefrag from bs4 import BeautifulSoup def extract_resources(html, base_url): soup = BeautifulSoup(html, 'lxml') resources = set() # 提取常见标签的引用属性 tag_attr_map = { 'img': 'src', 'script': 'src', 'link': 'href', 'source': 'src', 'video': 'poster', 'iframe': 'src' } for tag, attr in tag_attr_map.items(): for el in soup.find_all(tag): val = el.get(attr) if val: # 归一化:拼接绝对路径并去掉锚点 absolute = urljoin(base_url, val) clean, _ = urldefrag(absolute) resources.add(clean) return resources这段代码的逻辑是:用 BeautifulSoup 解析 HTML,遍历预设的标签-属性映射表,把每个资源引用转成绝对 URL 并去掉#锚点。urljoin负责处理相对路径,urldefrag去掉锚点避免重复抓取同一资源的不同片段。参数上,tag_attr_map可以根据目标站点特点扩展,比如有些站点用>import os, re def rewrite_css_urls(css_content, css_local_path, url_map): # 匹配 CSS 中的 url() 引用 pattern = re.compile(r'url\((["\']?)([^)"\']+)\1\)') def replacer(match): original_url = match.group(2) # 跳过 data: 和 http(s): 开头的绝对地址 if original_url.startswith(('data:', 'http://', 'https://')): return match.group(0) # 计算相对于 CSS 文件所在目录的本地路径 abs_url = os.path.normpath( os.path.join(os.path.dirname(css_local_path), original_url) ) local = url_map.get(abs_url, original_url) return f'url("{local}")' return pattern.sub(replacer, css_content)
这段重写逻辑的关键在于:CSS 里的相对路径基准是 CSS 文件自身的位置,所以要先dirname再join,然后用normpath消掉../这类冗余。url_map是抓取阶段建立的映射字典,查不到就保留原样,避免把外链误伤。参数上,正则里的["\']?是为了兼容带引号和不带引号的写法,实际站点里两种都会出现。
3. 动手搭一个可用的整站下载流程:参数、过滤与断点续传
3.1 请求头、并发数与限速:别把人家站点打挂
整站下载器最容易惹麻烦的地方就是请求频率。你并发开太高,对方服务器直接把你 IP 封了,或者触发 WAF 返回一堆 403。我一般会把并发数控制在 5 到 10 之间,每个请求之间加 100 到 300 毫秒的随机延迟。请求头里必须带User-Agent,最好模拟常见浏览器,有些站点会检查Referer,那就把当前页面的 URL 填进去。
import requests, time, random from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(): session = requests.Session() # 重试策略:最多 3 次,退避因子 0.5 retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry, pool_maxsize=10) session.mount('http://', adapter) session.mount('https://', adapter) session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 'AppleWebKit/537.36 (KHTML, like Gecko) ' 'Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', }) return session def polite_get(session, url, referer=None): # 随机延迟,降低被封风险 time.sleep(random.uniform(0.1, 0.3)) headers = {} if referer: headers['Referer'] = referer return session.get(url, headers=headers, timeout=15)这段代码做了三件事:配置重试策略应对临时性错误、设置连接池大小、封装一个带随机延迟的请求函数。Retry的status_forcelist里放了 429 和 5xx,这些是典型的「稍后重试可能成功」的状态码。backoff_factor=0.5意味着第一次重试等 0.5 秒,第二次 1 秒,第三次 2 秒。polite_get里的延迟范围可以根据目标站点的承受能力调整,小站点建议拉到 0.5 到 1 秒。
3.2 资源过滤规则:哪些该抓,哪些该放
不是所有资源都值得抓。视频文件动辄几百 MB,抓下来离线副本也没法流畅播放;字体文件如果站点用了字体图标,那必须抓,否则页面全是方块。我一般会按扩展名做白名单和黑名单:
| 资源类型 | 扩展名 | 处理策略 |
|---|---|---|
| 页面 | .html .htm .php .asp | 抓取并解析 |
| 样式 | .css | 抓取并重写 url() |
| 脚本 | .js .mjs | 抓取,不解析 |
| 图片 | .jpg .png .gif .svg .webp | 抓取 |
| 字体 | .woff .woff2 .ttf .eot | 抓取 |
| 视频 | .mp4 .webm .avi | 默认跳过 |
| 压缩包 | .zip .rar .7z | 默认跳过 |
| 数据 | .json .xml | 按需抓取 |
白名单之外还有一层「URL 模式过滤」。比如/logout、/delete这类操作型链接必须排除,否则爬虫可能误触发删除操作。带?查询参数的 URL 要小心,分页参数?page=1到?page=100可以抓,但?sessionid=xxx这种就跳过。常见做法是维护一个正则黑名单,匹配到就直接丢弃。
3.3 断点续传与去重:抓了一半断了怎么办
整站下载最怕跑到一半网络断了或者程序崩了,重新跑一遍又得从头来。解决办法是维护一个「已抓取 URL 集合」,持久化到本地文件或者 SQLite。每次启动时先加载这个集合,已经抓过的直接跳过。同时把「待抓取队列」也存下来,恢复时从队列里继续。
import sqlite3, os class CrawlState: def __init__(self, db_path='crawl_state.db'): self.conn = sqlite3.connect(db_path) self.conn.execute('''CREATE TABLE IF NOT EXISTS visited (url TEXT PRIMARY KEY, local_path TEXT, status INTEGER)''') self.conn.execute('''CREATE TABLE IF NOT EXISTS queue (url TEXT PRIMARY KEY, depth INTEGER, referer TEXT)''') self.conn.commit() def is_visited(self, url): cur = self.conn.execute('SELECT 1 FROM visited WHERE url=?', (url,)) return cur.fetchone() is not None def mark_visited(self, url, local_path, status=200): self.conn.execute('INSERT OR REPLACE INTO visited VALUES (?,?,?)', (url, local_path, status)) self.conn.commit() def push_queue(self, url, depth, referer=''): self.conn.execute('INSERT OR IGNORE INTO queue VALUES (?,?,?)', (url, depth, referer)) self.conn.commit() def pop_queue(self): cur = self.conn.execute('SELECT url, depth, referer FROM queue LIMIT 1') row = cur.fetchone() if row: self.conn.execute('DELETE FROM queue WHERE url=?', (row[0],)) self.conn.commit() return row这个状态管理类的核心是两张表:visited记录已抓取的 URL 和本地路径,queue存待抓取队列。INSERT OR IGNORE保证队列里不会出现重复 URL,INSERT OR REPLACE让重复抓取时更新状态。参数上,db_path可以按项目名区分,避免多个任务互相干扰。恢复时先查queue表,空了再从种子 URL 重新初始化。
4. 避坑与排查:整站下载器最容易翻车的五个地方
4.1 现象:离线副本打开全是乱码,中文变问号
原因:目标页面用的是 GBK 编码,但下载器默认按 UTF-8 解码,或者响应头里没带charset,程序猜错了编码。
解决:不要依赖response.text的自动解码。先看响应头的Content-Type,没有就从 HTML 的<meta charset>里找,再没有就用chardet检测。拿到编码后手动response.content.decode(encoding),写入本地时统一转成 UTF-8。
import chardet def decode_response(resp): # 优先级:响应头 > meta 标签 > 自动检测 content_type = resp.headers.get('Content-Type', '') if 'charset=' in content_type: encoding = content_type.split('charset=')[-1].split(';')[0].strip() else: raw = resp.content[:2048] detected = chardet.detect(raw) encoding = detected.get('encoding') or 'utf-8' try: return resp.content.decode(encoding, errors='replace') except (LookupError, UnicodeDecodeError): return resp.content.decode('utf-8', errors='replace')4.2 现象:CSS 里的背景图全部丢失,控制台一堆 404
原因:只重写了 HTML 里的链接,忘了处理 CSS 文件内部的url()引用。或者重写时路径基准算错了,把相对于 CSS 的路径当成了相对于 HTML 的。
解决:CSS 文件下载后必须单独过一遍重写流程,基准目录用 CSS 文件自身的存放位置。重写完的 CSS 存到本地对应目录,确保相对路径层级和原始站点一致。
4.3 现象:抓了没几页就被封 IP,返回 403 或 429
原因:并发太高、没有延迟、请求头太假、或者触发了目标站点的反爬规则。
解决:把并发降到 3 到 5,请求间隔加到 0.5 秒以上,补全User-Agent、Accept、Accept-Language、Referer四个头。如果还不行,考虑用代理池轮换 IP,但注意代理质量参差不齐,免费代理基本不能用。另外检查robots.txt,虽然它没有强制力,但尊重一下能减少很多麻烦。
4.4 现象:页面里的 JS 动态内容全是空白,离线副本和在线看到的不一样
原因:整站下载器抓的是服务端返回的原始 HTML,而在线页面是 JS 执行后渲染出来的。SPA 站点尤其明显,原始 HTML 里只有一个<div id="app"></div>。
解决:这种情况静态下载器无能为力,需要上无头浏览器(比如 Playwright 或 Puppeteer)先渲染再抓取。但无头浏览器速度慢、资源消耗大,只适合页面数量少的场景。另一个思路是找到站点背后的 API 接口,直接抓 JSON 数据,但这需要逆向分析,成本更高。
4.5 现象:抓下来的目录结构乱七八糟,同名文件互相覆盖
原因:不同路径下的资源文件名相同,比如/a/style.css和/b/style.css,如果扁平化存储就会冲突。
解决:本地存储时保留原始 URL 的路径层级。把 URL 的 path 部分映射到本地目录,/a/style.css存成a/style.css,/b/style.css存成b/style.css。查询参数用哈希或者编码处理,避免文件名里出现非法字符。常见做法是用urlparse取出 path,去掉开头的/,再按/分段创建目录。
5. 进阶:用无头浏览器补齐动态渲染,以及验证离线副本完整性的技巧
静态抓取搞不定的 SPA 站点,得上无头浏览器。我一般用 Playwright,因为它对异步加载的等待策略比较灵活。核心思路是:先用浏览器打开页面,等网络请求静默(networkidle)或者某个关键元素出现,再把渲染后的 HTML 交给下载器处理。这样拿到的 HTML 里已经包含了 JS 动态插入的 DOM 节点和资源引用。
from playwright.sync_api import sync_playwright def fetch_rendered_html(url, wait_selector=None, timeout=30000): with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto(url, wait_until='networkidle', timeout=timeout) if wait_selector: # 等待特定元素出现,确保动态内容加载完成 page.wait_for_selector(wait_selector, timeout=timeout) html = page.content() browser.close() return html这段代码的关键参数是wait_until='networkidle',它表示等网络请求安静下来再取内容。但有些站点有轮询请求,永远静不下来,那就得用wait_selector指定一个只有渲染完成后才出现的元素。headless=True在服务器上跑没问题,本地调试时可以设成False观察页面加载过程。注意无头浏览器抓取速度大概是静态请求的十分之一,几百个页面就得跑很久,建议只对确认需要渲染的页面启用。
离线副本抓完之后,怎么验证完整性?我一般会做三件事。第一,用grep扫一遍所有 HTML 和 CSS,看还有没有残留的http://或https://绝对链接——有的话说明重写漏了。第二,起一个本地 HTTP 服务(python -m http.server),用浏览器打开首页,看控制台有没有 404。第三,随机抽几个深层页面,点进去看图片、样式、跳转是否正常。这套流程走下来,基本能覆盖 90% 的离线可用性问题。
从那以后我每次跑整站下载,都会先拿一个页面做小范围测试,确认编码、重写、目录结构都对了,再放开全站抓取。这个习惯帮我省了很多返工时间。希望帮到你。
本文还有配套的精品资源,点击获取