Fessを使用して、Asciidoctorで簡単に全文検索できるようにする

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

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

導入するとドキュメントの右上に検索窓が出てきて全文検索できるようになります。
![6月-24-2017 19-05-32.gif](https://qiita-image-store.s3.amazonaws.com/0/49915/123ec771-fb77-e4dc-df7e-a4b407792759.gif "6月-24-2017 19-05-32.gif")


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


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

![0_Fess_全体像_1.png](https://qiita-image-store.s3.amazonaws.com/0/49915/10c8ac46-2155-8eb7-a5ed-7c13ce544ec2.png)

![0_Fess_全体像_2.png](https://qiita-image-store.s3.amazonaws.com/0/49915/40e1d456-2635-2aed-333d-8b1aec447931.png)

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

![0_Fess_全体像_3.png](https://qiita-image-store.s3.amazonaws.com/0/49915/0badb8d2-1a58-22b6-457c-12becf3a8911.png)


### Fessのインストール
Docker Hubの[codelibs/fess](https://hub.docker.com/r/codelibs/fess/)を使用します。今回ポートは10084で公開します。
Dockerを使わない場合は[Fess インストールガイド](http://fess.codelibs.org/ja/11.2/install/index.html)を参考にしてください。

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

### Fessの設定
#### クローラの設定
`http://[PCのローカルIPアドレス]:10084/login`にアクセスするとログイン画面が表示されます。
デフォルトのID/PASS　`admin/admin`でログインしましょう。
![1_Fess管理者_ログイン画面.png](https://qiita-image-store.s3.amazonaws.com/0/49915/e790eca9-5073-948e-3e43-d7546d57fae5.png)
<br/>

ログインするとダッシュボードが表示されます。左ペインの`クローラ` > `Web`　を選択しましょう。
![2_Fess管理者_ダッシュボード.png](https://qiita-image-store.s3.amazonaws.com/0/49915/acfbe950-fa74-d0cb-3cee-5e8db4de5b74.png)
<br/>

Webクローラにはまだ何も登録されていないので、左上の`+　新規作成`ボタンをクリックしましょう。
![3_Fess管理者_Webクロール設定_1.png](https://qiita-image-store.s3.amazonaws.com/0/49915/631037b4-aab2-8e0d-f9aa-3d23ec917a29.png)
<br/>

Webクロール情報入力画面が表示されます。
![3_Fess管理者_Webクロール設定_2.png](https://qiita-image-store.s3.amazonaws.com/0/49915/ee5a09c9-7cce-3fec-7884-1258c32fa91a.png "3_Fess管理者_Webクロール設定_2.png")

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

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


値を設定したら、画面を下にスクロールして`+　作成`ボタンをクリックします。
すると下記のようにWebクロールのデータが１件登録されます。
![3_Fess管理者_Webクロール設定_4.png](https://qiita-image-store.s3.amazonaws.com/0/49915/3d00bf5a-f131-d367-de53-af10bb36839d.png)
<br/>


#### クローラの実行
左ペインで　`システム` > `スケジューラ` を選択してジョブスケジューラを開きます。
ジョブスケジューラで `Default Crawler`を選択します。
![4_Fess管理者_スケジューラ_1.png](https://qiita-image-store.s3.amazonaws.com/0/49915/b0aeca0d-64a4-43da-782b-0a1a0cd70b83.png)
<br/>

`今すぐ開始`をクリックします。
![4_Fess管理者_スケジューラ_1.1.png](https://qiita-image-store.s3.amazonaws.com/0/49915/00e76920-bfad-eeb1-cb99-724e3a9392a9.png "4_Fess管理者_スケジューラ_1.1.png")
<br/>

するとクロールが実行されます。
![4_Fess管理者_スケジューラ_2.png](https://qiita-image-store.s3.amazonaws.com/0/49915/725d300f-07f5-724d-ce93-8e1c9752e10e.png)
<br/>

しばらくして`F5`キーを押してブラウザを更新してください。
クロールが終了すると、スケジューラの状態が`実行中`から`有効`になります。
![4_Fess管理者_スケジューラ_3.png](https://qiita-image-store.s3.amazonaws.com/0/49915/ee4f5277-aac3-ad7e-cdeb-41d39b733c1f.png)
<br/>


#### クローラ実行結果の確認
左ペインの`システム情報`>`クロール情報`を選択すると、先ほど実行したクロールの結果が表示されています。その行を選択します。
![5_Fess管理者_システム情報_クロール情報_1.png](https://qiita-image-store.s3.amazonaws.com/0/49915/92f310b5-83f6-deda-448d-afa8c5b0f104.png "5_Fess管理者_システム情報_クロール情報_1.png")
<br/>


セッションIDを選択します。
![5_Fess管理者_システム情報_クロール情報_2.png](https://qiita-image-store.s3.amazonaws.com/0/49915/4e80cd14-3634-f665-b448-8b4b4e7a8d21.png "5_Fess管理者_システム情報_クロール情報_2.png")
<br/>

ここにドキュメント用Webサーバのドキュメントが全て表示されればOKです。
![5_Fess管理者_システム情報_クロール情報_3.png](https://qiita-image-store.s3.amazonaws.com/0/49915/60b86bc4-f70f-42c8-a017-5f138b3b88e3.png "5_Fess管理者_システム情報_クロール情報_3.png")
<br/>

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

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

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

    // １ページあたりの検索結果表示件数
    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();
                    })
                    // 前ページリンクをクリックしたら、１ページ前を検索する
                    .on("click", "#prevPageLink", {navi:-1}, doSearch)
                    // 次ページリンクをクリックしたら、１ページ後を検索する
                    .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);
    }
});

```


```css: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.js`と`full-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](https://qiita-image-store.s3.amazonaws.com/0/49915/99093cd6-58e8-7cde-a3d8-5bec2a020668.png)
<br/>

検索条件を入力し、虫眼鏡アイコンをクリックすると、検索結果が表示されます。
![7_全文検索イメージ_2.png](https://qiita-image-store.s3.amazonaws.com/0/49915/cde3d8ea-d883-a66b-f88d-9a5941026bc9.png)
<br/>


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


## 参考
* [Fess で作るApache Solrベースの検索サーバー 〜 REST API 編](http://fess.codelibs.org/ja/articles/article-4.html#js)
* [全文検索サーバー Fess を Docker で動かす](http://qiita.com/cookienote/items/1f2443be25630a78d946)