Asciidoctorを使うと、技術文書や手順書などのドキュメントを簡単にHTMLで公開できますが、ドキュメントが多くなると自分の調べたいキーワードが、どのドキュメントに載っているのか探すのが困難になってきます。

そこで今回は、Dockerで全文検索サーバのFessを立てて、
ドキュメントからJavaScript経由でFessのJSON APIを呼び出すことで、
全文検索を簡単に導入する方法をご紹介します。

導入するとドキュメントの右上に検索窓が出てきて全文検索できるようになります。
6月-24-2017 19-05-32.gif

Fessとは

Fess「5 分で簡単に構築可能な全文検索サーバー」です。
Javaベースで構築されており、Apacheライセンスで提供されるオープンソース製品で、無料で使用できます。
自分でクローラを設定して、自分だけの検索エンジンがつくれるようなイメージです。
デフォルトで検索画面を提供していますが、JSON APIも提供しているので、様々なシステムと連携可能です。

全体像

ドキュメント用Webサーバに全文検索用のJavaScriptファイルとCSSファイルを追加し、Dockerで全文検索用のFessサーバを立てます。
Fessサーバはドキュメント用Webサーバをクロールし、その情報を保存しておきます。
ドキュメントで全文検索を実施すると、JavaScript経由でFessサーバのJSON AIPを呼び出して、全文検索結果をドキュメントに表示します。

0_Fess_全体像_1.png

0_Fess_全体像_2.png

導入手順

この導入手順は、下記のような環境(ローカルPCのDocker上にドキュメント用WebサーバとFessサーバが立っている環境)を作った時のものです。
Dockerを使わなくても、ローカルPC以外でも、導入可能です。導入手順は適宜読み替えてください。

0_Fess_全体像_3.png

Fessのインストール

Docker Hubのcodelibs/fessを使用します。今回ポートは10084で公開します。
Dockerを使わない場合はFess インストールガイドを参考にしてください。

$ docker run -d -p 10084:8080 --name fess codelibs/fess:latest

Fessの設定

クローラの設定

http://[PCのローカルIPアドレス]:10084/loginにアクセスするとログイン画面が表示されます。
デフォルトのID/PASS admin/adminでログインしましょう。
1_Fess管理者_ログイン画面.png

ログインするとダッシュボードが表示されます。左ペインのクローラ > Web を選択しましょう。
2_Fess管理者_ダッシュボード.png

Webクローラにはまだ何も登録されていないので、左上の+ 新規作成ボタンをクリックしましょう。
3_Fess管理者_Webクロール設定_1.png

Webクロール情報入力画面が表示されます。
3_Fess管理者_Webクロール設定_2.png

設定項目は色々ありますが、とりあえず下記項目だけ設定しましょう。

  • 名前
    • 任意の名前を設定してください。
  • URL
    • ドキュメント用Webサーバにおいてドキュメントが格納されているルートフォルダのURLを指定してください。末尾に/を付けてください。
  • クロール対象とするURL
    • 正規表現で値を設定します。上記URLで設定したルートフォルダ配下の全資産を対象とするために、URLで設定した値 + .* を指定してください。
  • 検索対象とするURL
    • 正規表現で値を設定します。HTMLのみを検索対象にしたい(JS、CSSなどは除外したい)ので、URLで設定した値 + .+\.html$ を指定してください。

値を設定したら、画面を下にスクロールして+ 作成ボタンをクリックします。
すると下記のようにWebクロールのデータが1件登録されます。
3_Fess管理者_Webクロール設定_4.png

クローラの実行

左ペインで システム > スケジューラ を選択してジョブスケジューラを開きます。
ジョブスケジューラで Default Crawlerを選択します。
4_Fess管理者_スケジューラ_1.png

今すぐ開始をクリックします。
4_Fess管理者_スケジューラ_1.1.png

するとクロールが実行されます。
4_Fess管理者_スケジューラ_2.png

