给网站加搜索功能
2026-07-16 11:14:00 创建
2026-07-16 11:35:13 修改
一开始问AI,AI说用pagefind,好像前后问了好几个AI都提到了pagefind。所以我就打算用pagefind。按照官网上的quick start一通操作,搜索是有了。虽然看不懂(怎么还有IDF的事),不过至少看起来是好的。
结果一用就出了问题,先是随便搜了一个词,发现搜不到。AI说是因为我的网页没有说lang="zh-CN"导致分词出了问题。结果,加上之后,电脑上搜没问题了,也提交了。手机上一测,又出了问题。怎么手机上搜同一个关键词跟电脑上还能不一样的?好吧,问AI吧。AI让我打开手机USB调试模式,上电脑上看,到底是不是传入的搜索词出了问题。结果USB调试也不行。手机根本就没弹出USB调试的弹窗询问我。(但我明明记得之前可以,比如使用scrcpy的时候)总之这部分也没搞明白,不了了之了。
实际上我根本用不到pagefind这么强大的功能,我又去看了一下别人的Jekyll构建的网站,搜索功能就是一个search.json驱动的。我需要的也是这样,完全的关键词匹配。所以就让AI写了一个Node脚本,然后写了搜索框的JavaScript和css,看起来还不错。挺朴素。这一块完全是让AI写的,我没法掌控。不过至少结果看起来是非常可控的,而且也符合我的预期,那就这样吧。把我的精力花到更应该投入精力的地方去。
下附代码:
const fs = require("fs");
const path = require("path");
const publicDir = path.join(__dirname, "public");
const outputFile = path.join(publicDir, "search.json");
// 跳过这些目录
const skipDirs = new Set(["pagefind", "assets"]);
function walkHtmlFiles(dir) {
const results = [];
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!skipDirs.has(entry.name)) {
results.push(...walkHtmlFiles(full));
}
} else if (entry.name.endsWith(".html")) {
results.push(full);
}
}
return results;
}
function stripHtml(html) {
return html
// 整块删除,完全不参与搜索
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<pre[^>]*>[\s\S]*?<\/pre>/gi, "") // 代码块有 syntax-highlight <span>,是噪音
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "")
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "")
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "")
// 去掉剩余 HTML 标签,替换为空格
.replace(/<[^>]+>/g, " ")
// 解码常用 HTML 实体
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/ /g, " ")
// 合并多余空白
.replace(/\s+/g, " ")
.trim();
}
function urlFromPath(filePath) {
let rel = path.relative(publicDir, filePath).replace(/\\/g, "/");
if (rel === "index.html") return "/";
if (rel.endsWith("/index.html")) return "/" + rel.replace(/\/index\.html$/, "/");
return "/" + rel;
}
const pages = [];
for (const filePath of walkHtmlFiles(publicDir)) {
const html = fs.readFileSync(filePath, "utf-8");
// 标题
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const title = titleMatch ? titleMatch[1].trim() : "";
// 正文
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
const text = bodyMatch ? stripHtml(bodyMatch[1]) : stripHtml(html);
if (text.length < 10) continue; // 跳过内容过短的页面(如纯跳转页)
pages.push({
title,
url: urlFromPath(filePath),
text,
});
}
fs.writeFileSync(outputFile, JSON.stringify(pages, null, 2), "utf-8");
console.log(`search.json 已生成:${pages.length} 个页面`);
<style>
#search-wrap { position: relative; max-width: 400px; margin: 10px 0; }
#search-input { width: 100%; padding: 6px 10px; font-size: 1em; border: 1px solid #999; border-radius: 4px; box-sizing: border-box; }
#search-dropdown { display: none; position: absolute; top: 100%; left: 0; right: 0; background: Canvas; border: 1px solid #999; border-radius: 0 0 4px 4px; max-height: 360px; overflow-y: auto; z-index: 99; }
.sr-item { display: block; padding: 8px 12px; text-decoration: none; color: CanvasText; border-bottom: 1px solid ButtonBorder; }
.sr-item:last-child { border-bottom: none; }
.sr-item:hover, .sr-item:focus { background: Highlight; color: HighlightText; }
.sr-title { font-weight: bold; }
.sr-snippet { font-size: 0.85em; color: GrayText; margin-top: 2px; }
.sr-item:hover .sr-snippet, .sr-item:focus .sr-snippet { color: inherit; }
.sr-empty { padding: 8px 12px; color: GrayText; }
mark { background: #ff0; color: #000; border-radius: 1px; }
</style>
<div id="search-wrap">
<input id="search-input" type="text" placeholder="搜索..." autocomplete="off">
<div id="search-dropdown"></div>
</div>
<script></script>