Jekyll博客中添加全文本搜索的最佳方案与踩坑记录

 
Category: Frontend

写在前面

最近一直想折腾一下博客的全文本搜索, 但是找了很多博客都比较老旧了, 尤其是使用 Luna.js 以及使用 simple-Jekyll-Search 等插件的, 基本上最近的都在 3 年前了… 并且 simple 这个库不更新了, 看知乎(为Jekyll+GitHub Pages添加全文搜索功能)还有一些特殊字符提取的bug.

后来看到了这篇文章:

个人博客网站添加文章搜索功能;

感觉可行! 于是就一点一点配置了.

后来不知道为什么, (一开始猜测可能是我的博客内容比较多) 总是显示匹配数量为 0, 但是后来我精简了一下用例, 还是不行.

百思不得解的时候我联系了作者,他提供了一个办法: 浏览器控制台调试, 不得不说, 真的有用!

最后的问题竟然是一个小小的 &

下面主要说一下我在我的博客上成功配置作者的 js 搜索脚本的方法以及一些存在的问题, 具体的话还是看作者提供的思路, 非常好用!

步骤

添加 js 文件

下面的内容放在/assets/js/search.js中即可:(根目录是博客目录)

/**
 * 网站文章内容搜索功能实现
 * Copyright (c) 2020 knightyun.
 * <https://github.com/knightyun/knightyun.github.io/assets/js/search.js>
 * @todo 多关键词搜索
 */

// 获取搜索框、搜索按钮、清空搜索、结果输出对应的元素
var elSearchBox = document.querySelector('.search'),
    elSearchBtn = document.querySelector('.search-start'),
    elSearchClear = document.querySelector('.search-clear'),
    elSearchInput = document.querySelector('.search-input'),
    elSearchResults = document.querySelector('.search-results');

// 声明保存文章的标题、链接、内容的数组变量
var searchValue = '', arrItems = [], arrContents = [], arrLinks = [],
    arrTitles = [], arrResults = [], indexItem = [], itemLength = 0;
var tmpDiv = document.createElement('div'),
    tmpAnchor = document.createElement('a');
var isSearchFocused = false;

// ajax 的兼容写法
var xhr = new XMLHttpRequest() || new ActiveXObject('Microsoft.XMLHTTP');

// 获取根目录下 feed.xml 文件内的数据
xhr.onreadystatechange = function() {
  if (xhr.readyState == 4 && xhr.status == 200) {
    var xml = xhr.responseXML;
    if (!xml) // xml 验证
      return;

    arrItems = xml.getElementsByTagName('item');
    itemLength = arrItems.length;

    // 遍历并保存所有文章对应的标题、链接、内容到对应的数组中
    // 同时过滤掉 HTML 标签
    for (i = 0; i < itemLength; i++) {
      arrContents[i] = arrItems[i]
                           .getElementsByTagName('description')[0]
                           .childNodes[0]
                           .nodeValue.replace(/<.*?>/g, '');
      arrLinks[i] = arrItems[i]
                        .getElementsByTagName('link')[0]
                        .childNodes[0]
                        .nodeValue.replace(/<.*?>/g, '');
      arrTitles[i] = arrItems[i]
                         .getElementsByTagName('title')[0]
                         .childNodes[0]
                         .nodeValue.replace(/<.*?>/g, '');
      // console.log(arrItems[i]);
      // console.log(arrItems[i].getElementsByTagName('title')[0]);
      // console.log(arrItems[i].getElementsByTagName('title')[0].childNodes[0]);
      // console.log(arrItems[i]
      //                 .getElementsByTagName('title')[0]
      //                 .childNodes[0]
      //                 .nodeValue.replace(/<.*?>/g, ''));
      // console.log(arrTitles[i]);
    }

    // 内容加载完毕后显示搜索框
    elSearchBox.style.display = 'block';
  }
};

xhr.open('get', '/feed.xml', true);
xhr.send();

// 绑定按钮事件
elSearchBtn.onclick = searchConfirm;
elSearchClear.onclick = searchClear;

// 输入框内容变化后就开始匹配,可以不用点按钮
// 经测试,onkeydown, onchange 等方法效果不太理想,
// 存在输入延迟等问题,最后发现触发 input 事件最理想,
// 并且可以处理中文输入法拼写的变化
elSearchInput.oninput = function() { setTimeout(searchConfirm, 0); };
elSearchInput.onfocus = function() { isSearchFocused = true; };
elSearchInput.onblur = function() { isSearchFocused = false; };