しばらくしてF5キーを押してブラウザを更新してください。
クロールが終了すると、スケジューラの状態が実行中から有効になります。
4_Fess管理者_スケジューラ_3.png

クローラ実行結果の確認

左ペインのシステム情報>クロール情報を選択すると、先ほど実行したクロールの結果が表示されています。その行を選択します。
5_Fess管理者_システム情報_クロール情報_1.png

セッションIDを選択します。
5_Fess管理者_システム情報_クロール情報_2.png

ここにドキュメント用Webサーバのドキュメントが全て表示されればOKです。
5_Fess管理者_システム情報_クロール情報_3.png

ドキュメント用Webサーバに全文検索用資産を配置

全文検索用の資産はfull-text-search.jsfull-text-search.cssの2つです。
full-text-search.jsの変数 FESS_JSON_ENDPOINT(FESSサーバのJSON APIのエンドポイント) は適時置き換えてください。
これらの資産をドキュメント用Webサーバのドキュメントのルートフォルダ直下に配置してください。

full-text-search.js
$(function() {
    'use strict';

    // FESSサーバのJSON APIのエンドポイント(FessサーバのIPアドレス + /json)
    var FESS_JSON_ENDPOINT = 'http://192.168.1.5:10084/json';

    // 1ページあたりの検索結果表示件数
    var COUNT_PAR_PAGE = 10;


    // 目次の
    $('#toc')
        // 一番上に検索条件入力エリアを挿入
        .prepend(
            '<div id="search-area">' +
                '<form id="search-form">' +
                    '<div class="search-input-area">' +
                        '<i class="fa fa-search left-icon"></i>' +
                        '<input id="search-query" placeholder="全文検索" />' +
                        '<i class="fa fa-close right-icon"></i>' +
                    '</div>' +
                    '<input id="search-start" type="hidden" value="0"/>' +
                    '<input id="search-num" type="hidden" value="' + COUNT_PAR_PAGE + '"/>' +
                '<form>' +
            '</div>')
        // イベント登録
        .ready(function() {
            var $searchArea = $(this);

            // 入力項目の検索条件でEnterを押したら、検索処理を実行する
            $searchArea.find('#search-form').submit({navi:0}, doSearch);

            // 虫眼鏡アイコン押下したら、検索処理を実行する
            $searchArea.find(".left-icon").click({navi:0}, doSearch);

            // 検索条件入力したら、
            $searchArea.find("#search-query").keyup(function(){
              var $this = $(this);
              var $rightIcon = $this.parent().find(".right-icon");

              if($this.val().length > 0) {
                 // 検索条件に値がある場合は×アイコンの色を濃くする
                 $rightIcon.css('color','#555');
              } else {
                 // 検索条件に値がない場合は×アイコンの色を薄くする
                 $rightIcon.css('color','#ccc');
              }
            });

            // ×アイコン押下したら、
            $searchArea.find(".right-icon").click(function(){
              // ×アイコンの色を薄くして
              $(this).css('color','#ccc')
                      // 検索条件をクリアする
                     .parent().find("input").val('');
            });
        });

    // ドキュメントタイトルの
    $('#header>h1')
        // 直下に検索結果エリアを挿入
        .before(
            '<div id="search-result-area">' +
                '<div id="search-result-subheader"></div>' +
                '<div id="search-result-content"></div>' +
            '</div>')
        // イベント登録
        .ready(function() {
            $(this)
                .find('#search-result-area')
                    // 検索結果エリアのバツアイコンをクリックしたら、
                    .on("click", '#remove-search-result', function(e) {
                      var $searchResultArea = $(e.delegateTarget)
                      // 検索結果エリアを非表示モードにする
                      $searchResultArea.removeClass('show');
                      // 検索結果エリアの中身を削除する
                      $searchResultArea.find('#search-result-subheader').empty();
                      $searchResultArea.find('#search-result-content').empty();
                    })
                    // 前ページリンクをクリックしたら、1ページ前を検索する
                    .on("click", "#prevPageLink", {navi:-1}, doSearch)
                    // 次ページリンクをクリックしたら、1ページ後を検索する
                    .on("click", "#nextPageLink", {navi:1}, doSearch);
        });



    /**
     * 検索処理
     *
     * @param  {eventObject} event
     * @return {boolean}  submit処理を中断させるために必ずfalseを返却する
     */
    function doSearch(event){
      // 検索フィールドの値をトリムして取得
      var searchQuery = $.trim($('#search-query').val());
      // 空の場合は検索処理を実行しない
      if(searchQuery.length == 0) {
        return false;
      }


      // 表示開始位置、表示件数の取得
      var start = parseInt($('#search-start').val()),
          num = parseInt($('#search-num').val());
      // 表示開始位置のチェック
      if(start < 0) {
        start = 0;
      }
      // 表示件数のチェック
      if(num < 1 || num > 100) {
        num = 20;
      }
      // 表示ページ情報の取得
      switch(event.data.navi) {
        case -1:
          // 前のページの場合
          start -= num;
          break;
        case 1:
          // 次のページの場合
          start += num;
          break;
        default:
        case 0:
          start = 0;
          break;
      }


      // URLを構築
      var url = FESS_JSON_ENDPOINT + '?callback=?' + // 別ドメインを想定してJSONP形式でリクエストを送信する
                                     '&q=' + encodeURIComponent(searchQuery) +
                                     '&start=' + start +
                                     '&num=' + num;

      // 検索リクエスト送信
      // 別ドメインを想定してJSONP形式でリクエストを送信する
      $.ajax({
          url: url,
          dataType: 'jsonp',
          success: renderSearchResult
      });


      // ページ情報の更新
      $('#searchNum').val(num);

      // ページ表示を上部に移動
      $(document).scrollTop(0);

      // サブミットを抑止するためにfalseを返す
      return false;
    };


    /**
     * 検索成功時に検索結果を描画する
     *
     * @param  {Anything} data レスポンスデータ
     */
    function renderSearchResult(data) {
      // 検索結果処理
      var dataResponse = data.response;
      // ステータスチェック
      if(dataResponse.status != 0) {
        alert("検索中に問題が発生しました。");
        return;
      }

      // 検索結果領域を表示する
      $('#search-result-area').addClass('show');

      var $searchResultSubheader = $('#search-result-subheader'),
          $searchResultContent = $('#search-result-content'),
          record_count = dataResponse.record_count;

      // 検索結果がない場合
      if(record_count == 0) {
        // サブヘッダーに出力
        $searchResultSubheader[0].innerHTML =  '<div id="remove-search-result" style="float:right;"><i class="fa fa-times"></i></div>';

        // 結果領域に出力
        $searchResultContent[0].innerHTML = '<b>' + dataResponse.q + '</b>に一致する情報は見つかりませんでした。';

        return;
      }


      // 検索にヒットした場合
      var page_number = dataResponse.page_number,
          page_size = dataResponse.page_size,
          page_count = dataResponse.page_count,
          startRange = (page_number - 1) * page_size + 1,
          endRange = page_number * page_size,
          i = 0,
          max,
          offset = startRange - 1;

      $('#search-start').val(offset);


      // サブヘッダーに出力
      $searchResultSubheader[0].innerHTML = '<b>' + dataResponse.q + '</b> の検索結果 ' +
                                record_count + " 件中 " +  startRange + ' - ' +
                                endRange + ' 件目 (' + dataResponse.exec_time + ' 秒)' +
                               '<div id="remove-search-result" style="float:right;"><i class="fa fa-times"></i></div>'

      // 検索結果領域のクリア
      $searchResultContent.empty();


      // 検索結果の出力
      var $resultBody = $("<ol/>");
      var results = dataResponse.result;
      for(i = 0, max = results.length; i < max; i++) {
        var element =
            '<li>' +
                '<h4 class="title">' +
                    '<a href="' +results[i].url_link + '">' + results[i].title + '</a>' +
                '</h4>' +
                '<div class="body">' +
                    results[i].content_description +
                    '<br/>' +
                    '<cite>' + results[i].site + '</cite>' +
                '</div>' +
            '</li>';

        $(element).appendTo($resultBody);
      }
      $resultBody.appendTo($searchResultContent);


      // ページ番号情報の出力
      var pageArea = [];
      pageArea.push('<div id="pageInfo">', page_number, 'ページ目<br/>');
      if(page_number > 1) {
        // 前のページへのリンク
        pageArea.push('<a id="prevPageLink" href="#">&lt;&lt;前ページへ</a> ');
      }
      if(page_number < page_count) {
        // 次のページへのリンク
        pageArea.push('<a id="nextPageLink" href="#">次ページへ&gt;&gt;</a>');
      }
      pageArea.push('</div>');
      $(pageArea.join("")).appendTo($searchResultContent);
    }
});

