如何为 Hugo 静态网站添加搜索功能

2023-11-11
标签: HUGO NGINX PYTHON

我的网站使用 Hugo 编译静态页面。在此基础上,支持了搜索和评论等动态内容。我打算分两篇文章介绍具体的实现方法,分享给读者。

本文先介绍搜索功能的实现方法,还有 另一篇文章 介绍如何添加评论。

效果预览

搜索效果如下:

先决条件

关于搭建静态网站的做法,我在 这篇文章 中有简单介绍。要在静态网站添加动态内容,有如下一些条件:

  • 自己掌握的云服务器
  • 反向代理(比如nginx)
  • 数据库(比如 mysql 或 redis )

具备以上的条件后,我们接下来可以了解下流程。

基本原理

基本的流程是:

  1. 制定一个用于搜索的路由,不能跟 hugo 项目已有的路由冲突,比如我用的 /s/
  2. 在 Hugo 项目的页面中创建请求搜索的表单,指向 /s/
  3. 在 Hugo 项目中,content 目录中建立名叫 s 的静态内容,作为搜索结果的底版页面。
  4. 在 Hugo 项目中,在搜索页面中加在 JavaScript 文件,用于向后台请求搜索内容。
  5. 在 Hugo 项目中,配置 输出文件格式 ,增加一种 txt 格式输出,输出内容为每篇文章的无格式内容。
  6. 编写后台脚本,用于在 Hugo 项目每次编译后,将输出的 txt 内容写入数据库
  7. 建立后台搜索服务,从数据库中查找关键字
  8. 在 nginx 配置中,针对 /s/ 的请求,分发给后台搜索服务

简单的部署图如下:

下面分步骤介绍实现方法:

制定路由

路由的名称不能和 Hugo 项目的 content 目录下的内容重复,会被 Hugo 和 nginx 同时使用。我这里使用/s/.

创建表单

页面顶部的表单效果如下:

( ☝页面顶栏效果 )

页面顶栏的文件在 Hugo 项目的 layouts/partials/header.html。编辑这个文件,在相关位置添加如下的表单:

<form method='GET' action='/s' class="" id="search-keyword">
  <input class="..." id="search-input" maxlength="20" name="s" placeholder="" autocomplete="off" >
  <button class="..." type="submit">{{ i18n "search" . }}</button> 
</form>

此处省略页面元素的样式属性,因为这取决于各个网站的视觉风格。

这里有两点要注意:

  1. 表单的请求方法为 GET
  2. 表单的 action 属性为事先指定的路由 /s/

这个表单填入关键字(比如 李白 )后,会转到 /s/?s=李白 这个链接。后面会处理这个链接。

建立目录

在 Hugo 项目中,content 目录中建立与路由同名(我这里是s)的静态内容,作为搜索结果的底版页面。

比如,我的 content 目录的结构如下:

content
  ├─ archive
  ├─ blog
  ├─ game
  ├─ grow
  ├─ nature
  ├─ ...
  └─ s
     └─ _index.md

这个 s 页面将被 Hugo 编译并形成一个静态页面,所以需要为这个页面设计一个空的 index 页面 (_index.md),作为搜索结果的页面。搜索结果的内容将在后面章节中使用 Javascript 填充。

index 页面 (_index.md) 的例子:

---
title: 搜索结果
headless: true
---

在 front matter 中设置 headless:true 的原因是:不希望这个页面作为一个普通的页面被统计到归档中去,也不产生其他类型的输出文件。

搜索结果页面样式

在上节中创建的静态页面 /s/,需要一个对应的列表的样式文件,用于指定搜索结果列表的样式。

可以写在 layouts/s/list.html 中。内容举例如下:

{{ define "main" }}
  <article class="mx-6 my-8">
    <h1 class="text-3xl text-primary-text" id="search-title">{{ .Title }}</h1>
    {{ with .Content }}
      <div class="content mt-4 text-secondary-text">
        {{ . }}
      </div>
    {{ end }}
  </article>
    
  <div class="bg-secondary-bg rounded px-6" id="search-result">
    <div class="overflow-hidden divide-y">
    </div>
  </div>
{{ end }}

注意!上面的样式仅仅是例子,需要读者根据自己的网站的特点设计样式。上面 id#search-result<div>内容是空的,将在下节中使用 JavaScript 填充。

页面发起请求

