Hugo 博客如何实现全文搜索?支持模糊匹配 + 高亮 + 摘要
版权归作者所有,如有转发,请注明文章出处: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)
生成的数据结构大概如下:
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(类似命令面板)
✅ 快捷键支持
完整源码
<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>