full-text-search.css
@charset "UTF-8";

#search-area {
    margin-bottom: 1em;
}

.search-input-area {
    position:relative;
}

/* 入力項目 */
#search-query {
    padding: 0.7em 2em;
    width: 100%;
    color: black;
    font-family: arial,sans-serif;
    font-size: 1em;
    border: 1px solid #ccc;
    border-radius: 2em;
    outline: 0;
}

.search-input-area input:focus {
    border: 1px solid #4d90fe;
}

/* アイコンは入力項目の左と右に配置する */
.search-input-area .left-icon,
.search-input-area .right-icon {
    /* 縦方向の中央寄せ */
    position:absolute;
    top: 50%;
    margin-top: -0.5em;
    font-sise: 1em;
    /* 要素にマウスを合わせたら、マウスポインタのマークを変える */
    cursor:pointer;
}

.search-input-area .left-icon {
    left: 0.7em;
    color:#444;
}

.search-input-area .right-icon {
    right: 0.7em;
    /* 最初は、グレーアウトしておく */
    color: #ccc;
}

/* アイコンにマウスを合わせたら、サイズを大きくする */
.search-input-area .left-icon:hover,
.search-input-area .right-icon:hover {
    font-size: 1.4em;
}


.search-input-area .left-icon:hover {
    left: 0.5em;
}