接下来开始处理 /s/?s=李白 页面向后台发出请求,这个用 Javascript 实现。

由于/s/是静态页面,所以这个 URL 中的 querystring 部分,在正常情况下,不会有没有任何一方来处理。所以可以使用 JavaScript 处理。

如果要让 Hugo 页面加载 js,需要处理两个地方,一个是 layouts/partials/head.html 文件,另一个是新建一个 JavaScript 文件,不妨命名为 search.js ,放在 static 目录下,

head.html 文件里加载 js 的部分,增加如下语句:

{{- $assets := .Site.Data.assets }}

{{- if eq .RelPermalink "/s/" }}
  <script defer src="{{ $assets.base64.js.url }}"></script>
  <script defer src="{{ $assets.search.js.url }}"></script>
{{- end }}

{{ .Site.Data.assets }} 变量指 data/assets.yaml 文件,保存着 search.js 文件的路径。

{{if}} 判断用来确保只有 /s/ 搜索页面才加载这个 js 文件。

search.js 文件的作用主要是将 querystring 编码为 base64,然后向后台发请求。关键的部分如下:


// 处理 querystring
decodeURIComponent(window.location.search.substring(3));

// 填充搜索结果
if(search_title !== null){
  if (result.output.length > 0){
      search_title.innerHTML = '有关 "'+result.keywords.join(', ') + '" 的 '+result.output.length.toString()+' 条搜索结果'
  }else{
      search_title.innerHTML = '没有找到 "'+result.keywords.join(', ') + '" 的匹配结果'
  }
}

// 向后台发请求
var r = new XMLHttpRequest();

r.addEventListener('load', function () {
    var result = JSON.parse(r.responseText)
    if(result.status === 'ok'){
        populate(result)
    }
});
r.open("GET", "/search/"+BASE64.urlsafe_encode(query), true);
r.send(null);

// 加载页面时调用函数
window.onload = getSearchResult

接下来开始处理后台数据,包括内容数据和后台服务。

输出纯文本格式的文章内容

Hugo 编译输出的产物,除了常规的静态 HTML 格式以外,还可以输出多种 MIME type 的文件。具体的格式列表 在这里

为了实现搜索功能,希望只得到每篇文章的文字,而不包括 front matter 和 markdown 的格式信息。先简单了解下 Hugo 输出的逻辑:

上图中,蓝色方块在 配置文件中枚举出,分别对应 outputFormatsoutputs,绿色方块在 layouts 目录中出现,黄色的输出则在应该在的地方(取决于来源 partial 的位置,比如首页位置、section位置、每个页面的位置)

下面是具体的实现方法:

首先,修改配置文件(config/_default/config.yaml),增加一种输出格式。在 yaml 格式的最顶层,找到 outputFormats,如果没有就创建一个。像下面这样:

outputFormats:
  content:
    mediaType: "text/plain"
    baseName: "plain-page-content"
    isPlainText: true

这样增加了一类名叫 content 的输出格式,参数的含义是:增加的这种输出,采用 “text/plain” 的媒体类型,输出文件的 base name 是 plain-page-content,输出文件的扩展名跟随模板文件的扩展名(这里是 .txt)。在每次编译完成后,我们将在每篇文章的目录中得到 plain-page-content.txt 这个输出文件。

然后,继续修改配置文件,增加一种输出结果,采用上面定义的 content 输出格式。

outputs:
  page:
    - "html"
    - "content"

page 中,原有的 html 是默认的渲染输出,再增加一种 content

最后,创建输出格式的模板文件,命名规则 在这里

简单的说,就是 【内容来源】.【输出格式名称】.【输出文件类型】

  • 搜索的内容来源来自各个 single页面
  • 输出格式使用上面增加的 content 类型;
  • 输出文件类型可以自由发挥,我们使用最普通的 txt

所以就得到了输出模板的名称:single.content.txt,放置在 layouts/_default/ 目录下。

这个模板的作用,就是将每个页面的 Page 变量,通过模板的格式化,得到渲染的结果。

为了加深理解,看看 layouts/_default/ 目录下的 single.html 文件,它对应的是 config 文件中的 outputs.page.html 输出。

为了得到去掉格式的纯文本,single.content.txt 文件内容如下:

{{ .Plain | htmlUnescape }}

输出纯文本格式的文章标题

用相同的办法,同时输出纯文本格式的标题。具体配置如下:

配置文件(config/_default/config.yaml),增加一种输出格式:

