文章摘要 FakeGPT
加载中...|
由于 Algolia 的爬取服务受到网络波动和额度限制,为保证搜索体验百分之百高可用,我基于 MiniSearch 研发了纯离线、本地全文倒排检索备份系统。
安装依赖:
bash
npm install minisearch1. 构建期:Markdown 纯文本清洗与提取
为了压缩传输索引体积,在 VitePress 构建静态化阶段对 Markdown 源文件进行去格式清洗。
随着博客全量源代码嵌入文档的增加,我后续引入了两项终极性能重构:
- 增量磁盘缓存 (mtimeMs Cache):利用
fs.stat(item).mtimeMs对比文件最近修改戳,未改动的文章直接读取./.vitepress/cache/search-cache.json缓存,全站构建读取时间缩短为毫秒级。 - 行级线性过滤剔除:将原本极度消耗 CPU 算力进行正则表达式全局匹配的
replace(/```[\s\S]*?```/g, "")重构为行级 Toggle 标记过滤,直接完全跳过代码块所在的行,彻底规避了大代码块下的正则回溯匹配开销。
javascript
// .vitepress/theme/utils/getPostData.mjs
/**
* 获取所有文章,包含正文内容(用于本地搜索索引,已集成 mtimeMs 缓存与行级线性代码块过滤)
*/
export const getAllPostsWithContent = async () => {
try {
let paths = await getPostMDFilePaths();
// 载入搜索缓存文件
const cachePath = "./.vitepress/cache/search-cache.json";
let cache = {};
try {
if (await fs.pathExists(cachePath)) {
cache = await fs.readJson(cachePath);
}
} catch (e) {
console.warn("读取搜索缓存文件失败,将重新生成:", e.message);
}
let posts = await Promise.all(
paths.map(async (item) => {
try {
const stat = await fs.stat(item);
const mtimeMs = stat.mtimeMs;
// 命中缓存:文件未修改时直接使用缓存数据
if (cache[item] && cache[item].mtimeMs === mtimeMs) {
return cache[item].data;
}
const content = await fs.readFile(item, "utf-8");
const { data, content: body } = matter(content);
const { title, date, description, hidden } = data;
// 高效过滤:直接跳过 ``` 代码块包裹的内容,不做冗余清洗
const lines = body.split("\n");
let inCodeBlock = false;
const filteredLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.trim().startsWith("```")) {
inCodeBlock = !inCodeBlock;
continue;
}
if (!inCodeBlock) {
filteredLines.push(line);
}
}
const bodyWithoutCodeBlocks = filteredLines.join("\n");
// 简单的正文清理:去除 Markdown 语法
const cleanBody = bodyWithoutCodeBlocks
.replace(/!\[.*?\]\(.*?\)/g, "") // 去除图片
.replace(/\[.*?\]\(.*?\)/g, "$1") // 去除链接保留文字
.replace(/[#*`>]/g, "") // 去除标题、粗体、代码标记
.replace(/\s+/g, " ") // 合并空格
.trim();
const postData = {
id: generateId(item),
title: title || "未命名文章",
description: description || "",
body: cleanBody,
regularPath: `/${item.replace(".md", "")}`,
hidden: !!hidden,
};
// 写入内存缓存
cache[item] = {
mtimeMs,
data: postData
};
return postData;
} catch (error) {
console.error(`处理文章正文 '${item}' 时出错:`, error);
throw error;
}
}),
);
// 将缓存持久化写入磁盘
try {
await fs.ensureDir("./.vitepress/cache");
await fs.writeJson(cachePath, cache, { spaces: 2 });
} catch (e) {
console.warn("写入搜索缓存文件失败:", e.message);
}
return posts.filter(post => !post.hidden);
} catch (error) {
console.error("获取文章全文时出错:", error);
throw error;
}
};2. 客户端:按需实例化与缓存失效控制
前端采用按需触发加载 JSON 索引策略,并引入 buildTime 版本号作缓存破坏 (Cache Busting),保证多级缓存环境下搜索内容始终最新:
javascript
// .vitepress/theme/components/Search.vue
const initMiniSearch = async () => {
if (isIndexLoaded.value) return;
const response = await fetch(`/search.json?v=${theme.value.buildTime}`);
const data = await response.json();
miniSearch.value = new MiniSearch({
fields: ["title", "description", "body"],
storeFields: ["title", "description", "body", "regularPath"],
searchOptions: {
boost: { title: 3, description: 2, body: 1 }, // 搜索权重分配
fuzzy: 0.2, // 开启智能模糊匹配
prefix: true, // 开启前缀匹配
},
});
miniSearch.value.addAll(data);
isIndexLoaded.value = true;
};3. 动态上下文高亮提取
在搜索面板中,根据用户输入的词流,实时切割正文关键词前后 40~60 个字符作为结果的摘要预览,并对搜索词进行黄色荧光背景高亮:
javascript
const getSearchExcerpt = (text, query) => {
const index = text.toLowerCase().indexOf(query.toLowerCase());
const start = Math.max(0, index - 40);
const end = Math.min(text.length, index + 60);
return highlightText(text.slice(start, end), query);
};4. 全局搜索组件完整代码 (Search.vue)
点击查看 Search.vue 完整代码
vue
// .vitepress/theme/components/Search.vue
<!-- 全局搜索 -->
<template>
<Modal
:show="store.searchShow"
title="全局搜索"
titleIcon="search"
@mask-click="store.changeShowStatus('searchShow')"
@modal-close="store.changeShowStatus('searchShow')"
>
<!-- Algolia 搜索 -->
<ais-instant-search
v-if="store.searchType === 'algolia'"
:search-client="searchClient"
:future="{
preserveSharedStateOnUnmount: true,
}"
index-name="crawler_test"
@state-change="searchChange"
>
<ais-configure :hits-per-page.camel="8" />
<ais-search-box placeholder="想要搜点什么" autofocus />
<ais-hits v-if="hasSearchValue">
<template v-slot="{ items }">
<Transition name="fade" mode="out-in">
<div v-if="formatSearchData(items)?.length" class="search-list">
<div
v-for="(item, index) in formatSearchData(items)"
:key="index"
class="search-item s-card hover"
@click="jumpSearch(item.url)"
>
<p class="title" v-html="item.title" />
<p v-if="item?.anchor" class="anchor" v-html="item.anchor" />
<p v-if="item?.content" class="content s-card" v-html="item.content" />
</div>
</div>
<div v-else class="no-result">
<i class="iconfont icon-search-empty" />
<span class="text">搜索结果为空</span>
</div>
</Transition>
</template>
</ais-hits>
<ais-pagination v-if="hasSearchValue" />
<ais-stats>
<template v-slot="{ processingTimeMS }">
<div class="information">
<span v-if="hasSearchValue" class="text"> 本次用时 {{ processingTimeMS }} 毫秒 </span>
</div>
<a class="power" href="https://www.algolia.com/" target="_blank">
<i class="iconfont icon-algolia" />
<span class="name">Algolia</span>
</a>
</template>
</ais-stats>
</ais-instant-search>
<!-- 本地搜索 -->
<div v-else class="local-search">
<div class="ais-SearchBox">
<input
v-model="localSearchQuery"
class="ais-SearchBox-input"
type="search"
placeholder="搜索文章标题、描述或正文"
autofocus
@input="handleLocalSearch"
/>
</div>
<div v-if="localSearchQuery" class="ais-Hits">
<Transition name="fade" mode="out-in">
<div v-if="localSearchResults.length" class="search-list">
<div
v-for="(item, index) in localSearchResults"
:key="index"
class="search-item s-card hover"
@click="jumpSearch(item.regularPath)"
>
<p class="title" v-html="highlightText(item.title, localSearchQuery)" />
<p
v-if="item.description"
class="content s-card"
v-html="highlightText(item.description, localSearchQuery)"
/>
<p
v-else-if="item.body"
class="content s-card"
v-html="getSearchExcerpt(item.body, localSearchQuery)"
/>
</div>
</div>
<div v-else class="no-result">
<i class="iconfont icon-search-empty" />
<span class="text">未找到相关文章</span>
</div>
</Transition>
</div>
<div class="ais-Stats">
<div class="information">
<span v-if="localSearchQuery" class="text">
共找到 {{ localSearchResults.length }} 篇相关文章
</span>
</div>
<div class="power">
<i class="iconfont icon-search" />
<span class="name">本地搜索 (MiniSearch)</span>
</div>
</div>
</div>
</Modal>
</template>
<script setup>
import { mainStore } from "@/store";
import { liteClient } from "algoliasearch/lite";
import MiniSearch from "minisearch";
const store = mainStore();
const router = useRouter();
const { theme } = useData();
const { appId, apiKey } = theme.value.search;
const searchClient = liteClient(appId, apiKey);
// 是否具有搜索词
const hasSearchValue = ref(false);
// 搜索变化
const searchChange = ({ uiState, setUiState }) => {
const searchData = Object.values(uiState);
hasSearchValue.value = searchData.length > 0 && searchData[0].query?.length > 0;
setUiState(uiState);
};
// 本地搜索数据
const localSearchQuery = ref("");
const localSearchResults = ref([]);
const miniSearch = ref(null);
const isIndexLoaded = ref(false);
// 初始化 MiniSearch
const initMiniSearch = async () => {
if (isIndexLoaded.value) return;
try {
const response = await fetch(`/search.json?v=${theme.value.buildTime}`);
const data = await response.json();
miniSearch.value = new MiniSearch({
fields: ["title", "description", "body"], // 搜索字段
storeFields: ["title", "description", "body", "regularPath"], // 返回字段
searchOptions: {
boost: { title: 3, description: 2, body: 1 }, // 权重
fuzzy: 0.2, // 模糊匹配
prefix: true, // 前缀匹配
},
});
miniSearch.value.addAll(data);
isIndexLoaded.value = true;
} catch (error) {
console.error("加载本地搜索索引失败:", error);
}
};
// 监听搜索框打开
watch(() => store.searchShow, (val) => {
if (val && store.searchType === "local") {
initMiniSearch();
}
});
// 监听搜索类型切换
watch(() => store.searchType, (val) => {
if (val === "local" && store.searchShow) {
initMiniSearch();
}
});
// 处理本地搜索
const handleLocalSearch = () => {
if (!localSearchQuery.value) {
localSearchResults.value = [];
return;
}
if (!miniSearch.value) {
// 如果索引还没加载完,先用基础过滤
const query = localSearchQuery.value.toLowerCase();
localSearchResults.value = theme.value.postData.filter((post) => {
return (
post.title?.toLowerCase().includes(query) ||
post.description?.toLowerCase().includes(query)
);
});
return;
}
// 使用 MiniSearch 全文搜索
const results = miniSearch.value.search(localSearchQuery.value);
localSearchResults.value = results;
};
// 获取正文摘要并高亮
const getSearchExcerpt = (text, query) => {
if (!text || !query) return "";
const index = text.toLowerCase().indexOf(query.toLowerCase());
if (index === -1) return text.slice(0, 100) + "...";
const start = Math.max(0, index - 40);
const end = Math.min(text.length, index + 60);
let excerpt = text.slice(start, end);
if (start > 0) excerpt = "..." + excerpt;
if (end < text.length) excerpt = excerpt + "...";
return highlightText(excerpt, query);
};
// 高亮文本
const highlightText = (text, query) => {
if (!text || !query) return text;
const regex = new RegExp(`(${query})`, "gi");
return text.replace(regex, "<mark>$1</mark>");
};
// 处理搜索结果
const formatSearchData = (data) => {
const results = [];
// 遍历搜索结果
for (let i = 0; i < data.length; i++) {
const search = data[i];
// 若无 anchor
// if (search.anchor === "" || search.anchor === "app") continue;
// 获取数据
const url = search?.url;
const type = search.type === "lvl1" ? "post" : "content";
const title = search._highlightResult?.hierarchy?.lvl1?.value;
const anchor = search._highlightResult?.hierarchy?.[search.type]?.value;
const content = search._highlightResult?.content?.value;
// 生成搜索数据
const searchData = { url, type, title, anchor, content };
results.push(searchData);
}
return results;
};
// 跳转搜索结果
const jumpSearch = (url) => {
store.changeShowStatus("searchShow");
router.go(url);
};
onBeforeUnmount(() => {
hasSearchValue.value = false;
localSearchQuery.value = "";
localSearchResults.value = [];
});
</script>
<style lang="scss">
.ais-InstantSearch,
.local-search {
height: 100%;
.ais-SearchBox {
height: 40px;
width: 100%;
.ais-SearchBox-input {
width: 100%;
outline: none;
border-radius: 8px;
font-size: 16px;
padding: 0.6rem 1rem;
color: var(--main-font-color);
font-family: var(--main-font-family);
border: 1px solid var(--main-card-border);
background-color: var(--main-card-second-background);
transition:
border-color 0.3s,
box-shadow 0.3s;
&:focus {
border-color: var(--main-color);
box-shadow: 0 8px 16px -4px var(--main-color-bg);
}
&::-webkit-search-cancel-button {
display: none;
}
}
.ais-SearchBox-loadingIndicator,
.ais-SearchBox-submit,
.ais-SearchBox-reset {
display: none;
}
}
.ais-Hits {
margin-top: 20px;
min-height: 300px;
height: 100%;
.no-result {
height: 300px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.iconfont {
font-size: 40px;
margin-bottom: 12px;
}
.text {
font-size: 18px;
opacity: 0.6;
}
}
.search-list {
.search-item {
margin-bottom: 12px;
.title {
display: inline;
font-size: 16px;
margin-bottom: 6px;
}
.anchor {
margin-top: 6px;
color: var(--main-font-second-color);
font-size: 14px;
&::before {
content: "# ";
}
}
.content {
color: var(--main-font-second-color);
margin-top: 0.8rem;
font-size: 12px;
padding: 8px;
border-radius: 8px;
}
p {
margin: 0;
mark {
background-color: transparent;
color: var(--main-color);
}
}
&:last-child {
margin-bottom: 0;
}
}
}
}
.ais-Pagination {
margin-top: 20px;
.ais-Pagination-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.ais-Pagination-item {
margin: 0 4px;
width: 30px;
height: 30px;
border-radius: 8px;
transition: background-color 0.3s;
cursor: pointer;
.ais-Pagination-link {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
&:hover {
color: var(--main-font-color);
}
}
&:hover {
color: var(--main-font-color);
background-color: var(--main-color);
.ais-Pagination-link {
color: var(--main-card-border);
}
}
&.ais-Pagination-item--selected {
font-weight: bold;
background-color: var(--main-color);
.ais-Pagination-link {
color: var(--main-card-border);
}
}
&.ais-Pagination-item--disabled,
&.ais-Pagination-item--nextPage,
&.ais-Pagination-item--lastPage {
opacity: 0.8;
}
}
}
}
.ais-Stats {
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
margin-top: 20px;
opacity: 0.8;
font-size: 14px;
.power {
display: flex;
flex-direction: row;
align-items: center;
font-size: 16px;
opacity: 0.6;
transition:
color 0.3s,
opacity 0.3s;
.iconfont {
margin-right: 4px;
font-size: 20px;
transition: color 0.3s;
}
.name {
font-weight: bold;
}
&:hover {
opacity: 1;
color: var(--main-color);
.iconfont {
color: var(--main-color);
}
}
}
@media (max-width: 512px) {
justify-content: center;
.information {
display: none;
}
}
}
}
</style>基于 MiniSearch 的本地离线全文检索系统https://blog.chgr.cc/posts/2026/0513
评论 隐私政策