给网站加搜索功能

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(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&nbsp;/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>
    (function() {
      var input = document.getElementById('search-input');
      var dropdown = document.getElementById('search-dropdown');
      var pages = [];
      fetch('/search.json').then(function(r) { return r.json(); }).then(function(d) { pages = d; });

      function snippet(text, q) {
        var i = text.toLowerCase().indexOf(q.toLowerCase());
        if (i === -1) return '';
        var s = Math.max(0, i - 30);
        var e = Math.min(text.length, i + q.length + 60);
        var t = text.substring(s, e);
        if (s > 0) t = '…' + t;
        if (e < text.length) t = t + '…';
        return t.replace(new RegExp(q.replace(/[.*+?^\({}()|[\]\\]/g, '\\\)&'), 'gi'), '<mark>$&</mark>');
      }

      input.addEventListener('input', function() {
        var q = this.value.trim();
        if (!q) { dropdown.style.display = 'none'; return; }
        var hits = [];
        var ql = q.toLowerCase();
        for (var i = 0; i < pages.length; i++) {
          if (pages[i].title.toLowerCase().indexOf(ql) !== -1 || pages[i].text.toLowerCase().indexOf(ql) !== -1) {
            hits.push(pages[i]);
          }
        }
        if (!hits.length) {
          dropdown.innerHTML = '<div class="sr-empty">无结果</div>';
          dropdown.style.display = 'block';
          return;
        }
        var html = '';
        for (var j = 0; j < Math.min(hits.length, 10); j++) {
          var s = snippet(hits[j].text, q);
          html += '<a class="sr-item" href="' + hits[j].url + '">' +
            '<div class="sr-title">' + hits[j].title + '</div>' +
            (s ? '<div class="sr-snippet">' + s + '</div>' : '') +
            '</a>';
        }
        dropdown.innerHTML = html;
        dropdown.style.display = 'block';
      });

      document.addEventListener('click', function(e) {
        if (e.target !== input && !dropdown.contains(e.target)) {
          dropdown.style.display = 'none';
        }
      });
    })();
    </script>