outputFormats:
  content:
    mediaType: "text/plain"
    baseName: "plain-page-content"
    isPlainText: true
  title:
    mediaType: "text/plain"
    baseName: "plain-page-title"
    isPlainText: true

增加一种输出结果,已有的配置不要删除,只增加就行

outputs:
  page:
    - "html"
    - "content"
    - "title"

新建模板:single.title.txt,放置在 layouts/_default/ 目录下,内容为:

{{ print .Title }}

到这里,每次编译结束后,在每篇文章的目录下应该输出两个文件:plain-page-content.txtplain-page-title.txt,内容分别是文章的内容和标题。到这里, Hugo 的改造就结束了,下面要开始编写外部的服务。

设计数据库

要实现高效的搜索,需要使用数据库。这里以 mysql 数据库为例。

安装好 mysql 后,需要首先创建库和表。为了保存搜索结果的来源信息,数据表需要存储文章的所属 section、标题 等信息。

所以表格设计如下:

+----+---------+-------------------------+--------------------------------+--------------+
| id | section | path                    | title                          | content      |
+----+---------+-------------------------+--------------------------------+--------------+
|  1 | tech    | mocha-reporter          | 为 Mocha.js 编写自定义的 reporter |    文        |
|  2 | tech    | puppeteer-wait-for-ajax | Puppeteer 等待页面更新            |    章        |
|  3 | tech    | python-namedtuple       | Python 中 NamedTuple 的理解      |    内        |
| .. | ...     | ...                     | ...                             |    容        |
+----+---------+-------------------------+---------------------------------+--------------+

建表语句为:

CREATE TABLE IF NOT EXISTS `search`(    
    `id` INT UNSIGNED AUTO_INCREMENT,
    `section` VARCHAR(64) NOT NULL,
    `path` VARCHAR(255) NOT NULL,
    `title` VARCHAR(128) NOT NULL,
    `content` TEXT NOT NULL,
    PRIMARY KEY ( `id` ) 
)ENGINE=InnoDB DEFAULT CHARSET=utf8;

section 和 tech 字段用于拼接文章的链接,title 字段用于显示,content 是文章的内容,用于搜索。

编写读取文本文件到数据库的脚本

接下来要编写脚本用来把 Hugo 输出的文本文件(plain-page-content.txtplain-page-title.txt)的内容写入数据库。

对于 mysql 数据库,可以使用 Python 语言的 pymysql 库完成这个任务。有一个陷阱是在读入文件内容后,插入数据库之前,需要处理字符转义:

# 部分语句
from pymysql.converters import escape_string

# 文件内容读入 content
article = escape_string(content)

# 把 article 插入数据库
sql = f'INSERT INTO search(section, path, title, content) values ("{section}", "{path}", "{title}", "{article}")'   

建立后台搜索服务

简单的方案使用 Flask 框架建立服务,这个话题可以另开一篇,这里只列出视图函数最关键的部分:

import base64
import urllib
from app.models import Search
from sqlalchemy import and_

import regex as re

# search 是个蓝图,公共前缀是 /search
@search.route('/<string:code>', methods=['GET'])
def search(code):
  b_keyword = base64.urlsafe_b64decode(code)
  keyword = b_keyword.decode(encoding='utf-8', errors='strict')
  unquoted = urllib.parse.unquote(keyword)
  keywords = set([x.strip() for x in unquoted.split('+') if x])

  if keywords:
      criteria = [Search.content.like(f'%{w}%') for w in keywords]
      records = Search.query.filter(and_(*criteria)).limit(max_results).all()

另外再配合安装 gunicorn 和 supervisord 就可以开启 web 服务,这里假设web 服务开在 8888 端口。

配置 nginx

需要配置 nginx ,将 /s/ 的请求转发到后台 web 服务上,而其余的请求仍然解释为静态文件:


location ~ /search/ {
        proxy_pass http://127.0.0.1:8888;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

这样就实现了 Hugo 静态内容配合使用的动态搜索功能。

如果您对本站内容有疑问或者寻求合作,欢迎 联系邮箱邮箱已到剪贴板

标签: HUGO NGINX PYTHON

精彩评论

欢迎转载本文,惟请保留 原文出处 ,且不得用于商业用途。
本站 是个人网站,若无特别说明,所刊文章均为原创,并采用 署名协议 CC-BY-NC 授权。