本文介绍了 Hexo Butterfly 主题下 Algolia 搜索的使用

本文首发在语雀 自动同步更新至CC 的部落格

注册账号

前往 Algolia 官网注册一个账号,新建 应用和 index 数据中心建议选择新加坡或香港,当然根据你自己情况而定

安装插件

npm install hexo-algolia --save
npm install hexo-algoliasearch --save

分别是 hexo-algoliahexo-algoliasearch,他们的介绍分别为

Index your hexo website content to Algolia Search.
🔎 A plugin to index posts of your Hexo blog on Algolia

也就很明显了,如果你想要全站搜索可选择前者,如果你只想搜索文章两者兼可。但前者不能将文章内容作为索引上传(其实老版本是支持的,但因为索引大小限制,在新版本取消索引了文章内容),后者目前仍可全文上传。然后就是 HEXO 配置文件中添加以下内容,下文基本以 hexo-algoliasearch 为例,因为我个人认为访客只会搜文章吧(事实上是搜索根本没人用,毕竟也根本没人访问),hexo-algolia 可查看官方文档,注意配置和命令的区别

_config.yml :

algolia:
appId: "Z7A3XW4R2I"
apiKey: "12db1ad54372045549ef465881c17e743"
adminApiKey: "40321c7c207e7f73b63a19aa24c4761b"
chunkSize: 5000
indexName: "my-hexo-blog"
fields:
- content:strip:truncate,0,500
- excerpt:strip
- gallery
- permalink
- photos
- slug
- tags
- title

为了保险,识别到插件,还可以加入以下内容

plugins:
- hexo-algoliasearch

去主题配置文件打开 Algolia 搜索,记得关闭本地搜索,二者只能取其一!

_config.butterfly.yml :

# Algolia search
algolia_search:
enable: true
hits:
per_page: 3

# Local search
local_search:
enable: false

然后来看以下具体的参数配置获取方式appIdapiKeyadminApiKey可在 API Keys 页面获取,注意保管好你的 Admin Key,不要让其他人知道,不建议直接写在配置中 对于 Windows 系统,如果你不想每次都进行设定变量操作,可以添加ALGOLIA_ADMIN_API_KEY到系统的环境变量中 而 hexo-algolia 插件环境变量名称为 HEXO_ALGOLIA_INDEXING_KEY 注意根据对应的文档更改,当然也可以使用命令行工具

# Windows
## 微软的 powershell)
$env:ALGOLIA_ADMIN_API_KEY = ""

## cmd
建议不用 cmd,正经人不用 cmd

# Linux
## sh/bash
export ALGOLIA_ADMIN_API_KEY=

## fish
set -xg ALGOLIA_ADMIN_API_KEY ""

如果你和我一样使用的自动部署,例如 Github Actions,你可以在工作流中一开始或者对应的步骤添加环境变量,记得 Secrets 中也要添加哦

jobs:
deploy:
name: Deploy Hexo Public To Pages
runs-on: ubuntu-latest
env:
TZ: Asia/Shanghai
ALGOLIA_ADMIN_API_KEY: ${{ secrets.ALGOLIA_ADMIN_API_KEY }}

或者使用

export ALGOLIA_ADMIN_API_KEY=…
export HEXO_ALGOLIA_INDEXING_KEY=…

indexName 即你开始新建的索引名称 其他内容保持默认即可,但对于fields

配置示例

这里选择不截取上传全文,并且删除 html 标签,只留下有用的搜索内容

fields:
- content:strip
- excerpt:strip
- gallery
- permalink
- photos
- slug
- tags
- title

但对于博客来说,没人会按照 tags photos(或者 cover)来搜索吧,所以有些内容不必要上传,并如果你和我一样有多个镜像站,在不改源码(algolia.js)的情况下,不会使用 permalink 而使用 path(改源码可以使用 slug,但没必要),并且只留下必要的内容,如下所示:

algolia:
appId: "947RX7HP3E"
apiKey: "9114b3fa2a3307b2cc8eec7e3ae5a8ea"
chunkSize: 5000
indexName: "ccknbc-blog"
fields:
- path
- title
- content:strip

这样有了标题,全文内容,路径即可在不同镜像站找到对应的页面,而不是跳到主站,当然你选择跳到主站无可厚非。

使用命令

hexo algolia
而在这之前还需要hexo g生成文件
所以具体使用命令就是
hexo cl && hexo g && hexo algolia
或者在未安装 HEXO CLI 的情况下使用以下命令
npm run clean && npm run build && hexo algolia -n && gulp

可选配置

是否删除之前建立好的索引重新建立索引?

hexo algolia -n
或者
hexo algolia --no-clear

注意查看命令行输出信息,然后去官网检查索引是否生成 事实上到这里已经可以获得下图所示的搜索效果(这是冰老师博客的效果,它使用的是 hexo-algolia,毕竟有关于我界面)

