版权归作者所有,如有转发,请注明文章出处:https://cyrus-studio.github.io/blog/

前言

博客文章越来越多,仅靠分类或归档查找内容会变得低效,因此需要引入一套轻量级搜索方案

实现一个:无需后端、支持全文检索、体验接近文档站的搜索功能

一、整体方案

搜索功能分为三部分:Markdown → index.json → 前端搜索(Fuse.js)

  • Python 遍历 markdown 文件生成全文索引(index.json)

  • 前端使用 Fuse.js 实现模糊搜索

  • 自定义 UI(类似 Command Palette)

二、生成索引(index.json)

1. 核心思路

  • 遍历 markdown 文件

  • 提取标题 + 内容

  • 生成 JSON

  • 放入 static/ 目录供前端访问

2. 完整代码

import os
import re
import json
import urllib.parse


def hugo_slugify(title: str) -> str:
    slug = title.lower()
    slug = slug.replace(" ", "-")
    slug = re.sub(r"[^a-z0-9\u4e00-\u9fff\-\+]", "", slug)
    slug = urllib.parse.quote(slug, safe="-+")
    return slug


def read_markdown_content(path: str) -> str:
    """读取 markdown 内容(去掉 front matter)"""
    with open(path, "r", encoding="utf-8") as f:
        text = f.read()

    # 去掉 Hugo front matter(--- 包裹部分)
    text = re.sub(r"^---.*?---", "", text, flags=re.S)

    # 去掉 markdown 语法(简单版)
    text = re.sub(r"`.*?`", "", text)  # 行内代码
    text = re.sub(r"!\[.*?\]\(.*?\)", "", text)  # 图片
    text = re.sub(r"\[.*?\]\(.*?\)", "", text)  # 链接
    text = re.sub(r"[#>*\-]", "", text)  # 常见符号

    return text.strip()


def generate_index_json(directory: str, url_prefix: str, output_file: str):
    files = []

    for fname in os.listdir(directory):
        if fname.endswith(".md"):
            title = os.path.splitext(fname)[0]
            fpath = os.path.join(directory, fname)
            mtime = os.path.getmtime(fpath)

            content = read_markdown_content(fpath)

            files.append({
                "title": title,
                "slug": hugo_slugify(title),
                "content": content,
                "mtime": mtime
            })

    # 按时间排序(新 -> 旧)
    files.sort(key=lambda x: x["mtime"], reverse=True)

    # 拼接 URL
    for item in files:
        item["permalink"] = f"{url_prefix}{item['slug']}/"

    # 写入 JSON
    with open(output_file, "w", encoding="utf-8") as f:
        json.dump(files, f, ensure_ascii=False, indent=2)

    print(f"✅ index.json 已生成:{output_file}")
    
if __name__ == "__main__":
    directory = r"D:\hugo\markdown"
    prefix = r"https://cyrus-studio.github.io/blog/posts/"
    output = r"index.json"

    generate_index_json(directory, prefix, output)

生成的数据结构大概如下:

word/media/image1.png

3. 写入 Hugo static 目录

在 Hugo 里 static/ 目录 = 原样复制到最终网站根目录

构建后路径:

public/index.json

前端访问路径:

/index.json

源码如下(文件生成路径 static/index.json ):

def generate_index_from_config(config_path: str):
    """
    从 config.json 读取 hugo_dir,并生成 index.json 到 static 目录
    """

    # 读取配置
    with open(config_path, "r", encoding="utf-8") as f:
        config = json.load(f)

    hugo_dir = config.get("hugo_dir")
    markdown_dir = config.get("hugo_markdown_dir")

    if not hugo_dir or not markdown_dir:
        raise ValueError("config.json 必须包含 hugo_dir 和 hugo_markdown_dir")

    # static 目录
    static_dir = os.path.join(hugo_dir, "static")
    os.makedirs(static_dir, exist_ok=True)

    # 输出路径
    output = os.path.join(static_dir, "index.json")

    # URL 前缀
    prefix = "https://cyrus-studio.github.io/blog/posts/"

    # 生成 index.json 到 static 目录
    generate_index_json(markdown_dir, prefix, output)

三、搜索 UI

1. baseof.html

编辑:

themes/m10c/layouts/_default/baseof.html

加入:

  <div class="search-trigger" id="search-trigger">
    <span class="search-icon">🔍</span>
    <span class="search-text">搜索文章...</span>
    <span class="search-shortcut">/</span>
  </div>  

  <div id="search-overlay">
    <div id="search-modal">
      
      <!-- 输入框 -->
      <input 
        type="text"
        id="search-input"
        placeholder="🔍 搜索文章(支持标题 / 内容)..."
        autocomplete="off"
      />

      <!-- 搜索结果 -->
      <div id="search-results"></div>

    </div>
  </div>

