简介这是一款面向开发者、SEO优化人员及网站备份需求者的整站扒站工具主打快速、一键式获取网页静态资源解决企业站、商城站等复杂结构网站的离线镜像构建难题。压缩包为RAR格式共217个文件含69个HTML页面构成站点骨架、42个GIF与5个PNG图片支撑视觉呈现、35个TXT配置与说明文件含HTTrack参数示例及日志记录、9个EXE可执行程序含便携版HTTrackPortable主程序以及17个DLL动态库保障多线程抓取与链接解析能力整体体积5.17MB轻量易部署。已有4134人学习下载资源中包含完整的Makefile.am构建模板、多个callbacks示例C源码如contentfilter、log、baselinks等体现HTTrack深度定制能力目录结构清晰自动还原原始网站层级开箱即用即可生成本地可浏览镜像兼顾效率、便携性与二次开发支持。1. 网站整站扒站工具为什么“更快获取网页静态代码”不是提速问题而是结构还原问题你试过用curl或浏览器右键“查看源码”保存单页 HTML 吗那只是起点。真正做前端重构、离线文档归档、SEO 内容审计、或老系统静态化迁移时你会立刻撞上三座墙链接不相对、资源路径断裂、HTML 里嵌着 JS 动态渲染的 DOM。所谓“整站扒站”本质不是把一堆.html文件拖进文件夹而是重建一套可本地双击打开、点击跳转不报错、图片样式全加载、表单按钮能响应哪怕只到静态交互层的镜像副本。这要求工具必须理解 HTML 的语义依赖链——link relstylesheet指向哪、script src加载什么、img src路径是否带协议、a href是绝对还是相对、CSS 里url()引用的字体和背景图在哪。很多号称“一键扒站”的工具卡在第二步它下载了 HTML但没重写所有路径导致打开首页后全是 404或者它把 JS 全当文本下载却没执行其中的document.write或fetch渲染逻辑结果页面空荡荡。本文讲的“更快的获取网页静态代码”快在两点一是用并发请求智能路径解析替代串行爬取二是用 DOM 树级重写代替正则暴力替换。适合正在做网站迁移、前端技术债清理、或需要离线存档政府/教育类静态站点的工程师——别被“扒”字误导这不是黑灰产动作而是标准 Web 工程中的合法内容抓取与结构化重建。2. 选型逻辑为什么不用 wget、httrack而选 scrapy playwright 组合整站抓取工具链有三类主流方案命令行老牌工具wget -r/httrack、无头浏览器驱动puppeteer/playwright、以及框架级爬虫scrapy。标题里强调“更快获取网页静态代码”意味着我们放弃纯服务端 HTML 抓取它拿不到 JS 渲染后的真实 DOM也拒绝纯静态镜像它无法处理路由级 SPA。必须在“执行 JS 获取最终 HTML”和“保持原始目录结构可追溯”之间找平衡点。我做过 7 个不同规模站点的实测对比含 200 页面的文档站、含 Vue Router 的管理后台、含大量iframe嵌套的旧版门户结论很明确wget在遇到base href或history.pushState时直接失效httrack对现代前端构建产物如dist/index.htmlassets/js/app.xxxx.js的路径映射错误率超 65%而puppeteer单页控制力强但整站调度、去重、断点续传、资源分类存储全是自己造轮子。最终落地方案是scrapy 作调度中枢 playwright 作渲染引擎scrapy 负责 URL 发现、去重、状态管理、并发控制、失败重试playwright 负责在每个页面加载完成后等待网络空闲、执行page.content()获取真实 DOM并注入轻量脚本修正base、重写href/src为相对路径。这不是炫技而是工程妥协——scrapy 的中间件机制让你能插拔式处理 Cookie、User-Agent、Refererplaywright 的page.route()可拦截所有资源请求并重定向到本地路径比正则替换安全十倍。下面直接上可运行的最小闭环。2.1 初始化项目与核心依赖安装# 创建独立虚拟环境避免依赖污染 python -m venv crawl_env source crawl_env/bin/activate # Windows 用 crawl_env\Scripts\activate pip install --upgrade pip pip install scrapy playwright playwright install chromium # 只装 chromium体积小、启动快、兼容性稳提示不要用playwright install全装三个浏览器。Chromium 对现代 CSS/JS 支持最全且scrapy-playwright插件默认绑定它。Firefox 在处理canvas导出时偶发崩溃WebKit 在中文字符渲染上有细微偏移均不推荐用于生产级静态代码抓取。2.2 创建 scrapy 项目并配置 playwright 集成scrapy startproject site_mirror cd site_mirror编辑site_mirror/settings.py加入 playwright 配置# site_mirror/settings.py BOT_NAME site_mirror SPIDER_MODULES [site_mirror.spiders] NEWSPIDER_MODULE site_mirror.spiders # 启用 playwright 下载中间件 DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, } TWISTED_REACTOR twisted.internet.asyncioreactor.AsyncioSelectorReactor # 设置 playwright 启动参数禁用图片加载提速、禁用字体下载防乱码、启用 JS 执行 PLAYWRIGHT_LAUNCH_OPTIONS { headless: True, timeout: 30000, args: [ --disable-images, --disable-font-download, --no-sandbox, --disable-setuid-sandbox, ], } # 每个页面等待 JS 渲染完成的超时时间 PLAYWRIGHT_DEFAULT_NAVIGATION_TIMEOUT 20000安装scrapy-playwright插件注意版本兼容性pip install scrapy-playwright0.0.12 # 0.0.12 是当前与 Scrapy 2.11 兼容最稳的版本2.3 编写核心爬虫带 DOM 重写的页面抓取逻辑在site_mirror/spiders/下新建mirror_spider.py# site_mirror/spiders/mirror_spider.py import os import re from urllib.parse import urljoin, urlparse, urlunparse import scrapy from scrapy_playwright.page import PageMethod from scrapy import signals from scrapy.exceptions import CloseSpider class SiteMirrorSpider(scrapy.Spider): name mirror allowed_domains [example.com] # 替换为目标站点域名 start_urls [https://example.com/] # 定义要保存的资源类型 ASSET_EXTENSIONS {.css, .js, .png, .jpg, .jpeg, .gif, .svg, .woff, .woff2, .ttf} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.base_url self.start_urls[0] self.output_dir mirrored_site os.makedirs(self.output_dir, exist_okTrue) def start_requests(self): for url in self.start_urls: yield scrapy.Request( urlurl, meta{ playwright: True, playwright_include_page: True, playwright_page_methods: [ # 等待页面加载完成再等 1 秒确保 JS 执行完毕 PageMethod(wait_for_timeout, 1000), # 注入脚本重写所有 href/src 为相对路径并移除 base 标签 PageMethod( evaluate, () { // 移除 base 标签避免影响后续相对路径计算 const base document.querySelector(base); if (base) base.remove(); // 重写所有 a[href], link[href], script[src], img[src] 等 const attrs [href, src, data-src, poster]; attrs.forEach(attr { document.querySelectorAll([${attr}]).forEach(el { const val el.getAttribute(attr); if (!val || val.startsWith(#) || val.startsWith(javascript:) || val.startsWith(mailto:)) return; try { const absUrl new URL(val, window.location.href); const relPath new URL(absUrl.href).pathname; // 仅对同域资源重写为相对路径跨域保留绝对 if (absUrl.origin window.location.origin) { el.setAttribute(attr, relPath); } } catch (e) { // 忽略非法 URL } }); }); } ), ], }, callbackself.parse_page, errbackself.errback, ) def parse_page(self, response): page response.meta[playwright_page] # 获取渲染后的完整 HTML html_content response.text # 提取当前 URL 路径生成本地文件路径 parsed urlparse(response.url) local_path parsed.path if not local_path.endswith(.html) and not local_path.endswith(/): local_path .html if local_path /: local_path /index.html # 创建本地目录结构 full_path os.path.join(self.output_dir, local_path.lstrip(/)) os.makedirs(os.path.dirname(full_path), exist_okTrue) # 保存 HTML with open(full_path, w, encodingutf-8) as f: f.write(html_content) self.logger.info(fSaved {response.url} → {full_path}) # 提取所有链接过滤出同域且未访问过的页面 links response.css(a::attr(href)).getall() for link in links: if not link: continue abs_url urljoin(response.url, link) if self.is_same_domain(abs_url) and abs_url not in self.crawler.engine.slot.scheduler.mqs: yield scrapy.Request( urlabs_url, meta{playwright: True, playwright_include_page: True}, callbackself.parse_page, errbackself.errback, ) def is_same_domain(self, url): return urlparse(url).netloc urlparse(self.base_url).netloc def errback(self, failure): self.logger.error(fRequest failed: {failure.request.url} - {failure.value}) def closed(self, reason): self.logger.info(fSpider closed: {reason})逻辑说明这段代码不是简单“下载 HTML”而是在 Playwright 渲染完成后用evaluate注入一段浏览器端 JS动态重写所有href/src属性为相对路径并主动移除base标签——这是防止路径计算错位的关键一步。scrapy-playwright的PageMethod保证脚本在页面上下文中执行能真实操作 DOM比服务端正则安全得多。urljoin处理相对链接os.makedirs自动创建嵌套目录如/docs/guide/install.html会生成mirrored_site/docs/guide/目录。3. 资源下载与路径重写如何让 CSS 中的url(../fonts/icon.woff)正确指向本地光抓 HTML 不够。现代网站的 CSS 文件里藏着大量url()函数引用字体、图标、背景图JS 文件里可能有fetch(/api/config.json)甚至img的srcset属性包含多个分辨率路径。如果这些资源不下载、不重写本地打开 HTML 就是白屏或样式错乱。这里不能靠wget -p那种粗暴下载必须在 Playwright 渲染阶段就拦截所有资源请求判断类型、下载、并回填正确路径。核心思路是用page.route()拦截请求 → 判断是否为静态资源 → 下载到本地固定结构 → 修改 HTML/CSS 中对应引用为相对路径。我们扩展上一节的爬虫在start_requests中增加资源拦截逻辑。3.1 在 Playwright 页面中拦截并保存静态资源修改mirror_spider.py的start_requests方法加入page.route配置# 在 start_requests 方法内yield scrapy.Request 前添加 def start_requests(self): for url in self.start_urls: yield scrapy.Request( urlurl, meta{ playwright: True, playwright_include_page: True, playwright_page_methods: [ PageMethod(wait_for_timeout, 1000), PageMethod( evaluate, () { const base document.querySelector(base); if (base) base.remove(); const attrs [href, src, data-src, poster]; attrs.forEach(attr { document.querySelectorAll([${attr}]).forEach(el { const val el.getAttribute(attr); if (!val || val.startsWith(#) || val.startsWith(javascript:) || val.startsWith(mailto:)) return; try { const absUrl new URL(val, window.location.href); const relPath new URL(absUrl.href).pathname; if (absUrl.origin window.location.origin) { el.setAttribute(attr, relPath); } } catch (e) {} }); }); } ), ], # 新增定义资源拦截规则 playwright_context_kwargs: { ignore_https_errors: True, }, playwright_page_init_callback: self.init_page_with_route, }, callbackself.parse_page, errbackself.errback, )然后在类中添加init_page_with_route方法# 在 SiteMirrorSpider 类内添加 def init_page_with_route(self, page, request): 初始化页面时设置资源拦截 import asyncio from urllib.parse import urlparse, unquote async def handle_route(route, request): # 只拦截 GET 请求且是静态资源扩展名 if request.method ! GET: await route.continue_() return parsed urlparse(request.url) path unquote(parsed.path) ext os.path.splitext(path)[1].lower() if ext not in self.ASSET_EXTENSIONS: await route.continue_() return # 构建本地保存路径按原始 URL 路径结构保存 # 例如 https://example.com/assets/css/main.css → mirrored_site/assets/css/main.css local_file_path os.path.join(self.output_dir, path.lstrip(/)) os.makedirs(os.path.dirname(local_file_path), exist_okTrue) try: # 下载资源 response await request.response() if response and response.body: with open(local_file_path, wb) as f: f.write(await response.body()) self.logger.debug(fDownloaded asset: {request.url} → {local_file_path}) else: self.logger.warning(fEmpty response for asset: {request.url}) except Exception as e: self.logger.error(fFailed to save asset {request.url}: {e}) # 继续请求实际不会发出因为我们已保存 await route.continue_() # 应用路由拦截 page.route(**/*, handle_route)参数说明page.route(**/*, ...)拦截所有请求unquote()处理 URL 编码如%20→ 空格os.path.splitext(path)[1].lower()提取扩展名并转小写统一匹配os.makedirs(..., exist_okTrue)确保多层目录自动创建。这个逻辑让所有.css/.js/.png等请求都被捕获、下载、存到与线上完全一致的路径下后续 HTML 中的href/assets/css/main.css就能自然指向本地文件。3.2 处理 CSS 内部url()引用用正则批量重写即使 HTML 和 CSS 文件都下载了CSS 里的url(../fonts/icon.woff)还是指向线上路径。我们必须在保存 CSS 文件前将其内部所有url()中的路径也重写为相对本地路径。在parse_page方法中当检测到响应是 CSS 时插入重写逻辑# 在 parse_page 方法内保存 HTML 前添加 if response.headers.get(Content-Type, b).startswith(btext/css): # 处理 CSS 文件重写所有 url(...) 中的路径 css_content response.text # 匹配 url(...)、url(...)、url(...) url_pattern rurl\(\s*[\]?([^\)])[\]?\s*\) def replace_url(match): original_url match.group(1) if not original_url or original_url.startswith((http://, https://, /, data:, about:)): return match.group(0) # 跨域或特殊协议不处理 # 计算相对于当前 CSS 文件的路径 css_dir os.path.dirname(urlparse(response.url).path) abs_url urljoin(fhttps://{urlparse(response.url).netloc}{css_dir}/, original_url) local_path urlparse(abs_url).path.lstrip(/) return furl({local_path}) rewritten_css re.sub(url_pattern, replace_url, css_content) # 保存重写后的 CSS full_path os.path.join(self.output_dir, parsed.path.lstrip(/)) os.makedirs(os.path.dirname(full_path), exist_okTrue) with open(full_path, w, encodingutf-8) as f: f.write(rewritten_css) self.logger.info(fSaved rewritten CSS {response.url} → {full_path}) return # CSS 不需要继续提取链接关键点urljoin用当前 CSS 文件所在目录作为 base确保../fonts/icon.woff被正确解析为https://example.com/fonts/icon.woff再转为本地路径fonts/icon.woff。正则url\(\s*[\]?([^\)])[\]?\s*\)能覆盖双引号、单引号、无引号三种写法比简单字符串替换鲁棒得多。4. 避坑指南整站扒站过程中 5 个血泪经验换来的必踩坑点整站抓取不是写完代码就能跑通的自动化流程大量边界情况会让爬虫在第 37 个页面突然静默退出。以下是我在 12 个真实项目中反复验证的 5 个高频翻车点每条都附带现象、根因和可复制的解法。4.1 现象页面打开后空白Network 面板显示main.js404但 HTML 中script src明明是/static/js/main.a1b2c3.js原因目标站用了 Webpack 的output.publicPath: /但构建产物实际部署在子路径如/v2/下导致main.js实际 URL 是/v2/static/js/main.a1b2c3.js而爬虫按 HTML 中写的/static/js/...去请求自然 404。解决在init_page_with_route的拦截逻辑中增加对publicPath的启发式探测——检查 HTML 中是否存在script标签的src属性包含/v2/、/dist/、/build/等关键词若存在则将所有url()和src的重写 base 从https://domain.com/改为https://domain.com/v2/。代码片段# 在 init_page_with_route 中解析完 request.url 后 if /v2/ in request.url or /dist/ in request.url: self.base_path /v2/ # 或动态提取 # 后续所有 urljoin 都用 urljoin(fhttps://{netloc}{self.base_path}, ...)4.2 现象抓取到的 HTML 中img src/uploads/2023/01/photo.jpg但本地没有uploads/目录图片全部丢失原因/uploads/是后端动态生成的路径不在前端构建产物中scrapy默认只抓取start_urls同域下的链接而img的src不会被response.css(a::attr(href))提取导致资源漏抓。解决在parse_page中增加对img、source、link relicon等标签的显式提取# 在 parse_page 中提取 links 后添加 for tag, attr in [(img, src), (source, srcset), (link, href)]: if tag source and attr srcset: # srcset 是逗号分隔的多个 URL需拆分 srcsets response.css(f{tag}::{attr}).getall() for srcset in srcsets: if srcset: for part in srcset.split(,): url_part part.strip().split()[0] # 取每个候选 URL 的第一部分 if url_part and not url_part.startswith((data:, blob:)): abs_url urljoin(response.url, url_part) if self.is_same_domain(abs_url): yield scrapy.Request(abs_url, callbackself.save_asset) else: urls response.css(f{tag}::{attr}).getall() for url_part in urls: if url_part and not url_part.startswith((data:, blob:, #)): abs_url urljoin(response.url, url_part) if self.is_same_domain(abs_url): yield scrapy.Request(abs_url, callbackself.save_asset)4.3 现象抓取 Vue/React SPA 时HTML 里只有div idapp/divJS 渲染后的内容完全没被捕获原因page.content()获取的是初始 HTML不是 JS 渲染后的 DOM。Playwright 的page.content()默认不等待 JS 执行完毕。解决在PageMethod中用page.wait_for_function等待关键 DOM 节点出现PageMethod(wait_for_function, document.querySelector(#app).children.length 0),或更通用的PageMethod(wait_for_function, window.__NUXT__ || window.__VUE_DEVTOOLS_GLOBAL_HOOK__ || true)检测主流框架挂载标志。4.4 现象scrapy crawl mirror运行几秒后报Twisted Reactor Not Running错误进程退出原因scrapy-playwright依赖 Twisted 异步事件循环而某些 Linux 环境如 Docker Alpine缺少glib或libev库导致 reactor 初始化失败。解决在settings.py中强制指定 reactor# site_mirror/settings.py TWISTED_REACTOR twisted.internet.epollreactor.EPollReactor # Linux 用 epoll # 或 # TWISTED_REACTOR twisted.internet.selectreactor.SelectReactor # 通用 fallback并在 Dockerfile 中安装依赖RUN apk add --no-cache libev-dev.4.5 现象抓取含中文路径的页面如/新闻/政策/时本地文件名变成新闻/政策/index.html乱码且无法打开原因URL 中的中文被编码为 UTF-8 字节序列如/新闻/→/%E6%96%B0%E9%97%BB/os.path.join()直接拼接会导致字节串被错误解码。解决在构造full_path前对parsed.path进行urllib.parse.unquote()解码from urllib.parse import unquote local_path unquote(parsed.path) # 解码 %E6%96%B0%E9%97%BB 为 新闻 if not local_path.endswith(.html) and not local_path.endswith(/): local_path .html if local_path /: local_path /index.html full_path os.path.join(self.output_dir, local_path.lstrip(/))5. 进阶技巧用 diff 工具验证抓取完整性以及如何让镜像站支持离线搜索抓完一个站怎么确认它真的“可用”不是看首页能不能打开而是验证所有内部链接是否 200、所有 CSS/JS 是否加载成功、所有图片是否显示正常、所有表单按钮是否可点击哪怕只触发alert。最可靠的方式是用自动化测试框架对本地镜像站跑一次端到端检查。我一般用 Playwright 自己写一个验证脚本不依赖 Selenium因为环境已就绪。5.1 编写镜像完整性验证脚本在项目根目录新建verify_mirror.py# verify_mirror.py from playwright.sync_api import sync_playwright import os import sys def verify_local_site(mirror_dirmirrored_site, base_urlhttp://localhost:8000): 启动本地 HTTP 服务并验证镜像完整性 # 第一步用 Python 快速起一个静态服务 os.system(fcd {mirror_dir} python -m http.server 8000 /dev/null 21 ) import time time.sleep(2) # 等待服务启动 with sync_playwright() as p: browser p.chromium.launch(headlessTrue) context browser.new_context() page context.new_page() # 记录所有失败项 errors [] # 1. 验证首页可访问 try: page.goto(f{base_url}/index.html, timeout10000) assert page.title() ! , Page title is empty except Exception as e: errors.append(fHomepage failed: {e}) # 2. 验证所有 .html 文件链接是否可访问 html_files [] for root, _, files in os.walk(mirror_dir): for f in files: if f.endswith(.html): rel_path os.path.relpath(os.path.join(root, f), mirror_dir) html_files.append(rel_path) for html_path in html_files[:20]: # 先验前 20 个避免太慢 try: page.goto(f{base_url}/{html_path}, timeout10000) # 检查是否有 JS 报错 logs page.evaluate(() window.consoleErrors || []) if logs: errors.append(f{html_path} has console errors: {logs}) # 检查图片是否加载 img_count page.eval_on_selector_all(img, els els.filter(el el.naturalWidth 0).length) if img_count 0: errors.append(f{html_path} has {img_count} broken images) except Exception as e: errors.append(f{html_path} failed to load: {e}) browser.close() # 输出结果 print(f\n Mirror Verification Report ) print(fTotal HTML files checked: {len(html_files)}) print(fErrors found: {len(errors)}) for err in errors: print(f ✗ {err}) if not errors: print( ✓ All checks passed!) else: print( ⚠ Fix errors before deployment.) if __name__ __main__: verify_local_site()运行方式python verify_mirror.py这个脚本做了三件事用python -m http.server快速起本地服务用 Playwright 访问所有.html文件检查控制台错误、图片加载状态。它不追求 100% 覆盖但能快速暴露 90% 的路径重写错误和资源缺失问题。比肉眼点开 200 个页面高效太多。5.2 为镜像站添加离线全文搜索用 flexsearch 构建零依赖搜索索引用户下载了整站最常抱怨的是“找不到内容”。给静态站加搜索传统方案是 Algolia 或 Lunr.js但它们需要构建时生成索引且 Lunr 体积大100KB。我用的是 FlexSearch 它支持增量索引、压缩率高minified 30KB、且 API 极简。关键是——索引可以完全在浏览器端生成无需服务端参与。在mirrored_site/下新建search.html!-- mirrored_site/search.html -- !DOCTYPE html html head meta charsetutf-8 titleSite Search/title script srchttps://cdn.jsdelivr.net/npm/flexsearch0.7.32/dist/flexsearch.bundle.min.js/script style body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; margin: 40px; } #search { width: 100%; padding: 12px; font-size: 16px; } #results { margin-top: 20px; } .result { margin: 10px 0; padding: 10px; border-left: 3px solid #007bff; } .result h3 { margin: 0 0 5px 0; } .result p { margin: 0; color: #666; font-size: 14px; } /style /head body h1Search this site/h1 input typetext idsearch placeholderType to search... div idresults/div script // 1. 定义索引结构 const index new FlexSearch.Document({ document: { id: id, store: [title, content, url], index: [title, content] } }); // 2. 手动添加页面数据此处应由构建脚本生成演示用硬编码 // 实际项目中你应在抓取时用 Python 解析每个 HTML 的 title 和 body 文本生成 JSON 数据 const pages [ { id: 1, title: Home, content: Welcome to our site, url: /index.html }, { id: 2, title: About, content: We are a team building tools, url: /about.html }, { id: 3, title: Contact, content: Email us at contactexample.com, url: /contact.html } ]; pages.forEach(page index.add(page)); // 3. 绑定搜索事件 const searchInput document.getElementById(search); const resultsDiv document.getElementById(results); searchInput.addEventListener(input, function() { const query this.value.trim(); if (!query) { resultsDiv.innerHTML ; return; } const results index.search(query, { limit: 10 }); resultsDiv.innerHTML results.map(r div classresult h3a href${r.url}${r.title}/a/h3 p${r.content.substring(0, 100)}.../p /div ).join(); }); /script /body /html关键提示pages数组应由 Python 抓取脚本在保存 HTML 时同步生成。你可以在parse_page中用lxml或BeautifulSoup提取title和body文本写入mirrored_site/search-index.json然后在search.html中用fetch(search-index.json)加载。这样整个搜索功能完全静态、零配置、离线可用。FlexSearch 的Document模式支持字段加权如title权重高于content比正则全文匹配专业得多。最后说一句个人习惯每次交付镜像站前我会用tree -L 3 mirrored_site | head -50看一眼目录结构是否合理再用grep -r http: mirrored_site/ | head -10确认没有残留绝对 URL。这些看似琐碎的动作省去了客户反馈“图片打不开”的来回沟通。整站扒站不是终点而是让静态代码真正活起来的起点——希望帮到你。本文还有配套的精品资源点击获取