提供全文搜索的引擎有很多,但是使用简单、结果准确的只有以下几个:

  • Meilisearch 免费开源、快速准确、支持中文、使用简单的搜索引擎,如果有服务器非常推荐。
  • algolia 商业软件,每月免费一万次搜索。适用于小型博客
  • Fuse 非常轻量级的模糊搜索JS库
  • Pagefind 静态全文搜索工具,通过 wrapper package through npm 生成索引文件

注意:Hugo 官网提供的 其它搜索方案 很多都过时了或者不支持中文搜索,比如 hugo-lunr-zh 5年没更新了。

因为我现在还没有购买服务器,又不想使用商业产品,安装 NPM 又比较麻烦,所以只能使用 Fuse了。

Fuse 有以下优点:

  • 配置简单、结果准确、支持中文、可以自定义配置
  • 直接开箱即用,没有其它依赖项,如 npmgrunt 等,也不需要上传数据文件
  • 开发相当活跃,使用人数较多

当然,也有一些缺点:

  • 对小型博客类搜索比较快,但中大型比较慢
  • 搜索结果显示的是博客开头部分而不是高亮匹配的区域,有些不友好

总体而言,用于博客搜索是够了。

Fuse 配置

因为我使用的 Hugo 主题是 jane,所以搜索页面和JS脚本都放在主题目录下。

在hugo博客根目录添加4个文件:

  • content/search.md 在主菜单栏上添加 搜索 菜单
  • themes/jane/layouts/_default/search.html 搜索页面
  • themes/jane/layouts/_default/index.json 搜索结果数据格式
  • static/js/search.js 搜索页面里点击搜索后的操作

搜索菜单

content/search.md 搜索菜单

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
---
title: "🔍搜索"
sitemap:
    priority : 0.1
layout: "search"
menu: "main"
slug: search
---

This file exists solely to respond to /search URL with the related `search` layout template.

No content shown here is rendered, all content is based in the template layouts/page/search.html

Setting a very low sitemap priority will tell search engines this is not important content.

This implementation uses Fusejs, jquery and mark.js

搜索页面

themes/jane/layouts/_default/search.html 搜索页面

我定制的搜索页面有以下功能:

  1. 页面自适应布局,已适配手机、平板、PC屏幕
  2. 和博客文章风格保持一致,上面是菜单,下面是页脚,中间是搜索结果。

如果想定制可以参考同目录下 baseof.html 文件的格式。

  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
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fuse.js/3.2.0/fuse.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mark.js/8.11.1/jquery.mark.min.js"></script>
<script src="https://ludard.com/js/search.js"></script>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>
    {{- block "title" . -}}
      {{ if .IsPage }}{{ .Title }} - {{ .Site.Title }}{{ else }}{{ .Site.Title }}{{ end }}
    {{- end -}}
  </title>
  {{ partial "head.html" . }}

  <style type="text/css">
    @media (max-width: 425px) {
        /*0~425*/
        .search-container {
            padding: 3em 1em;
            margin: 0 0 1em 0;
            background-color: #fff;
            font-size: 11px;
            font-weight: 200;
        }

        #search-box {
            width: 85%;
            height: 25px;
            border: 2px solid #e58f38;
            margin: auto;
        }
    }

    @media (min-width: 426px) and (max-width: 768px) {
        /*426~768*/
        .search-container {
            padding: 3em 2em;
            margin: 0 2em 1.5em;
            background-color: #fff;
        }

        #search-box {
            width: 80%;
            height: 40px;
            border: 2px solid #e58f38;
            margin: auto;
        }
    }

    @media (min-width: 769px) {
      /*769~+∞*/
      .search-container {
        padding: 3em 5em;
        margin: 0 5em 3em;
        background-color: #fff;
      }

      #search-box {
        width:71%;
        height:40px;
        border:2px solid #E58F38;
        margin:auto;
      }
    }

    #search-query {
      float:left;
      width:80%;
      height:100%;/*高38(因为文本框内外边框要占用1像素所以总体高度减2,其他盒子同理)*/
      outline:none;
      border:none;/*取消文本框内外边框*/
    }

    #search-submit {
      float:left;
      text-align: center;
      width:20%;
      height:100%;
      color:white;
      background-color:#E58F38;
      border:none;
      outline:none;/*取消边框和外边框将按钮边框去掉*/
      cursor: pointer;
    }
  </style>
</header>

<body>
  {{ partial "slideout.html" . }}

  {{ if or .Site.Params.photoswipe .Site.Params.fancybox }}
    {{ partial "photoswipe.html" . }}
  {{ end }}

  {{ if .Site.Params.search.google.enable }}
    {{ partial "search_google.html" . }}
  {{ end }}

  <header id="header" class="header container">
    {{ partial "header.html" . }}
  </header>

  <div id="mobile-panel">
    <section class="resume-section p-3 p-lg-5 d-flex flex-column">
      <main id="main" class="main bg-llight wallpaper">
        <div class="content-wrapper">
          <div id="content" class="content container">
            <div class="search-container">
              <form action="https://ludard.com/search">
                <div id="search-box">
                  <input id="search-query" name="s" placeholder="输入搜索内容..." />
                  <button type="submit" id="search-submit" action="https://ludard.com/search">搜索</button>
                </div>
              </form>
              <div id="search-results">

              </div>
            </div>
          </div>
        </div>
      </main>

      <footer id="footer" class="footer">
        {{ partial "footer.html" . }}
      </footer>

      <div class="back-to-top" id="back-to-top">
        <i class="iconfont">
          {{/* icon up */}}
          {{ partial "svg/up.svg" }}
        </i>
      </div>
    </section>
  </div>