2. _app.scss

编辑:

themes\m10c\assets\css\components\_app.scss

加入:

/* 遮罩 */
#search-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.4);
  backdrop-filter: blur(6px);
  display: none;
  z-index: 9999;
}

/* 弹窗 */
#search-modal {
  position: absolute;
  top: 10%;
  left: 50%;
  transform: translateX(-50%);
  
  width: 720px;
  max-width: 90%;

  background: #fff;
  border-radius: 14px;
  overflow: hidden;

  box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}

/* 输入框 */
#search-input {
  width: 100%;
  padding: 16px;
  border: none;
  border-bottom: 1px solid #eee;
  font-size: 16px;
  outline: none;
}

/* 结果列表(可滚动) */
#search-results {
  max-height: 60vh;
  overflow-y: auto;
}

/* 每项 */
.search-item {
  padding: 12px 16px;
  border-bottom: 1px solid #f0f0f0;
  cursor: pointer;
}

.search-item a {
  text-decoration: none;
  color: #333;
  font-size: 15px;
}

.search-item:hover {
  background: #f5f5f5;
}


.search-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;

  /* 居中 */
  margin: 20px auto;   /* 上下 20px,左右自动居中 */
  width: 75%;
  max-width: 420px;    /* 控制宽度(关键) */

  padding: 6px 16px;

  border-radius: 10px;
  border: 1px solid #ddd;

  background: #f8f8f8;
  cursor: pointer;

  transition: all 0.2s ease;
}

/* hover 效果 */
.search-trigger:hover {
  background: #f0f0f0;
  border-color: #ccc;
  transform: translateY(-1px);
}

/* 点击反馈 */
.search-trigger:active {
  transform: scale(0.98);
}

/* 左侧 */
.search-icon {
  margin-right: 8px;
}

.search-text {
  flex: 1;
  color: #666;
  font-size: 14px;
}

/* 右侧快捷键 */
.search-shortcut {
  font-size: 12px;
  color: #999;

  border: 1px solid #ddd;
  border-radius: 6px;
  padding: 2px 6px;

  background: #fff;
}

/* 高亮 */
mark {
  background: #ffe58f;
  padding: 0 2px;
  border-radius: 2px;
}

/* 标题 */
.search-title {
  font-size: 15px;
  font-weight: 500;
  color: #222;
}