.search-input-area .right-icon:hover {
    right: 0.5em;
}

/* 検索結果表示時に適用するスタイル */
#search-result-area.show {
    background: #f8f8f7;
    border: 0px solid;
    border-radius: 0.5em;
    margin-top: 1em;
    margin-bottom: 1em;
    padding: 1em;
}

ドキュメントに全文検索用資産の読み込み処理を追加

前手順でドキュメント用Webサーバに配置したfull-text-search.jsfull-text-search.cssを、
各ドキュメントから読み込むようにします。
full-text-search.jsはjQueryに依存しているので、
既存のドキュメントで読み込んでいない場合はjQueryも読み込みも追加してください。

ドキュメント用Webサーバがこのようなフォルダ構成だとしたら、

ドキュメント用Webサーバのドキュメントルート
├── full-text-search.css
├── full-text-search.js
└── asciidoctor-sample
    └── asciidoctor-sample.html

asciidoctor-sample.adocには下記を追加します。

++++
<link rel="stylesheet" href="../full-text-search.css"></link>
<script
  src="https://code.jquery.com/jquery-3.2.1.min.js"
  integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4="
  crossorigin="anonymous"></script>
<script src="../full-text-search.js"></script>
++++

全文検索ができるかの確認

以上の手順を実施すると、Asdciidoctorで全文検索ができるようになります。
ドキュメントをブラウザで見ると、目次上部に検索窓が追加されています。
7_全文検索イメージ_1.png

検索条件を入力し、虫眼鏡アイコンをクリックすると、検索結果が表示されます。
7_全文検索イメージ_2.png

まとめ

FessのREST APIを使用してAsciidoctorで全文検索できるようにする方法を紹介しました。
FessのREST APIを使えば、Asciidoctorに限らず、様々なシステムに全文検索機能を導入できそうです。

参考