简介:这是一款面向开发者、SEO优化人员及网站备份需求者的整站扒站工具,主打快速、一键式获取网页静态资源,解决企业站、商城类网站本地镜像构建与离线分析难题。压缩包为RAR格式,共217个文件、5.17MB,包含69个HTML页面(构成站点骨架)、42个GIF与5个PNG图片(视觉资源)、35个TXT配置与说明文档、9个EXE可执行文件(含便携版HTTrackPortable主程序)、17个DLL动态库(支撑功能模块),以及C源码、CSS样式、INI配置等辅助文件,结构完整,开箱即用。已有4134人学习下载。用户可直接运行HTTrackPortable实现全站递归抓取,自动重建链接关系与目录层级;包内还提供Makefile.am、callbacks-example系列C示例代码及日志、readme等配套材料,便于理解扒站原理、调试参数或二次开发,是实践网页抓取、离线浏览与内容归档的实用型工具集。
1. 网站整站扒站工具,更快的获取网页静态代码:不是爬虫,是结构化快照引擎
你有没有遇到过这样的场景:前端团队要复刻一个竞品官网的视觉层,但对方用了 Vue SSR + 动态路由 + Webpack 分包,F12 里看到的 DOM 是运行时拼出来的,Network 面板里全是chunk-xxx.js和/api/menu;又或者安全审计需要比对某政务站点在 CDN、源站、WAF 后的真实 HTML 输出差异,但curl -s只能拿首页,wget --mirror却卡在 JS 跳转、AJAX 渲染、Token 校验上?这时候,“扒站”早已不是早期那种wget -r -l 5的粗暴镜像——它本质是一个带渲染能力的静态快照采集系统:既要绕过前端框架的动态屏障,又要保留原始 HTML/CSS/JS 的文件拓扑关系,还要控制资源深度、去重逻辑和响应头继承。本篇讲的不是通用爬虫,而是专为“获取网页静态代码”这一明确目标设计的轻量级整站快照工具链。它不模拟用户行为,不登录、不提交表单、不触发事件,只做一件事:以最短路径、最高保真度,把目标网站当前可访问的 HTML 结构及其依赖资源(CSS、JS、图片、字体)按真实引用关系下载到本地,生成可离线打开、可 diff、可进 CI 流程的静态副本。适合前端架构师做 UI 沉淀、安全人员做基线比对、SEO 工程师做渲染一致性验证——如果你的需求是“我要一份和浏览器地址栏输入 URL 后 F12 查看源代码一模一样的 HTML 文件树”,那它就是你要找的。
2. 为什么不用 wget/curl?从原理选型看“静态代码快照”的三个硬约束
2.1 渲染层缺失:传统工具的致命盲区
wget和curl是纯 HTTP 客户端,它们只管发请求、收响应体,完全不执行 JavaScript。而现代网站中,关键 HTML 内容往往由 JS 动态注入:Vue Router 的<router-view>、React 的ReactDOM.createRoot().render()、甚至简单的document.write()都会让wget -r下载到的 HTML 是空壳(如<div id="app"></div>)。这不是“没下全”,而是根本没触发内容生成。我们曾用wget --convert-links --page-requisites --no-parent https://example.com对一个 Next.js 站点执行,结果所有页面 HTML 文件大小均小于 2KB,而实际浏览器渲染后 DOM 节点超 3000 个。这说明:无渲染能力的工具,在 SPA 时代已失去“获取静态代码”的资格。
2.2 资源拓扑断裂:相对路径与构建产物的错位
即使网站是纯静态的,wget的资源下载逻辑也常导致文件引用失效。例如,源站 CSS 中写background: url(../images/logo.png),而wget默认将logo.png下载到images/logo.png,但若 CSS 文件被下载到css/main.css,则相对路径../images/logo.png在本地打开时会指向css/../images/logo.png→images/logo.png,看似正确。但当网站使用 Webpack 构建时,logo.png可能被哈希重命名为logo.a1b2c3.png并放在static/img/目录下,而 CSS 中的引用是url(/static/img/logo.a1b2c3.png)(绝对路径)。wget无法识别这种构建产物的映射关系,只会按原始 URL 下载,导致本地 CSS 仍引用旧路径,图片 404。真正的静态快照必须重建资源间的语义依赖,而非机械复制 URL 路径。
2.3 控制粒度粗糙:深度、范围、状态不可控
wget -l 5的“深度”是按 URL 路径层级算的(/a/b/c.html算 3 层),但现代前端路由是前端控制的,/user/profile和/user/settings可能由同一份 JS 渲染,wget却会当成两个独立页面反复请求。更严重的是,它无法区分“真实页面”和“API 接口”——只要 URL 符合-l规则,/api/v1/data这类 JSON 接口也会被下载为.html文件,污染快照目录。而我们需要的是:只抓取被<a href>显式链接的 HTML 页面,且每个页面的 JS/CSS/图片仅下载一次,无论被多少页面引用。这要求工具具备 DOM 解析能力,能从 HTML 中提取src/href/>const puppeteer = require('puppeteer'); const fs = require('fs').promises; const path = require('path'); async function takeSingleSnapshot(url, outputPath) { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); // 关键:禁用图片加载,加速快照(不影响 HTML/CSS/JS 获取) await page.setRequestInterception(true); page.on('request', (req) => { if (req.resourceType() === 'image') req.abort(); else req.continue(); }); await page.goto(url, { waitUntil: 'networkidle0' }); // 等待网络空闲 const html = await page.content(); // 获取完整渲染后 HTML await fs.writeFile(outputPath, html, 'utf8'); console.log(`✅ 已保存 ${url} 到 ${outputPath}`); await browser.close(); } takeSingleSnapshot('https://example.com', './snapshot.html');
这段代码做了三件关键事:
setRequestInterception拦截图片请求,避免下载大图拖慢速度(HTML 中<img>标签仍在,只是src不加载);waitUntil: 'networkidle0'确保所有网络请求(包括 JS 动态 fetch)完成后再取 HTML,比domcontentloaded更可靠;page.content()返回的是浏览器最终渲染的 HTML 字符串,包含 JS 注入的内容,这才是真正的“静态代码”。
3.2 扩展为整站快照:广度优先遍历 + 资源提取
单页不够,需自动发现并抓取全站。我们改用广度优先搜索(BFS),从入口 URL 开始,解析 HTML 中所有<a href>链接,过滤出同域、非外链、非锚点的 URL,逐个快照。同时,为每个页面提取其依赖的 CSS/JS/字体等资源:
const puppeteer = require('puppeteer'); const { URL } = require('url'); const fs = require('fs').promises; class SiteSnapshot { constructor(baseURL, options = {}) { this.baseURL = new URL(baseURL); this.maxPages = options.maxPages || 100; this.visited = new Set(); this.queue = [baseURL]; } async run() { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); while (this.queue.length > 0 && this.visited.size < this.maxPages) { const url = this.queue.shift(); if (this.visited.has(url)) continue; this.visited.add(url); try { await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); const html = await page.content(); const parsedUrl = new URL(url); const filename = `${parsedUrl.hostname}${parsedUrl.pathname.replace(/\//g, '_') || '_index'}.html`; await fs.writeFile(`./snapshots/${filename}`, html, 'utf8'); // 提取本页所有 CSS/JS/字体链接 const resources = await page.evaluate(() => { const links = Array.from(document.querySelectorAll('link[rel="stylesheet"], link[rel="preload"][as="font"], script[src]')) .map(el => el.href || el.src) .filter(src => src && !src.startsWith('data:') && !src.startsWith('blob:')); return [...new Set(links)]; // 去重 }); console.log(`📄 ${url} -> ${filename}, 提取资源 ${resources.length} 个`); await this.downloadResources(resources, page); } catch (err) { console.warn(`⚠️ 快照失败 ${url}:`, err.message); } } await browser.close(); } async downloadResources(urls, page) { for (const url of urls) { try { const response = await page.goto(url, { waitUntil: 'networkidle0', timeout: 10000 }); if (response?.ok()) { const buffer = await response.buffer(); const filename = new URL(url).pathname.split('/').pop() || 'resource'; await fs.writeFile(`./snapshots/${filename}`, buffer); } } catch (err) { console.debug(`❌ 资源下载失败 ${url}:`, err.message); } } } } // 启动 new SiteSnapshot('https://example.com', { maxPages: 50 }).run();这个脚本的关键升级在于:
- BFS 队列管理:
this.queue存储待抓取 URL,this.visited防止重复; - 资源提取逻辑:
page.evaluate()在浏览器上下文中执行,能准确获取 JS 动态添加的<link>和<script>标签,比服务端解析 HTML 更可靠; - 资源下载隔离:每个资源单独
page.goto(),避免page.content()只返回 HTML 而漏掉二进制资源。
注意:
page.goto()下载资源时,Puppeteer 会自动处理重定向、响应头(如Content-Type),所以buffer可直接写入文件,无需手动解析 MIME 类型。
4. 整站快照的 5 个必调参数:从 URL 过滤到资源去重
4.1--max-depth:不是路径深度,而是跳转链长度
很多工具文档写“设置最大抓取深度”,但对前端路由无效。我们定义maxDepth为从入口 URL 出发,通过<a>标签跳转的最大边数。例如:/→/products→/products/detail?id=123是 2 跳。实现时,在 BFS 队列中存储{url, depth}对象:
this.queue.push({ url: baseURL, depth: 0 }); // ... const { url, depth } = this.queue.shift(); if (depth >= this.maxDepth) continue; // 跳过更深链接 // 解析出新链接后,入队 {url: newUrl, depth: depth + 1}这样能精准控制导航范围,避免陷入/blog/page/1→/blog/page/2的无限分页。
4.2--include-pattern与--exclude-pattern:正则驱动的 URL 白名单
硬编码过滤太脆弱。我们支持传入正则字符串,如--include-pattern "^/((products|about|contact)/?$)"只抓产品、关于、联系页;--exclude-pattern "\.(pdf|zip|exe)$|/admin/|/api/"排除二进制文件、后台路径、API 接口。在解析<a>时应用:
const includeRegex = new RegExp(options.includePattern || '.*'); const excludeRegex = new RegExp(options.excludePattern || '^$'); // ... if (!includeRegex.test(href) || excludeRegex.test(href)) continue;4.3--resource-dedup:基于 Content-MD5 的全局资源去重
同一张 logo 图片可能被 20 个页面引用,wget会下载 20 次。我们为每个资源计算 MD5,只保存首次出现的副本,并在 HTML 中替换为本地路径:
const md5 = require('md5'); // ... const hash = md5(buffer); if (!this.resourceHashes.has(hash)) { this.resourceHashes.set(hash, filename); await fs.writeFile(`./snapshots/${filename}`, buffer); } // 替换 HTML 中的 src/href html = html.replace(new RegExp(`"${url}"`, 'g'), `"./${this.resourceHashes.get(hash)}"`);4.4--timeout与--retry:应对不稳定网络的弹性策略
page.goto()默认 30 秒超时,但某些页面 JS 执行慢或 CDN 延迟高。我们增加重试机制:
async function gotoWithRetry(page, url, options, maxRetries = 3) { for (let i = 0; i <= maxRetries; i++) { try { return await page.goto(url, { ...options, timeout: 60000 }); } catch (err) { if (i === maxRetries) throw err; console.log(`🔄 重试 ${url} (${i + 1}/${maxRetries})`); await new Promise(r => setTimeout(r, 2000)); } } }4.5--output-dir与--flat:目录结构决定后期维护成本
默认按 URL 路径生成嵌套目录(/products/list.html→./snapshots/products/list.html),但有时需要扁平化(所有 HTML 放根目录,用url-encode命名)。--flat参数开启后:
const safeFilename = encodeURIComponent(url).replace(/[^a-z0-9._-]/gi, '_'); await fs.writeFile(`./snapshots/${safeFilename}.html`, html);扁平化便于grep全局搜索,嵌套化则利于人工浏览——根据你的下游用途选择。
5. 整站快照避坑指南:5 条血泪经验总结
5.1 现象:快照 HTML 中大量<!-- HTML comment -->被清空,但源站有重要注释
原因:Puppeteer 的page.content()方法返回的是序列化后的 DOM,而浏览器在解析 HTML 时会丢弃部分注释(尤其是跨标签的、含特殊字符的)。这不是 bug,是标准 DOM 行为。
解决:改用page.evaluate(() => document.documentElement.outerHTML),它返回原始 HTML 字符串,保留所有注释。但注意:此方法不包含<base>标签的修正,需手动补全。
5.2 现象:CSS 文件下载后,本地打开显示乱码,中文变方块
原因:源站 CSS 响应头Content-Type: text/css; charset=utf-8正确,但 Puppeteerresponse.buffer()返回的是二进制,fs.writeFile默认用 UTF-8 解码,若 CSS 文件实际是 GBK 编码(国内老站常见),就会乱码。
解决:检测响应头charset,或用iconv-lite库自动探测编码:
const iconv = require('iconv-lite'); const buffer = await response.buffer(); const encoding = response.headers()['content-type']?.match(/charset=(\S+)/)?.[1] || 'utf8'; const content = iconv.decode(buffer, encoding); await fs.writeFile(`./snapshots/${filename}`, content);5.3 现象:快照中<script type="module">的import路径全部 404
原因:ESM 模块的import是运行时解析的,page.evaluate()提取的script[src]只拿到外链脚本,漏掉了import './utils.js'这类相对路径模块。
解决:对每个 JS 文件内容做正则扫描,提取import.*?from\s+['"]([^'"]+)['"],加入资源队列。注意处理./、../、/开头的路径,需结合当前 JS 文件 URL 计算绝对路径。
5.4 现象:<img>const cleanHTML = await page.evaluate(() => { // 移除所有>const pixelmatch = require('pixelmatch'); const { PNG } = require('pngjs'); async function compareRenderings(url, envA, envB) { const browserA = await puppeteer.launch({ executablePath: envA.executable }); const pageA = await browserA.newPage(); await pageA.goto(url, { waitUntil: 'networkidle0' }); const screenshotA = await pageA.screenshot(); const browserB = await puppeteer.launch({ executablePath: envB.executable }); const pageB = await browserB.newPage(); await pageB.goto(url, { waitUntil: 'networkidle0' }); const screenshotB = await pageB.screenshot(); const img1 = PNG.sync.read(screenshotA); const img2 = PNG.sync.read(screenshotB); const { width, height } = img1; const diff = new PNG({ width, height }); const numDiffPixels = pixelmatch( img1.data, img2.data, diff.data, width, height, { threshold: 0.1 } ); await fs.writeFile('./diff.png', PNG.sync.write(diff)); return { numDiffPixels, isSame: numDiffPixels === 0 }; }
当numDiffPixels > 0,说明两个环境渲染存在像素差异——可能是字体回退、CSS Grid 布局兼容性、或 Canvas 绘图精度问题。这比 DOM 结构比对更底层、更真实。
6.3 快照即文档:自动生成网站静态 API 文档
很多网站的“静态代码”本身就是接口文档。例如,一个展示商品列表的页面,其 HTML 中<div class="product">const schema = await page.evaluate(() => { const products = Array.from(document.querySelectorAll('.product')).map(el => ({ id: el.dataset.id, price: el.dataset.price, name: el.querySelector('.name')?.textContent?.trim(), })); return { products, timestamp: Date.now() }; }); await fs.writeFile('./snapshots/api-schema.json', JSON.stringify(schema, null, 2));
这样,快照目录里不仅有 HTML,还有api-schema.json,可直接作为前端 Mock 数据源或 QA 测试用例。
我坚持把快照工具当“显微镜”用,而不是“复印机”。每次运行前,我会问自己:这次快照要回答什么问题?是验证 CDN 是否缓存了错误 HTML?还是确认新版本发布后,某个按钮的class名没被误删?答案决定了参数怎么调、哪些坑必须绕。工具没有银弹,但清晰的目标能让每一步操作都落在实处。希望帮到你。
本文还有配套的精品资源,点击获取