Algolia 配置

这里不细节讲,你可以查阅官方文档,虽然有些过时的参数,但结合 Upgrade from v2 to v3 还是勉强能用,蝴蝶已经做好了高亮标题,虽然会查询文章内容,但并不会高亮文章内容节选,我们要做的就是修改部分 js 内容,并对应的设置好 Algolia,以便按照我们要求的优先顺序展示搜索结果,而不是默认的很奇怪的排序,毕竟针对中文分词他是一个一个分不能按照英语那样,针对英文我们可以开启分词查询,驼峰查找而不是盲目的匹配整个单词,并且允许拼错字母或汉字的情况存在,这些都是一个搜索系统要考虑的问题。然后针对搜索速度,我们可以对文章内容进行切片或者属性的刻画,但 V2 所支持的功能实在太少,派的上用场的大概就是 匹配的字词内容,匹配度,匹配内容的摘录(默认 10 个字词),还有高级搜索用法的启用。

JS 修改

主题 4.0.0 以下版本

到这里还没有结束,如果你这样操作就会有一个问题,假设你的访问流量很大,有很多人用搜索功能,那么免费的 1 万次搜索额度可能不够一个月的使用,需要按下ENTER键再执行搜索而不是实时搜索,因此可以稍作修改(blog\themes\butterfly\source\js\search\algolia.js) js 的部分内容,不想动源码的可以保存到其他与主题不冲突的路径,然后更换 CDN 地址即可主要修改以下内容,然后就是排版问题改了改位置,不喜欢的可以不改,很直白就不用过多解释了,这样就可获得和本博客一样的搜索效果了

  search.addWidget(
instantsearch.widgets.searchBox({
container: '#algolia-search-input',
reset: false,
magnifier: false,
+ searchOnEnterKeyPressOnly: true,
placeholder: GLOBAL_CONFIG.algolia.languages.input_placeholder
})
)
search.addWidget(
instantsearch.widgets.hits({
container: '#algolia-hits',
templates: {
item: function (data) {
const link = data.permalink ? data.permalink : (GLOBAL_CONFIG.root + data.path)
return (
'<a href="' + link + '" class="algolia-hit-item-link"><b>' +
data._highlightResult.title.value + '</b><br>'
+ + data._snippetResult.contentStrip.value + '<br>( 匹配字词 : '
+ + data._highlightResult.contentStrip.matchedWords + ' ) | ( 匹配等级 : '
+ + data._highlightResult.contentStrip.matchLevel + ' )</a>'
)
},

主题 4.0.0 以上版本

已经升级到 V4 版本,那么一些特性就可以使用了,修改内容其实差不多,只是建议对于第 87 行的页数限制,主要是为了手机上排版美观,不会转到下一行,但是这样会有一个问题,如果结果超过 5 页,那么将无法显示,最后一页代表第 5 页,所以我个人会选择删掉这个参数限制,同时合并删除了部分代码,以及使用 widget 的 powerby 组件而不是官方的 svg 代码解决方案。另外因为新版官方的每次访问网站都会有一次全局请求,这在消耗免费额度的同时,也影响网站加载的速度,所以修改默认行为为按下回车后再请求同样的部分参数发生了改变(L55-59),可以自行比对或查看官方文档,也可以直接引用我的

特别提一嘴 官方切片方式对我来说 140 个字太长了,所以如果可以接受就用官方的,不用做其他更改,但想要和我的显示方式一样,就注意配置好切片,默认是 10 个词符,毕竟有现成的切片高亮处理可以用,就没必要再来一次了

另外,新版浏览器支持搜索并定位高亮处理,所以对跳转链接也做了处理,算是弥补了一点不能精准定位的缺陷,比如点击如下链接它会跳转到搜索结果对应的位置,至于前后匹配多少字符你们自行修改,但也是处于不太好用的状态,毕竟是分片还是有点奇怪,对英文来说应该是单词识别,只是中文恰巧是另一标准,单字变成了词

#:~:text=勿滥用-,表情,-符号和

CDN:
# search
algolia_js: https://cdn.jsdelivr.net/gh/CCKNBC/ccknbc.github.io/js/search/algolia.js
window.addEventListener("load", () => {
const openSearch = () => {
const bodyStyle = document.body.style;
bodyStyle.width = "100%";
bodyStyle.overflow = "hidden";
btf.animateIn(document.getElementById("search-mask"), "to_show 0.5s");
btf.animateIn(
document.querySelector("#algolia-search .search-dialog"),
"titleScale 0.5s"
);
setTimeout(() => {
document.querySelector("#algolia-search .ais-SearchBox-input").focus();
}, 100);

// shortcut: ESC
document.addEventListener("keydown", function f(event) {
if (event.code === "Escape") {
closeSearch();
document.removeEventListener("keydown", f);
}
});
};

const closeSearch = () => {
const bodyStyle = document.body.style;
bodyStyle.width = "";
bodyStyle.overflow = "";
btf.animateOut(
document.querySelector("#algolia-search .search-dialog"),
"search_close .5s"
);
btf.animateOut(document.getElementById("search-mask"), "to_hide 0.5s");
};

const searchClickFn = () => {
document
.querySelector("#search-button > .search")
.addEventListener("click", openSearch);
};

const searchClickFnOnce = () => {
document
.getElementById("search-mask")
.addEventListener("click", closeSearch);
document
.querySelector("#algolia-search .search-close-button")
.addEventListener("click", closeSearch);
};

const algolia = GLOBAL_CONFIG.algolia;
const isAlgoliaValid = algolia.appId && algolia.apiKey && algolia.indexName;
if (!isAlgoliaValid) {
return console.error("Algolia setting is invalid!");
}

const search = instantsearch({
indexName: algolia.indexName,
searchClient: algoliasearch(algolia.appId, algolia.apiKey),
searchFunction(helper) {
helper.state.query && helper.search();
},
});

const configure = instantsearch.widgets.configure({
hitsPerPage: algolia.per_page || 5,
});

const searchBox = instantsearch.widgets.searchBox({
container: "#algolia-search-input",
showReset: false,
showSubmit: false,
searchAsYouType: false,
placeholder: GLOBAL_CONFIG.algolia.languages.input_placeholder,
showLoadingIndicator: true,
});

const hits = instantsearch.widgets.hits({
container: "#algolia-hits",
templates: {
item(data) {
const link = data.permalink
? data.permalink
: GLOBAL_CONFIG.root + data.path;
const content = data._snippetResult.contentStrip.value;
return `
<a href="${link}#:~:text=${content.substring(
content.indexOf("<mark>") - 3,
content.indexOf("<mark>")
)}-,${content.substring(
content.indexOf("<mark>") + 6,
content.indexOf("</mark>")
)},-${content.substring(
content.indexOf("</mark>") + 7,
content.indexOf("</mark>") + 10
)}" class="algolia-hit-item-link">
<b>${data._highlightResult.title.value || "no-title"}</b>
<br>${content}</br>
匹配字词: <em><mark>${
data._highlightResult.contentStrip.matchedWords
}</mark></em> | 匹配等级: <em><mark>${
data._highlightResult.contentStrip.matchLevel
}</mark></em>
</a>`;
},
empty: function (data) {
return (
'<div id="algolia-hits-empty">' +
GLOBAL_CONFIG.algolia.languages.hits_empty.replace(
/\$\{query}/,
data.query
) +
"</div>"
);
},
},
});

const stats = instantsearch.widgets.stats({
container: "#algolia-info > .algolia-stats",
templates: {
text: function (data) {
const stats = GLOBAL_CONFIG.algolia.languages.hits_stats
.replace(/\$\{hits}/, data.nbHits)
.replace(/\$\{time}/, data.processingTimeMS);
return `<hr>${stats}`;
},
},
});

const powerBy = instantsearch.widgets.poweredBy({
container: "#algolia-info > .algolia-poweredBy",
});

const pagination = instantsearch.widgets.pagination({
container: "#algolia-pagination",
totalPages: algolia.totalPages,
templates: {
first: '<i class="fa-solid fa-angle-double-left" title="第一页"></i>',
last: '<i class="fa-solid fa-angle-double-right" title="最后一页"></i>',
previous: '<i class="fa-solid fa-angle-left" title="上一页"></i>',
next: '<i class="fa-solid fa-angle-right" title="下一页"></i>',
},
});

search.addWidgets([configure, searchBox, hits, stats, powerBy, pagination]); // add the widgets to the instantsearch instance

search.start();

searchClickFn();
searchClickFnOnce();

window.addEventListener("pjax:complete", () => {
getComputedStyle(document.querySelector("#algolia-search .search-dialog"))
.display === "block" && closeSearch();
searchClickFn();
});

window.pjax &&
search.on("render", () => {
window.pjax.refresh(document.getElementById("algolia-hits"));
});
});

效果预览

(这个是因为我修改了源码,实际上也能通过修改 JS 实现,但大多数人不会关心这些搜索小贴士) 对于中文它当作单字匹配 允许拼写错误

特别说明

因两个月前已申请通过,本博客已切换至不限搜索次数的DocSearch!同时也加入了开源计划,但因为 10DSN 太香了,虽然 instantsearch 可玩性更好,但我也只申请了 200k/月的额度(虽然可以增加),所以为了即时搜索我还是选择了白嫖,而且设定为每天自动爬取的话,省去了生成索引上传的这一步骤,节省了自动部署的时间。而且爬取到数据后,前端我并非一定要使用 docsearch 方案,用 instantsearch 配合其他插件也不是不可以。