优雅地构建Hexo本地搜索引擎

  1. 1. 准备工作
    1. 1.1. 搜索页面
    2. 1.2. 内容数据
  2. 2. 搜索之前
    1. 2.1. 获取关键词
    2. 2.2. 获取数据
    3. 2.3. 格式化数据
  3. 3. 开始搜索
    1. 3.1. 分割关键词
    2. 3.2. 文章检索
    3. 3.3. 标记高亮
    4. 3.4. 汇总结果
  4. 4. 输出内容
  5. 5. 参考资料

对于像Hexo这样的静态博客而言,有一种痛苦叫不知道怎么处理搜索内容。自然有一种方法是依傍大型的搜索引擎,例如本主题的代码主要参考来源——landscape(同时也是Hexo的默认主题),使用的就是依赖于Google的搜索方式。诚然,也有swiftype这样的第三方公司为站点提供数据收录与搜索引擎集成,但暂且不说完全依赖第三方的服务是否具有合理的稳定性,光是其高昂的售价就足以让人望而却步了。因此,如何构建一个独立的搜索库,以便于用户更方便地搜寻所需要的资源,对于我们Hexo站长而言,自然也就成为了一大急需思考的难题。

实现了本地搜索功能的主题也不在少数,例如NexTSuka等等,考虑到NexT需要另外安装不利于自定义的搜索生成插件,而Suka则实现了从生成到搜索的完整过程,因而本次我就是以Suka为参照,构建一个至少能比较正常地工作的本地搜索功能吧。

现在已经可以将搜索相关的配置项全部放入主题配置里去啦!不再需要修改站点设置了的说呢,具体可以参见[这篇文章](/posts/process-with-theme-config-using-process-after/)哦~

准备工作

要想实现搜索,无非就是需要两大模块:搜索页面内容数据库。如何在Hexo默认不带有的路由情况下,新建一个搜索专属的页面呢?Hexo为我们提供了许多API可以使用。官方给出的样例非常简洁,因此我们可以根据第三方的教程参照,发现更多实现相关的细节。

搜索页面