/** 搜索确认 */
function searchConfirm() {
  if (elSearchInput.value == '') {
    searchClear();
  } else if (elSearchInput.value.search(/^\s+$/) >= 0) {
    // 检测输入值全是空白的情况
    searchInit();
    var itemDiv = tmpDiv.cloneNode(true);
    itemDiv.innerText = '请输入有效内容...';
    elSearchResults.appendChild(itemDiv);
  } else {
    // 合法输入值的情况
    searchInit();
    searchValue = elSearchInput.value;
    // 在标题、内容中匹配搜索值
    searchMatching(arrTitles, arrContents, searchValue);
  }
}

/** 搜索清空 */
function searchClear() {
  elSearchInput.value = '';
  elSearchClear.style.display = 'none';
  elSearchResults.style.display = 'none';
  elSearchResults.classList.remove('result-item');
}

/** 每次搜索完成后的初始化 */
function searchInit() {
  arrResults = [];
  indexItem = [];
  elSearchResults.innerHTML = '';
  elSearchClear.style.display = 'block';
  elSearchResults.style.display = 'block';
  elSearchResults.classList.add('result-item');
}

/**
 * 匹配搜索内容
 * @param {string[]} arrTitles   - 所有文章标题
 * @param {string[]} arrContents - 所有文件内容
 * @param {string}   input       - 搜索内容
 */
function searchMatching(arrTitles, arrContents, input) {
  var inputReg;

  try {
    // 转换为正则表达式,忽略输入大小写
    inputReg = new RegExp(input, 'i');
  } catch (_) {
    var errorInputDiv = tmpDiv.cloneNode(true);

    errorInputDiv.innerHTML =
        '正则表达式语法错误,特殊符号前考虑加上转义符:"&Backslash;"';
    errorInputDiv.className = 'pink-text result-item';
    elSearchResults.appendChild(errorInputDiv);
    return;
  }

  // 在所有文章标题、内容中匹配搜索值
  for (i = 0; i < itemLength; i++) {
    var titleIndex = arrTitles[i].search(inputReg);
    var contentIndex = arrContents[i].search(inputReg);
    var resultIndex, resultArr;

    if (titleIndex !== -1 || contentIndex !== -1) {
      // 优先搜索标题
      if (titleIndex !== -1) {
        resultIndex = titleIndex;
        resultArr = arrTitles;
      } else {
        resultIndex = contentIndex;
        resultArr = arrContents;
      }

      // 保存匹配值的索引
      indexItem.push(i);

      var len = resultArr[i].match(inputReg)[0].length;
      var step = 10;

      // 将匹配到内容的地方进行黄色标记,并包括周围一定数量的文本
      arrResults.push(
          resultArr[i].slice(resultIndex - step, resultIndex) + '<mark>' +
          resultArr[i].slice(resultIndex, resultIndex + len) + '</mark>' +
          resultArr[i].slice(resultIndex + len, resultIndex + len + step));
    }
  }

  // 输出总共匹配到的数目
  var totalDiv = tmpDiv.cloneNode(true);

  totalDiv.className = 'result-title';
  totalDiv.innerHTML = '总匹配:<b>' + indexItem.length + '</b> 项';
  elSearchResults.appendChild(totalDiv);

  // 未匹配到内容的情况
  if (indexItem.length == 0) {
    var noneItemDiv = tmpDiv.cloneNode(true);

    noneItemDiv.innerText = '未匹配到内容...';
    noneItemDiv.className = 'teal-text text-darken-3 result-item';
    elSearchResults.appendChild(noneItemDiv);
  }

  // 将所有匹配内容进行组合
  for (i = 0; i < arrResults.length; i++) {
    var itemDiv = tmpDiv.cloneNode(true);
    var itemTitleDiv = tmpDiv.cloneNode(true);
    var itemDetailDiv = tmpDiv.cloneNode(true);
    var itemDetailDivAnchor = tmpAnchor.cloneNode(true);

    itemDiv.className = 'card hoverable result-item';
    itemTitleDiv.className = 'card-content result-item-title';
    itemDetailDiv.className = 'card-action result-item-detail';
    itemDetailDivAnchor.className = "blue-text";

    itemTitleDiv.innerText = arrTitles[indexItem[i]];
    itemDetailDivAnchor.innerHTML = arrResults[i];
    itemDetailDivAnchor.href = arrLinks[indexItem[i]];

    itemDiv.appendChild(itemTitleDiv);
    itemDetailDiv.appendChild(itemDetailDivAnchor);
    itemDiv.appendChild(itemDetailDiv);

    elSearchResults.appendChild(itemDiv);
  }
};

window.addEventListener('load', searchClear);

