序言
我之前使用的 Fuwari 主题,默认是内置了 PageFind 来作为文章搜索工具的。 尽管 PageFind 并不是一个很差的解决方案,但是作为一个纯客户端搜索,还是带来了一些小问题的:
- 中文的分词做得相对比较差
- 依赖于一个索引文件,而索引文件会随着文章大小增大而增大。 在这种情况下,我决定使用一款带有服务端的搜素服务来解决问题。
选择搜索服务
目前主流的搜索服务中,有牛逼但是非常重量级的 ElasticSearch,也有 Algolia 这这种付费 SaaS 选手。
而开源、省资源且可以自己建立的情况下,则只剩下了 TypeSense 和 MeiliSearch 这两位。
TypeSense相对而言对复杂搜索做的比较好,但是对于博客的搜素,TypeSense 更高的硬件要求则显得有些多此一举。
相对于 TypeSense,MeiliSearch尽管缺失了很多高级功能,比如 StartWith 这些,但是其有特殊的分词优化,对于中文博客搜索还是绰绰有余的。
因为,我选择使用 MeiliSearch 来进行搜索服务的实现。
创建 MeiliSearch 服务
安装 MeiliSearch
MeiliSearch 不支持多节点,自然也不需要像 ElasticSearch 那样复杂的安装,一个简单的 DockerCompose 足够拉起来整个项目。
services:    meilisearch:        command: /bin/meilisearch        environment:            MEILI_CONFIG_FILE_PATH: /config/config.toml            MEILI_MASTER_KEY: ${MEILI_MASTER_KEY}        image: getmeili/meilisearch:v1.11.3        ports:            -  7700:7700        restart: always        volumes:            - ./config:/config            - ./data:/meili_data            - ./dumps:/dumps            - ./snapshots:/snapshots这里唯一需要注意的是,设置一个比较复杂的管理员秘钥,越长越好那种,之后所有的操作都是围绕这个秘钥进行的,这个秘钥也需要保管好。
之后所有的请求,均需要在请求头中添加 Authorization 字段,内容则需要以Bareer <YOUR_KEY>这样的形式书写。
创建索引
创建 MeiliSearch 索引的唯一方法就是通过 API。按照官方的文档,使用下面的方法创建即可:
curl \  -X POST 'http://localhost:7700/indexes' \  -H 'Content-Type: application/json' \  --data-binary '{    "uid": "articles",    "primaryKey": "id"  }'这里一共有两个字段需要设置,一个是uid,这个字段就是索引的名字。另一个字段是primaryKey,是文档的唯一标识符。
而与 ElasticSearch 等服务不同的是,MeiliSearch 的搜索是需要额外手动提供文档 ID 的。这一点在之后部署的时候需要注意。
创建搜索专用 Key
由于 MasterKey 有着极高的权限。我们有必要为前台搜索提供专门的 Key 并限制具体访问的 API。 MeiliSearch 可以通过一下的 API 来创建 Key:
curl \  -X POST 'http://localhost:7700/keys' \  -H 'Authorization: Bearer MASTER_KEY' \  -H 'Content-Type: application/json' \  --data-binary '{    "description": "The API key for search the articles",    "actions": ["search"],    "indexes": ["articles"],    "expiresAt": "2042-04-02T00:42:42Z"  }'这里同样有两个字段需要注意:
- actions:这个字段主要限制 key 的权限范围,具体可用参数可以 参考这里。
- indexes:这个参数则主要限制可以使用的索引名称,里面是有索引 UID 组成的数组。这里我们仅仅设置我们之前设置的索引 ID 即可。
按照官方提供的例子,下面的字段key就是我们创建好的 key
3 collapsed lines
{  "name": null,  "description": "Manage documents: Products/Reviews API key",  "key": "d0552b41536279a0ad88bd595327b96f01176a60c2243e906c52ac02375f9bc4",11 collapsed lines
  "uid": "6062abda-a5aa-4414-ac91-ecd7944c0f8d",  "actions": [    "documents.add"  ],  "indexes": [    "products"  ],  "expiresAt": "2021-11-13T00:00:00Z",  "createdAt": "2021-11-12T10:00:00Z",  "updatedAt": "2021-11-12T10:00:00Z"}录入文档