例如,以下这一段代码,可以调用主题的layout/_pages/search-page这个页面,而路由的切入点,则是*sitedir/search/*。

1
2
3
4
5
6
7
8
// Generate search page
hexo.extend.generator.register('searchPage', function(locals){
return {
path: 'search/index.html',
data: locals.posts,
layout: '_pages/search-page'
};
});

将这一段代码写入以.js结尾的文件中,保存在主题的scripts文件夹内,那么当hexo运行的时候,就会被页面生成进程调用,从而生成出搜索页面对应的文件与文件夹。

同时,我们需要给出_pages/search-page这个页面的具体配置。请注意,使用layout调用时,会按照主题的layout.ejs给出对应页面的样式,因此请记得保持相关页面的内容一致哦

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<section class="col-md-8">
<style>
.input-group{display:flex;width:80%;margin:30px auto;}
#search-input{flex:auto;margin:0 5px;border-radius:5px;padding:0 8px;}
#kr-search-notice.alert{transition:.3s}
#result-posts .kr-search-result{margin:auto;height:auto;width:90%;}
#result-posts m{color:#333;background-color:yellow;}
</style>
<div class="kratos-hentry kratos-post-inner clearfix">
<div class="kratos-post-content">
<h2 class="title-h2"><%- __('search') %></h2>
<form class="input-group" onsubmit="return inpSearch();">
<input class="form-input input-lg" id="search-input" maxlength="80" name="s" placeholder="<%- __('search_notice') %>" required type="search">
<button class="btn btn-primary" type="submit"><%- __('search_submit') %></button>
</form>
</div>
</div>

<div class="alert" id="kr-search-notice"></div>

<div id="result-posts"></div>

</section>

<script>var searchDataFile = "<%- config.root + (config.kratos_rebirth.search.path || 'search.json') %>";</script>
<script defer src="/js/local-search.min.js"></script>

注意,由于无法通过js直接调用Hexo的设置,因此此处单独将搜索文件的路径进行了提取。

对于表单默认的提交跳转事件会导致页面的强制刷新,我们需要使用return false进行拦截;为了运行搜索函数,我们让搜索函数返回值也变成false,然后将该拦截时间返回至表单,以防止出现跳转即可。

内容数据

如果是使用第三方接口的话,兴许到此就已经是基本完成了;但既然要构建本地搜索,我们还需要一个用来搜索的“数据库”。

有一个插件叫做hexo-generator-search,可以生成便于搜索使用的相关数据文件;但我们主题展示的页面里,存在的还不仅仅只有所列出来的这一些条目。如果需要用户为此专门去安装这个插件并进行相关代码的修改,那显然会带来更多难以维护的困难情况。因此,可以参照相关的生成方式,构建属于我们主题自己的搜索数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
const pathFn = require('path');
const { stripHTML } = require('hexo-util');

let config = hexo.config.kratos_rebirth.search;

// Generate search database

// Set default search path
if (!config.path) config.path = 'search.json';

if (pathFn.extname(config.path) === '.json') {
hexo.extend.generator.register('searchdb', function(locals){
const url_for = hexo.extend.helper.get('url_for').bind(this);

const parse = (item) => {
let _item = {};
if (item.title) _item.title = item.title;
if (item.date) _item.date = item.date;
if (item.path) _item.url = url_for(item.path);
if (item.tags && item.tags.length > 0) {
_item.tags = [];
item.tags.forEach((tag) => {
_item.tags.push([tag.name, url_for(tag.path)]);
});
}
if (item.categories && item.categories.length > 0) {
_item.categories = [];
item.categories.forEach((cate) => {
_item.categories.push([cate.name, url_for(cate.path)]);
});
}
if (hexo.config.kratos_rebirth.search.content && item.content) {
_item.content = stripHTML(item.content.trim().replace(/<pre(.*?)\<\/pre\>/gs, ''))
.replace(/\n/g, ' ').replace(/\s+/g, ' ')
.replace(new RegExp('(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]', 'g'), '');
}
return _item;
};

const searchfield = config.field;

let posts, pages;

if (searchfield) {
if (searchfield === 'post') {
posts = locals.posts.sort('-date');
} else if (searchfield === 'page') {
pages = locals.pages;
} else {
posts = locals.posts.sort('-date');
pages = locals.pages;
}
} else {
posts = locals.posts.sort('-date');
}

let res = [];

if (posts) {
posts.each((post) => {
res.push(parse(post));
});
}
if (pages) {
pages.each((page) => {
res.push(parse(page));
});
}

return {
path: config.path,
data: JSON.stringify(res)
};
});
}

为了能给这个主题的使用者提供更多的客制化选项,对于主题这个搜索功能,我选择设置了一个用于控制的开关。

但Hexo的API似乎没有提供直接读取主题配置参数的设置,因此需要将相关的设置代码写入站点的配置文件中。(可参见这篇文章进行修改调整)

1
2
3
4
5
6
kratos_rebirth:
search:
enable: true
path: search.json
field: post # page | post | all. Default post
content: true # Include post | page content

准备工作到此就已经结束,接下来就是在页面上调用搜索数据库,对于内容进行搜索了。

搜索之前

获取关键词

由于我们提供了两种方式,一种是直接带请求链接的搜索,另一种是表单的手动提交事件进行搜索,因此我们需要两种对应处理的方式,即一种是通过获取窗口的URL并进行解码来获取参数,另一种是通过获取表单的输入内容来搜索。

这里参考Suka的窗口参数获取事件给出了一个参考的函数写法:

1
2
3
4
5
6
7
function getParam(reqParam) {
// 获取参数
reqParam = reqParam.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]");
const paraReg = new RegExp('[\\?&]' + reqParam + '=([^&#]*)');
const results = paraReg.exec(window.location);
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
}

获取以参数reqParam开头的所有参数(我传入的是’s’),获取URL的请求路径。

这个事件在页面载入时即可触发,因此我们可以设置如下的函数来调用:

1
2
3
4
5
6
7
8
9
(()=>{
const skeys = getParam('s');
if (skeys !== '') {
// 存在关键词,把搜索词放到输入框里面
document.getElementById('search-input').value = skeys;
// 开始搜索
keySearch(skeys);
}
})();

为了提升用户交互的友好性,我还选择将关键词放入输入框内,以便于用户的交互、修改和后续的处理等。

而对于使用输入表单的提交搜索,直接调用相关的搜索函数即可;此处为了链接的双向同步,使用了浏览器的pushState事件来触发一次不刷新页面的浏览器地址变更(使用了正则表达式来处理空格问题):

1
2
3
4
5
6
7
8
9
function inpSearch() {
// 单击按钮检索
const skeys = document.getElementById('search-input').value;
// 更新URL
window.history.pushState({},0,window.location.href.split('?')[0]+'?s=' + skeys.replace(/\s/g, '+'));
// 开始搜索
keySearch(skeys);
return false;
}

请注意对于一些特殊HTML字符的转义传参,否则可能在结果生成的时候带来不必要的困难:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function searchEscape(keyword) {
const htmlEntityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\'': '&#39;',
'/': '&#x2F;'
};

return keyword.replace(/[&<>"'/]/g, function (i) {
return htmlEntityMap[i];
});
}

同时也有一些会影响正则表达式的字符,可以用类似的方式进行处理;但这样做相当于直接禁用了正则表达式的搜寻方式:(如有必要,可以设置一个切换开关)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function regEscape(keyword) {
const regEntityMap = {
'{': '\\\{',
'}': '\\\}',
'[': '\\\[',
']': '\\\]',
'(': '\\\(',
')': '\\\)',
'?': '\\\?',
'*': '\\\*',
'.': '\\\.',
'+': '\\\+',
'^': '\\\^',
'$': '\\\$'
};

return keyword.replace(/[\{\}\[\]\(\)\?\*\.\+\^\$]/g, function (i) {
return regEntityMap[i];
});
}

keySearch为传入参数后的搜索函数,可以这样来处理:

1
2
3
4
5
6
7
8
9
10
11
12
function keySearch(skeys) {
// 设置搜索提示
setNotice('info', '正在加载搜索文件...');

// 启动进度条
if (typeof NProgress !== 'undefined') {
NProgress.start();
}

// 加载数据并搜索
loadDataSearch(searchDataFile, searchEscape(skeys));
}

获取数据

jQuery有封装XHR操作,可以使用ajax来获取数据;而ES6也有引入一个新的XHR方式:Fetch API,可以更加优雅地实现获取数据的操作。相关格式的的简单样例如下:

1
2
3
4
5
6
7
8
9
fetch('path/to/file')
.then((res)=>{
// 后续操作
...
})
.catch((error)=>{
// 错误处理
...
});

具体的使用方法可参见MDN的文档:使用 Fetch

格式化数据

我们fetch到的是一个text类型传输的json序列,无法直接被使用,因此需要进行.json()操作将其转化成一个json对象。但不知为何,直接使用此操作似乎无法有效将其转化为可以forEach的对象。因此我们可以将其作为一个函数的参数进行传出。具体的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fetch('path/to/file')
.then((res)=>{
// 文件加载完成提示
...
return res.json();
})
.then((datas)=>{
// 具体操作细节
...
})
.catch((error)=>{
// 错误处理
...
});

在第二个.then里得到的datas,就可以进行forEach操作啦。

开始搜索

当数据准备完成,提示信息也已经给出之后,是时候开始正式的搜索过程啦。

搜索的过程主要分为三步,先是分割关键词、再是针对每一篇文章进行关键词检索、最后是汇总结果并标出高亮内容。

分割关键词

为了避开大小写对于搜索结果的影响,将搜索关键词去除两端空格后转换成小写,再以空格等分隔符进行分割即可。例如,可以使用这样的方式:

1
let keywords = skeys.trim().toLowerCase().split(/\s/);

其中的skeys是带有空格的搜索字符串,如"关键词1 关键词2 关键词3 ..."等;经过转换后的keywords则成了一个数组,如["关键词1", "关键词2", "关键词3", ...]这样的格式,便于后续的搜索操作。

文章检索

对于文章,一般主要关心的是文章的标题和内容,因此搜索模块也从这两方面进行着手考虑。

为了避免大小写导致的结果减少,我们同样将文章的标题和内容都转换成小写的字符串:

1
2
const dataTitle      = data.title.trim().toLowerCase();
const dataContent = data.content ? data.content.trim().replace(/<[^>]+>/g, '').toLowerCase() : '';

为了表达搜索数据的权重关系,我们引入一个权重标记,并规定一个简单的权重算法:当标题中出现关键词时,该文章的权重+2;当内容中出现关键词时,该文章的权重+1。

但是这个权重算法很简陋,而且在我目前的代码中没有很好的被实现,因此只是提供一个参考的思路吧。

完成设计之后,我们就可以开始当前文章的检索工作了。先假设当前文章里没有任何关键词,再在搜寻的过程中,如果有发现匹配成功,则将该文章标记为有关键词的文章即可。

参考的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let matched = false;
keywords.forEach((keyword)=>{
indexs.title = dataTitle.indexOf(keyword);
indexs.content = dataContent.indexOf(keyword);
if (indexs.title !== -1 || indexs.content !== -1) {
matched = true;
// 标记内容的最初与最终出现位置
...
// 权重计算
dataWeight += indexs.title !== -1 ? 2 : 0;
dataWeight += indexs.content !== -1 ? 1 : 0;
resultCount++;
}
});

标记高亮

在一篇文章搜索完成后,就已经可以将其中的高亮内容进行标记了,以便于后续的处理。对于之前设置的matched参数,此使便可以用于控制是否需要进行标记。一个样例的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (matched) {
let tPage = {};
tPage.title = data.title;
keywords.forEach((keyword)=>{
const regS = new RegExp(regEscape(keyword) + '(?!>)', 'gi');
tPage.title = tPage.title.replace(regS, '<m>$&</m>');
});
if (indexs.firstOccur >= 0) {
const halfLenth = 100;
// 准备需要高亮的内容区段
...
tPage.content = dataContent.substr(start, end-start);
keywords.forEach((keyword)=>{
const regS = new RegExp(regEscape(keyword) + '(?!>)', 'gi');
tPage.content = tPage.content.replace(regS, '<m>$&</m>');
});
}
resultArray.push([tPage, dataWeight]);
}

请注意我使用到的(?!>),这一部分是用于将可能存在的搜索结果里的HTML高亮标签(以>来辨识)进行排除,以免因搜索关键词中出现标签名,而导致重复嵌套的显示错误出现。

汇总结果

由之前的搜索权重,对每一个结果进行排序,以便于优先显示关联度更高的内容。如果没有搜索到任何结果,则直接返回没有输出。

1
2
3
4
5
6
7
8
9
10
11
if (resultCount !== 0) {
const finishTime = performance.now();
setNotice('success', '找到 ' + resultCount + ' 条搜索结果,用时 ' + Math.round((finishTime - startTime)*100)/100 + ' 毫秒~');
resultArray.sort((a, b)=>{
return b[1] - a[1];
});
createPosts(resultArray);
} else {
setNotice('danger', '什么都没有找到欸...');
clearPosts();
}

输出内容

没什么特别需要注意的地方,只是不要忘记将之前的结果清空即可。由于我使用了整体的替换选项,因此直接就可以进行覆盖。

我使用了ES6的字符串模板,因此能更有效地提升相关内容处理的效率,和后期的便于维护性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
function createPosts(resArr) {
const resultSectionElement = document.getElementById('result-posts');
let resultString = '';

resArr.forEach((resInfo)=>{
const pageInfo = resInfo[0];
let pageTags = '';
pageInfo.tags.forEach((tag, i)=>{
pageTags += i ? ', ' : '';
const postTagTemplate = `<a class="tag-link" href="${tag[1]}" rel="tag">${tag[0]}</a>`;
pageTags += postTagTemplate;
});
const postTemplate = `
<article class="kratos-hentry clearfix">
<div class="kratos-entry-border-new clearfix">
<div class="kratos-post-inner-new kr-search-result">
<header class="kratos-entry-header-new">
<a class="label-link" href="${pageInfo.category[1]}">${pageInfo.category[0]}</a>
<h2 class="kratos-entry-title-new"><a href="${pageInfo.link}">${pageInfo.title}</a></h2>
</header>
<div class="kratos-entry-content-new">
<p>...${pageInfo.content}...</p>
</div>
</div>
<div class="kratos-post-meta-new">
<span class="pull-left">
<a><i class="fa fa-calendar"></i></a><a>${pageInfo.date}</a>
<a><i class="fa fa-tags"></i></a>
${pageTags}
</span>
<span class="pull-right">
<a class="read-more" href="${pageInfo.link}" title="阅读全文">阅读全文 <i class="fa fa-chevron-circle-right"></i></a>
</span>
</div>
</div>
</article>
`;

resultString += postTemplate;
});
resultSectionElement.innerHTML = resultString;
}

到此,基本功能就已经实现了。由于为了功能的细节划分,部分代码进行了一定的修改;同时后续也将进行持续的升级,以便于提供更好的性能。具体的代码可以参见GitHub上的源文件:

参考资料