Nanocで全文検索を実装する
いくつかNanocの記事を書きましたが、検索がないと、実際問題使えないですよね。
というわけで、Googleで検索してみましたが、下記が見つかるくらいでした。
上記からたどったところ、下記のようなものがあれば、なんとかなりそうというところですが...... 元にしているNanocのバージョンが古いのと、英語のみを対象としているようなので、これを元に自作してみました。検索の実装自体はあまり変えていません。
lib/search.rb
# 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.rb
の search_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.
実際の検索
自前のものが用意できれば良かったのですが、最初に貼っていたコードのサイトを貼っておきます。ほぼほぼ似たような感じで動きます。
欠点
やむなしですが、search-data.js.erb からできあがる search-data.js は、ページ数が多いとサイズが大きくなってしまいます。 553ページほどあるところで試しましたが、18MBくらいにはなりました。
ですが、読み込んでしまえば、速いので、そこはやむなしとします。
ページが少なければ、そこまで大きくもなりませんし、問題にはならないかと思います。