/* 摘要 */
.search-summary {
  font-size: 13px;
  color: #888;
  margin-top: 4px;

  line-height: 1.4;

  /* 限制两行(关键) */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

四、实现搜索逻辑

1. 引入 Fuse.js

在 <head> 中增加下面代码:

<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2"></script>

Fuse.js 是一个轻量级的前端模糊搜索库,专为浏览器环境设计,无需依赖后端即可实现高效的全文检索功能。

它支持对数组对象进行搜索,并允许为不同字段设置权重(例如标题优先于正文),同时具备容错能力,即使输入存在拼写错误或不完整,也能返回相关结果。

相比传统的字符串匹配,Fuse.js 通过模糊匹配算法提供更智能的搜索体验,非常适合静态网站(如 Hugo 博客)实现本地搜索功能。

2. 加载索引

告诉 Fuse.js:用什么数据搜、搜哪些字段、怎么匹配

// 初始化数据
const indexUrl = "{{ "index.json" | relURL }}";
fetch(indexUrl)
  .then(res => res.json())
  .then(json => {
    data = json;

    // 初始化 Fuse 搜索实例
    fuse = new Fuse(data, {
    
      // 指定搜索字段(及其权重)
      keys: [
        {
          name: "title",   // 在 title 字段中搜索(文章标题)
          weight: 0.7      // 权重 0.7(优先级更高,匹配到标题更重要)
        },
        {
          name: "content", // 在 content 字段中搜索(文章正文)
          weight: 0.3      // 权重 0.3(次要,匹配到正文权重较低)
        }
      ],
    
      // 模糊匹配阈值(控制匹配严格程度)
      // 取值范围:0 ~ 1
      // 0   → 完全精确匹配(最严格)
      // 1   → 非常模糊(最宽松)
      // 0.3 → 允许一定拼写错误或模糊匹配
      threshold: 0.3,
    
      // 是否忽略关键词在文本中的位置
      // false(默认)→ 越靠前匹配,权重越高
      // true         → 忽略位置,只要匹配就行
      ignoreLocation: true
    
    });
});

3. 搜索核心

input.addEventListener('input', function () {
  const keyword = this.value.trim();

  if (!keyword) {
    resultsDiv.innerHTML = '';
    return;
  }

  const results = fuse.search(keyword);

返回的是一个 数组(Array) ,每一项结构大致如下:

[
  {
    item: { ...原始数据对象... },
    refIndex: 0,
    score: 0.023
  },
  ...
]

item 就是你传入的原始数据(index.json 里的每一条)

item: {
  title: "Frida 检测对抗",
  content: "...",
  permalink: "...",
  ...
}

4. 高亮关键词

function highlight(text, keyword) {
  const regex = new RegExp(`(${keyword})`, 'gi');
  return text.replace(regex, '<mark>$1</mark>');
}

5. 智能摘要

// 只截取“命中关键词附近内容”
function smartSnippet(text, keyword, length = 120) {
  const index = text.toLowerCase().indexOf(keyword.toLowerCase());
  if (index === -1) return text.slice(0, length);

  const start = Math.max(0, index - 30);
  const end = start + length;

  return (start > 0 ? '...' : '') +
         text.slice(start, end) +
         (end < text.length ? '...' : '');
}

6. / 快捷键打开搜索

document.addEventListener('keydown', (e) => {
  if (e.key === '/') openSearch();
});

7. 渲染结果

  resultsDiv.innerHTML = results.map(r => {
    const item = r.item;
    const keyword = input.value.trim();

    const title = highlight(item.title, keyword);

    // 智能截取 content
    let summary = smartSnippet(item.content, keyword);
    
    // 高亮摘要
    summary = highlight(summary, keyword);

    return `
      <div class="search-item">
        <a href="${item.permalink}" target="_blank" >
          <div class="search-title">${title}</div>
          <div class="search-summary">${summary}</div>
        </a>
      </div>
    `;
  }).join('');

五、最终效果

你将获得一个:

✅ 支持全文搜索

✅ 模糊匹配

✅ 关键词高亮

✅ 智能摘要

✅ 浮层 UI(类似命令面板)

✅ 快捷键支持

word/media/image2.png

完整源码

<script>
let fuse;
let data = [];

const overlay = document.getElementById('search-overlay');
const modal = document.getElementById('search-modal');
const input = document.getElementById('search-input');
const resultsDiv = document.getElementById('search-results');
const trigger = document.getElementById('search-trigger');

// 初始化数据
const indexUrl = "{{ "index.json" | relURL }}";
fetch(indexUrl)
  .then(res => res.json())
  .then(json => {
    data = json;

    fuse = new Fuse(data, {
      keys: [
        { name: "title", weight: 0.7 },
        { name: "content", weight: 0.3 }
      ],
      threshold: 0.3,
      ignoreLocation: true
    });
  });

// 打开搜索
function openSearch() {
  overlay.style.display = 'block';
  setTimeout(() => input.focus(), 50);
}

// 关闭搜索
function closeSearch() {
  overlay.style.display = 'none';
  input.value = '';
  resultsDiv.innerHTML = '';
}

// 点击按钮打开
trigger.addEventListener('click', openSearch);

// 点击遮罩关闭
overlay.addEventListener('click', (e) => {
  if (!modal.contains(e.target)) {
    closeSearch();
  }
});

// ESC 关闭
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') closeSearch();
});

// / 快捷键打开
document.addEventListener('keydown', (e) => {
  if (e.key === '/' && document.activeElement !== input) {
    e.preventDefault();
    openSearch();
  }
});

// 高亮函数
function highlight(text, keyword) {
  if (!keyword) return text;
  
  // 对用户输入的关键词进行“正则转义(escape)”,防止被当成正则表达式解析
  const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  
  const regex = new RegExp(`(${escaped})`, 'gi');
  return text.replace(regex, '<mark>$1</mark>');
}

// 只截取“命中关键词附近内容”
function smartSnippet(text, keyword, length = 120) {
  const index = text.toLowerCase().indexOf(keyword.toLowerCase());
  if (index === -1) return text.slice(0, length);

  const start = Math.max(0, index - 30);
  const end = start + length;

  return (start > 0 ? '...' : '') +
         text.slice(start, end) +
         (end < text.length ? '...' : '');
}

// 搜索逻辑
input.addEventListener('input', function () {
  const keyword = this.value.trim();

  if (!keyword) {
    resultsDiv.innerHTML = '';
    return;
  }

  const results = fuse.search(keyword);

  resultsDiv.innerHTML = results.map(r => {
    const item = r.item;
    const keyword = input.value.trim();

    const title = highlight(item.title, keyword);

    // 智能截取 content
    let summary = smartSnippet(item.content, keyword);
    
    // 高亮摘要
    summary = highlight(summary, keyword);

    return `
      <div class="search-item">
        <a href="${item.permalink}" target="_blank" >
          <div class="search-title">${title}</div>
          <div class="search-summary">${summary}</div>
        </a>
      </div>
    `;
  }).join('');

});
</script>

开源地址:https://github.com/CYRUS-STUDIO/blog