<!-- this template is sucked in by search.js and appended to the search-results div above. So editing here will adjust style -->
<script id="search-result-template" type="text/x-js-template">
    <div id="summary-${key}">
      <h4><a href="${link}">${title}</a></h4>
      <p>${snippet}</p>
      ${ isset tags }<p>Tags: ${tags}</p>${ end }
      ${ isset categories }<p>Categories: ${categories}</p>${ end }
    </div>
</script>
<script>
  document.getElementById('search-query').focus();
</script>
<script async src="//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"></script>

</body>

注意:

  1. 最核心的搜索组件 fuse.js 经我测试后最好不要使用最新的 6.x 版本,因为新版本只能搜索 post 目录的内容,其它目录的内容无法搜索。而且搜索结果顺序有点乱。

  2. script 里的引用脚本 https://ludard.com/js/search.js 必须是 https 协议,否则无法搜索。

  3. form 提交的 URL https://ludard.com/search 改为自已的网址。并且必须是 https协议。否则在搜索时有以下错误提示:

fuse_search

fuse_search

搜索结果

themes/jane/layouts/_default/index.json

1
2
3
4
5
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
    {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "categories" .Params.categories "contents" .Plain "permalink" .Permalink) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

搜索操作

static/js/search.js 操作脚本

  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
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
summaryInclude=60;
var fuseOptions = {
  shouldSort: true,
  includeMatches: true,
  // threshold: 0.0,
  threshold: 0.3,
  tokenize:true,
  location: 0,
  distance: 100,
  maxPatternLength: 32,
  minMatchCharLength: 1,
  keys: [
    {name:"title",weight:0.8},
    {name:"contents",weight:0.5},
    {name:"tags",weight:0.3}
    // , {name:"categories",weight:0.3}
  ]
};


var searchQuery = param("s");
if(searchQuery){
  $("#search-query").val(searchQuery);
  executeSearch(searchQuery);
}else {
  $('#search-results').append("<p>Please enter a word or phrase above</p>");
}


function executeSearch(searchQuery){
  $.getJSON( "/index.json", function( data ) {
    var pages = data;
    var fuse = new Fuse(pages, fuseOptions);
    var result = fuse.search(searchQuery);
    console.log({"matches":result});
    if(result.length > 0){
      populateResults(result);
    }else{
      $('#search-results').append("<p>No matches found</p>");
    }
  });
}

function populateResults(result){
  $.each(result,function(key,value){
    var contents= value.item.contents;
    var snippet = "";
    var snippetHighlights=[];
    var tags =[];
    if( fuseOptions.tokenize ){
      snippetHighlights.push(searchQuery);
    }else{
      $.each(value.matches,function(matchKey,mvalue){
        if(mvalue.key == "tags" || mvalue.key == "categories" ){
          snippetHighlights.push(mvalue.value);
        }else if(mvalue.key == "contents"){
          start = mvalue.indices[0][0]-summaryInclude>0?mvalue.indices[0][0]-summaryInclude:0;
          end = mvalue.indices[0][1]+summaryInclude<contents.length?mvalue.indices[0][1]+summaryInclude:contents.length;
          snippet += contents.substring(start,end);
          snippetHighlights.push(mvalue.value.substring(mvalue.indices[0][0],mvalue.indices[0][1]-mvalue.indices[0][0]+1));
        }
      });
    }

    if(snippet.length<1){
      snippet += contents.substring(0,summaryInclude*2);
    }
    //pull template from hugo templarte definition
    var templateDefinition = $('#search-result-template').html();
    //replace values
    var output = render(templateDefinition,{key:key,title:value.item.title,link:value.item.permalink,tags:value.item.tags,categories:value.item.categories,snippet:snippet});
    $('#search-results').append(output);

    $.each(snippetHighlights,function(snipkey,snipvalue){
      $("#summary-"+key).mark(snipvalue);
    });

  });
}

function param(name) {
    return decodeURIComponent((location.search.split(name + '=')[1] || '').split('&')[0]).replace(/\+/g, ' ');
}

function render(templateString, data) {
  var conditionalMatches,conditionalPattern,copy;
  conditionalPattern = /\$\{\s*isset ([a-zA-Z]*) \s*\}(.*)\$\{\s*end\s*}/g;
  //since loop below depends on re.lastInxdex, we use a copy to capture any manipulations whilst inside the loop
  copy = templateString;
  while ((conditionalMatches = conditionalPattern.exec(templateString)) !== null) {
    if(data[conditionalMatches[1]]){
      //valid key, remove conditionals, leave contents.
      copy = copy.replace(conditionalMatches[0],conditionalMatches[2]);
    }else{
      //not valid, remove entire section
      copy = copy.replace(conditionalMatches[0],'');
    }
  }
  templateString = copy;
  //now any conditionals removed we can do simple substitution
  var key, find, re;
  for (key in data) {
    find = '\\$\\{\\s*' + key + '\\s*\\}';
    re = new RegExp(find, 'g');
    templateString = templateString.replace(re, data[key]);
  }
  return templateString;
}

config.toml 配置

在博客配置文件 config.toml 添加以下配置:

1
2
[outputs]
  home = ["HTML", "RSS", "JSON"]

这样 Fuse 所有配置就已经完成了,使用命令 hugo server 就可以看到效果了。是不是非常简单?

成果展示

搜索页面:

fuse_search

搜索中文 博客 结果:

fuse_search

参考资源

https://github.com/krisk/Fuse

fuzzy searching document

Client side searching for Hugo.io with Fuse.js

Hugo JS Searching with Fuse.js