简介:这是一款面向开发者、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 rel="stylesheet">指向哪、<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.html+assets/js/app.xxxx.js)的路径映射错误率超 65%;而puppeteer单页控制力强,但整站调度、去重、断点续传、资源分类存储全是自己造轮子。最终落地方案是scrapy 作调度中枢 + playwright 作渲染引擎:scrapy 负责 URL 发现、去重、状态管理、并发控制、失败重试;playwright 负责在每个页面加载完成后,等待网络空闲、执行page.content()获取真实 DOM,并注入轻量脚本修正<base>、重写href/src为相对路径。这不是炫技,而是工程妥协——scrapy 的中间件机制让你能插拔式处理 Cookie、User-Agent、Referer;playwright 的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-playwright==0.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_ok=True) def start_requests(self): for url in self.start_urls: yield scrapy.Request( url=url, 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 } }); }); }""" ), ], }, callback=self.parse_page, errback=self.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_ok=True) # 保存 HTML with open(full_path, "w", encoding="utf-8") as f: f.write(html_content) self.logger.info(f"Saved {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( url=abs_url, meta={"playwright": True, "playwright_include_page": True}, callback=self.parse_page, errback=self.errback, ) def is_same_domain(self, url): return urlparse(url).netloc == urlparse(self.base_url).netloc def errback(self, failure): self.logger.error(f"Request failed: {failure.request.url} - {failure.value}") def closed(self, reason): self.logger.info(f"Spider 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( url=url, 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, }, callback=self.parse_page, errback=self.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_ok=True) 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(f"Downloaded asset: {request.url} → {local_file_path}") else: self.logger.warning(f"Empty response for asset: {request.url}") except Exception as e: self.logger.error(f"Failed 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_ok=True)确保多层目录自动创建。这个逻辑让所有.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(b"text/css"): # 处理 CSS 文件:重写所有 url(...) 中的路径 css_content = response.text # 匹配 url("...")、url('...')、url(...) url_pattern = r'url\(\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(f"https://{urlparse(response.url).netloc}{css_dir}/", original_url) local_path = urlparse(abs_url).path.lstrip("/") return f'url("{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_ok=True) with open(full_path, "w", encoding="utf-8") as f: f.write(rewritten_css) self.logger.info(f"Saved & 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(f"https://{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 rel="icon">等标签的显式提取:
# 在 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, callback=self.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, callback=self.save_asset)4.3 现象:抓取 Vue/React SPA 时,HTML 里只有<div id="app"></div>,JS 渲染后的内容完全没被捕获
原因: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_dir="mirrored_site", base_url="http://localhost:8000"): """启动本地 HTTP 服务并验证镜像完整性""" # 第一步:用 Python 快速起一个静态服务 os.system(f"cd {mirror_dir} && python -m http.server 8000 > /dev/null 2>&1 &") import time time.sleep(2) # 等待服务启动 with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context() page = context.new_page() # 记录所有失败项 errors = [] # 1. 验证首页可访问 try: page.goto(f"{base_url}/index.html", timeout=10000) assert page.title() != "", "Page title is empty" except Exception as e: errors.append(f"Homepage 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}", timeout=10000) # 检查是否有 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(f"Total HTML files checked: {len(html_files)}") print(f"Errors 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 charset="utf-8"> <title>Site Search</title> <script src="https://cdn.jsdelivr.net/npm/flexsearch@0.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> <h1>Search this site</h1> <input type="text" id="search" placeholder="Type to search..."> <div id="results"></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 contact@example.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 class="result"> <h3><a 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。这些看似琐碎的动作,省去了客户反馈“图片打不开”的来回沟通。整站扒站不是终点,而是让静态代码真正活起来的起点——希望帮到你。
本文还有配套的精品资源,点击获取