Practice of Programming

プログラム とか Linuxとかの話題

Nanocで全文検索を実装する

いくつかNanocの記事を書きましたが、検索がないと、実際問題使えないですよね。

というわけで、Googleで検索してみましたが、下記が見つかるくらいでした。

groups.google.com

上記からたどったところ、下記のようなものがあれば、なんとかなりそうというところですが...... 元にしているNanocのバージョンが古いのと、英語のみを対象としているようなので、これを元に自作してみました。検索の実装自体はあまり変えていません。

github.com

github.com

lib/search.rb

検索用のindexを作るRubyスクリプトです。

# coding: utf-8
require 'json'
require 'nokogiri'

# 下記は適当に変えてください
$ROOT_PATH = ENV.fetch("SITE_ROOT"){Dir.getwd + '/output'}
# search-data.js.erbのMAX_WORD_LENと合わせる
$MAX_WORD_LEN = 3;

# item が一つのドキュメント
def search_terms_for(item)
  # md 以外は除外
  if item.identifier != nil && item.identifier.ext != nil && item.identifier.ext == 'md'
    # HTMLになった内容を取得
    content = item.reps.fetch(:default).compiled_content
    doc = Nokogiri::HTML(content)

    # 対象とするタグが足りなければ増やしてください
    full_text = doc.css("td, pre, div, p, h1, h2, h3, h4, h5, h6").map{|el| el.inner_text}.join("").gsub(/ /, '')
    # $MAX_WORD_LEN文字ずつに区切った組み合わせの文字をindexとしています
    "#{item.identifier.without_ext}#{item[:title]}#{item[:meta_description]}#{full_text}".each_char.each_cons($MAX_WORD_LEN).map{|chars| chars.join }
   end
end

def search_index
  # 後で、idとページ情報を紐付けるのに使います
  id = 0;
  idx = {
    "approximate" => {},
    "terms" => {},
    "items" => {}
  }
  if @items == nil
    return idx
  end
  @items.each do |item|
    # 上で定義した、search_terms_forを使って、$MAX_WORD_LEN ごとに区切られた単語の配列を取得
    result = search_terms_for(item)
    if result == nil
      next
    end
    result.each do |term|
      # termにマッチする id を入れておく入れ物
      idx["terms"][term] ||= []
      idx["terms"][term] << id
      # wor がtermの場合、 w, wo も検索対象とするための処理
      (0...term.length - 1).each do |c|
        # term の 0番目からc番目までを、subtermとします
        subterm = term[0...c]
        idx["approximate"][subterm] ||= []
        unless idx["approximate"][subterm].include?(id)
          idx["approximate"][subterm] << id
        end
      end
    end
    url = $ROOT_PATH + "#{item.identifier.without_ext}"+".html"
    # items に id と title, url等の情報を紐付けて持っておきます
    idx["items"][id] = {
      "url" => url,
      "title" => item[:title] || item.identifier.without_ext,
      "crumb" => item[:crumb]
    }
    id += 1
  end
  idx
end

content/search-data.js.erb

このJavaScriptの一番最後にある、<%= search_index.to_json %>; で、lib/search.rbsearch_index から返されたものをJSONに変えて、埋め込んでいます。

埋め込まれるのは、具体的には、下記のようなJSONですね。

{
  "approximate": {"w": [0,1,2], "wo": [0,1,2]}, 
  "terms":{"wor": [0,1,2]},
  "items": {
    "0": {"url":"/path/to/page1", "title1": "title1",
    "1": {"url":"/path/to/page2", "title2": "title2",
    "2": {"url":"/path/to/page3", "title3": "title3"
  }
}

実際に検索を行うJavaScriptの実装です。

// lib/search.rb の $MAX_WORD_LEN と合わせる
const MAX_WORD_LEN = 3

function unique(arrayName)
 {
    let newArray = new Array();
    label: for (let i = 0; i < arrayName.length; i++)
    {
        for (let j = 0; j < newArray.length; j++)
        {
            if (newArray[j] == arrayName[i])
            continue label;
        }
        newArray[newArray.length] = arrayName[i];
    }
    return newArray;
}

function search(query, callback) {
  let terms = $.trim(query).split(/\s+/);
  let matching_ids = null;
  let l = terms.length;
  for (let i = 0; i < l; i++) {
    let j = i
    for (;terms[j].length > MAX_WORD_LEN;) {
       // MAX_WORD_LEN文字までのindexなので、その文字数を超えた場合は、雑に別にする
       let s = terms[j]
       terms[j] = s.slice(0, MAX_WORD_LEN);
       terms.push(s.slice(MAX_WORD_LEN));
       j = terms.length - 1;
    }
  }
  for (let i = 0; i < terms.length; i++) {
    let term = terms[i];
    let exactmatch = index.terms[term] || [];
    let approxmatch = index.approximate[term] || [];
    let ids = unique(exactmatch.concat(approxmatch));
    if (matching_ids) {
      matching_ids = $.grep(matching_ids, function(id) {
        return ids.indexOf(id) != -1;
      });
    } else {
      matching_ids = ids;
    }
  }
  callback($.map(matching_ids, function(id){ return index.items[id]; }))
}

let index = <%= search_index.to_json %>;

content/search.haml

下記が、検索ページになります。

- content_for(:javascripts) do
  %script(type="text/javascript" src="/javascripts/search-data.js")
  %script(type="text/javascript" src="/javascripts/jquery.url.packed.js")
  :javascript
    $(function(){
      let getQuery = decodeURIComponent($.url.param("query"));
      let q = $.url.param("q") || getQuery;
      if ( q != '' ) {
        let query = q.replace("+"," ");
        $('input#q').attr('value', query);
        search(query, displayResults);
      }
      $('input#q').keyup(function(){
        search(this.value, displayResults);
      });
    })
    function displayResults(items) {
      if (items.length > 0) {
        let html = ""
        for (let i = 0; i < items.length; i++) {
          html += '<li><a href="'+items[i].url+'?h=' + $("#q").val().replace(' ', '%20') + '">'+items[i].title+'</a></li>';
        }
        $('ol#results').html(html)
      } else {
        $('ol#results').html("<li class='none'>Nothing found.</li>");
      }
    }
%input#q{:type => "text", :placeholder=>"Search"}

%h2 Results
%ol#results
  %li.none Please enter a search term.

実際の検索

自前のものが用意できれば良かったのですが、最初に貼っていたコードのサイトを貼っておきます。ほぼほぼ似たような感じで動きます。

compass-style.org

欠点

やむなしですが、search-data.js.erb からできあがる search-data.js は、ページ数が多いとサイズが大きくなってしまいます。 553ページほどあるところで試しましたが、18MBくらいにはなりました。

ですが、読み込んでしまえば、速いので、そこはやむなしとします。

ページが少なければ、そこまで大きくもなりませんし、問題にはならないかと思います。