由于 Astro 是 SSG 框架,所以索引的录入只能放在编译阶段。 目前其实有两种思路来录入文档,一种是直接读取 Markdown 文件,另外一种是读取编译好的页面。
根据我的实验,建议在页面编译完成后再录入文档,其中主要原因在于:
- Astro 的页面地址在编译前可能并不是确定的。
- 部分文档类型如 MDX 可能会引入很多没用的东西。
按照逻辑,我们应该可以使用下面的代码来读取并生成页面的元数据信息:
async function getPosts() {    const postsDirectory = path.join(process.cwd(), 'dist', 'posts')    const folders = await readdir(postsDirectory)
    const posts = await Promise.all(        folders.map(async folderName => {            const filePath = path.join(postsDirectory, folderName, 'index.html')            const source = await readFile(filePath, 'utf-8')
            // Parse HTML content using JSDOM            const dom = new JSDOM(source)            const document = dom.window.document
            // Get the main content            const contentElement = document.querySelector('.markdown-content')            const content = contentElement ? contentElement.textContent.trim() : ''
            // Get metadata from JSON-LD            const scriptElement = document.querySelector(                'script[type="application/ld+json"]',            )            const metadata = scriptElement                ? JSON.parse(scriptElement.textContent)                : {}
            return {                id: nanoid(),                content,                title: metadata.headline || '',                description: metadata.description || '',                keywords: metadata.keywords || [],                author: metadata.author?.name || '',                datePublished: metadata.datePublished || '',                language: metadata.inLanguage || 'en',                url: `${siteUrl}/posts/${folderName}`,            }        }),    )
    return posts}上边是本站正在使用的部分,简单讲解下就是以下的思路:
- 读取生成目录下的所有文章的 HTML。
- 读取页面中的元数据部分。(部分主题,如这里,可能已经将元数据做成了 json 格式)
- 去除文章中多余的 HTML 代码部分。
- 生成 ID,整理并返回页面的元数据。
由于截止目前,MeiliSearch 还不支持嵌套值的搜索,所以建议将页面的可搜索字段拉平。
在获取到文章后,则可以将所有的文章提交到 MeiliSearch 服务了。
const client = new MeiliSearch({host: MEILISEARCH_HOST, apiKey: YOUR_MASTERKEY})
const posts = await getPosts()const indexName = 'articles'
// Get the index instanceconst index = await client.getIndex(indexName)
await index.deleteAllDocuments()
// Add documents to the indexawait index.addDocuments(posts);前端实现搜索功能
与 ElasticSearch 不同,MeiliSearch 支持将API直接暴露在前端,那么,自然就可以直接与 MeiliSearch 的搜索接口通讯。
可以使用下面的代码与自己的 MeiliSearch 对接:
async function useMeiliSearch (keyword: string) {    if (!keyword){        return {            hits:[]        }    }    return (await fetch(`${import.meta.env.PUBLIC_MEILISEARCH_HOST}/indexes/articles/search`, {        method: 'POST',        headers: {            "content-type": "application/json",            'authorization': `Bearer ${import.meta.env.PUBLIC_MEILISEARCH_SEARCH_KEY}`,        },        body: JSON.stringify({            'q': keyword,            "attributesToHighlight":["content","title","description"], //这里填写需要高亮的字段            "attributesToCrop":["content"], //这里填写需要剪裁的字段。建议将文章剪裁来实现高亮。            "attributesToRetrieve":["url"],            "highlightPostTag":"</mark>", //这两个是高亮使用的 HTML TAG            "highlightPreTag":"<mark>"        })    })).json()}如果你想在客户端(浏览器)获取到编译时的部分环境变量,可以在变量前加入 PUBLIC_这个前缀来解决。
至于与主题的集成,则由于每个人的主题不同,有着不同的集成方法。
如本站,则是简单的替换了原主题使用的 PageFind方法实现的。
再次就不一而足了。
关于安全性
集成 MeiliSearch 的操作其实本身并不复杂。但是其周边生态其实有不少要考虑的。
比如如果使用第三方 CI 或 Serverless 部署,可能需要专门为其提供秘钥和访问方法来限制普通用户的访问。
对于这种情况,可以使用 Cloudflare Access 的 Service Auth 来避免公网直接访问非搜索接口的问题。
而对于搜索接口本身,同样建议反向代理指定 API 并增加速率限制等方式防止被刷。