// 搜索快捷键
document.addEventListener('keydown', function(evt) {
    if (isSearchFocused) return;
    if (evt.key === '/') {
        evt.preventDefault();
        elSearchInput.focus();
        window.isSearchFocused = true;
    }
});

有改动, 我加了一些调试信息. (注释掉了)

feed.xml

一个生成 feed 文件(最后就从这里搜索内容)的 liquid 模板: (最后发现就是这里出了问题)

---
---
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">

    <channel>
        <title>{{ site.title }}</title>
        <link>{{ site.url }}</link>
        <description>{{ site.description }}</description>
        <lastBuildDate>{{ site.time | date_to_rfc822 }}</lastBuildDate>
        {% for post in site.posts %}
        <item>
            <title>{{ post.title | escape }}</title>
            <link>
                {{ post.url | prepend: site.url }}
            </link>
            <description>
                {{ post.content | escape }}
            </description>
            <pubDate>{{ post.date | date_to_rfc822 }}</pubDate>
            <guid>
                {{ post.url | prepend: site.url }}
            </guid>
        </item>
        {% endfor %}
    </channel>
</rss>

放在根目录/

css 样式(可选)

.search {
    position: revert;
    color: orange;
    height: 350px;
    text-align: right;
    line-height: 30px;
    padding-right: 10px;
}

.search .search-icon {
    float: right;
    height: 100%;
    margin: 0 10px;
    line-height: 30px;
    cursor: pointer;
    user-select: none;
}

.search .search-input {
    float: right;
    width: 30%;
    height: 30px;
    color: orange;
    background-color: black;
    line-height: 30px;
    margin: 0;
    border: 2px solid #ddd;
    border-radius: 10px;
    box-sizing: border-box;
}

.search .search-results {
    display: block;
    z-index: 1000;
    position: absolute;
    top: 30px;
    right: 50px;
    width: 60%;
    max-height: 400px;
    overflow: auto;
    text-align: left;
    border-radius: 5px;
/*    background: #ccc;*/
    box-shadow: 0 .3rem .5rem #333;
}
.card {
    max-width: 60rem;
}

.search .search-results .result-item {
/*    background: aqua;*/
    color: #000;
    margin: 5px;
    padding: 3px;
    color: white;
    border-radius: 3px;
    cursor: pointer;
}

为了配合我的主题做了一些修改.

放在 /assets/css/search.css 下.

图标

修改之前的 /_includes/head/favicon.html 文件, 添加图标源.

<!-- 搜索 -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
      rel="stylesheet">

部署

主配置 _config.yaml:

## => Search
##############################
search:
  provider: custom # "default" (default), false, "google", "custom"

_includes 下新建一个目录, 然后新建search.html文件.

.
├── article-footer.html
├── article-info.html
├── head
│   └── favicon.html
└── search-providers
    └── custom
        └── search.html

内容如下:

这里的 async 关键字很重要, 没有的话会直接加载 js 导致错误.

<link rel="stylesheet" type="text/css" href="/assets/css/search.css">
<script async src="/assets/js/search.js"></script>

<div class="search">
    <i class="material-icons search-icon search-start">search</i>
    <input type="text" class="search-input" placeholder="Searching..." />
    <i class="material-icons search-icon search-clear">clear</i>
    <div class="search-results z-depth-4"></div>
</div>

问题

下面来说说为什么一个 & 导致了搜索失败.

一开始我以为文本内容过多导致搜索失败, 后来发现其实不管有多少文本, 搜索其实都是很快的(静态的本地搜索, 借助客户端)

后来我以为是文本文件内的特殊字符, 那这个找起来可就费劲了. 300 多篇文章, 我用排除法一点一点找, 后来发现就是一个文件出了问题:

2022-05-10-Win&MacR语言安装xlsx包的完全解决方案.md

乍看下没有问题, 可是 xml 他不支持 & 啊!

保留这个文件, 查看 feed.xml, 发现 xml 格式不对…

博客文章的文件名不要加特殊字符!

博客文章的文件名不要加特殊字符!

博客文章的文件名不要加特殊字符!

血的教训…

于是, 问题解决了.

blog

附上我的新博客地址:

https://zorchp.github.io/.

思考

  1. 遇到问题不要钻牛角尖(虽然对我来说还是有点难, 慢慢克服吧)

  2. 不要想当然(虽然有时候问题的解决也需要发散思考), 程序方面的问题大多还是要一步一步打 log 调试的, 一步一步追踪 肯定能找到问题

    (当然, 玄学问题目前无解)

  3. 遇到问题不要放弃, 但是可以先记录下来, 一直想一个问题其实很浪费时间.