Algolia Searchをつかって東京都府中市の粗大ゴミシール料金検索をつくりました

# 概要
* [TechRachoの週間Railsウォッチ](https://techracho.bpsinc.jp/hachi8833/2019_02_18/70283#2-1-10)をみて[Algolia Search](https://www.algolia.com/)に興味を持ったので、Algoliaを使ったアプリを作りました
* [東京都府中市の粗大ゴミシール料金](https://www.city.fuchu.tokyo.jp/kurashi/gomirisaikuru/dashikata/sodaigomidasikata.html)を検索して表示するアプリです。

# アプリ
こちらから使えます。（もし使えなかったら、それはalgoliaの使用上限に達したからです。あしからず。）
https://fuchu-gomi-sticker.herokuapp.com

# ソース
* https://github.com/junara/fuchu_gomi_sticker

# 動作イメージ
![4b67298239906ceb53d5aa9d4e98f8a4.gif](https://qiita-image-store.s3.amazonaws.com/0/124445/c322f52d-2047-031a-4524-b2460de76e31.gif)

# 解説
## 概要
* モデル作成と府中市の粗大ゴミシール料金表データのインポート
* algoliaを使ってサーバーサイドで検索
* algoliaを使ってフロントエンドでオートコンプリート
* Bulmaを使って飾り付け

## モデル作成と府中市の粗大ゴミシール料金表データのインポート
府中市が公開している粗大ゴミシールのCSVは下記の様になっています。
基本はこれを取り込みます。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/124445/90449414-9083-bb70-bd7b-fa1af9b9d99b.png)


Trashモデルをつくります。カラム構造は以下の通りです。

```db/migrate/20190223071022_create_trashes.rb
class CreateTrashes < ActiveRecord::Migration[6.0]
  def change
    create_table :trashes do |t|
      t.string :name # 品目
      t.string :name_furi # 品目のふりがな（ひらがな）
      t.integer :disposal_cost # 粗大ゴミシールの料金
      t.boolean :disposable, default: true # 引き取ってもらえるかかどうか（今回は説明しません）
      t.string :import_file # データcsvファイル名（今回は説明しません）
      t.string :memo # メモ（今回は説明しません）
      t.date :updated_on # データ更新日（府中市HPに書かれている日付です。）
      t.timestamps
    end
  end
end
```

imortはrake taskで行います。

```lib/tasks/db.rake
namespace :db do
  desc '府中市のcsvをインポート'
  # https://www.city.fuchu.tokyo.jp/kurashi/gomirisaikuru/dashikata/sodaigomidasikata.html
  # docker-compose exec web rake db:import['tmp/gojuon3012.csv','2019-01-05']
  task :import, %w[filename updated_on] => :environment do |_task, args|
    raise if args['filename'].nil? || args['updated_on'].nil?

    date = Date.parse(args['updated_on']).to_date
    Trash.import_csv(args['filename'], date) # データを取り込む
    Trash.all.each(&:update_name_furi) # フリガナをふる
  end
end
```

impor rakdeで使っている、importするメソッドをTrashにはやします。importは `acitiverecord-import` を使います。
配布されているCSVのエンコードcp932であり、UTF-8ではないので変換してあげてから取り込んでいます。


```app/models/trash.rb
require 'csv'
class Trash < ApplicationRecord
  def self.import_csv(file, date)
    list = []
    ActiveRecord::Base.transaction do
      CSV.foreach(file, encoding: 'cp932', headers: true) do |row|
        import_file = File.basename(file)
        list << Trash.new(trash_params(row).merge(import_file: import_file, updated_on: date))
      end
      import list # activerecord-import のメソッド
    end
  end

  def self.trash_params(row)
    {
      name: row['品目'],
      disposal_cost: row['料金'] == '不可' ? nil : row['料金'].sub(',', ''), # csvを見ると 不可書かれているデータがあったことからその場合の変換を追加。
      disposable: row['料金'] == '不可'# csvを見ると 不可書かれているデータがあったことからその場合の変換を追加。
    }
  end
end
```

ただし、このまま取り込むと、ひらがなの検索ができないので、使い勝手がいまひとつ。なので、漢字をひらがなに変換して別カラム `name_furi` に取り込みます。
`Trash.all.each(&:update_name_furi)`
変換は[rubyfuri](https://github.com/karur4n/rubyfuri)を使いました。使うためには[Yahoo Developer API](https://developer.yahoo.co.jp/)の入手が必要なので事前に取得して `.env` に記述してください。

```app/models/trash.rb
  def update_name_furi(force: false)
    return if name_furi.present? || !force

    rubyfuri = Rubyfuri::Client.new(ENV['YAHOO_JAPAN_DEVELOPER_CLIENT_ID'])
    update(name_furi: rubyfuri.furu(name))
  end
```

これでデータの準備は完了です。

## algoliaを使ってサーバーサイドで検索
### Algolia API入出
まず[algolia](https://www.algolia.com/)でアカウント作成しAPIを入手し、`.env` に記述してください。
[![Screenshot from Gyazo](https://gyazo.com/c5d1d9b4f853ae209a737a065673ffc4/raw)](https://gyazo.com/c5d1d9b4f853ae209a737a065673ffc4)

### algoliasearch-railsインストール
[gem algoliasearch-rails](https://github.com/algolia/algoliasearch-rails)をつかいます。設定はこのgemのREADMEを元に行います

### algoliasearch-rails設定
APIキーを記述します。本アプリではAPIキーはdotenvを使っているので `ENV[XXXX]`でよびだしています。直接かいてもいいです。
algoliaはkaminariとwill_paginateにも対応しています。 `pagination_backend` にお使いのgemを記述してください。

```config/initializers/algoliasearch.rb
AlgoliaSearch.configuration = {
    application_id: ENV['ALGOLIA_APPLICATION_ID'],
    api_key: ENV['ALGOLIA_ADMIN_API_KEY'],
    connect_timeout: 2,
    receive_timeout: 30,
    send_timeout: 30,
    batch_timeout: 120,
    search_timeout: 5,
    pagination_backend: :kaminari # 使用しているページネーションのgemを記載
}
```

TrashモデルにAlgoliaを使うためのコードを追加します。

```app/models/trash.rb
class Trash < ApplicationRecord
  include AlgoliaSearch
  algoliasearch do
    attributes :name, :name_furi, :disposal_cost # インデックスしたいカラムを書く
    searchableAttributes %w[name name_furi] # 実際検索対象となるカラムを書く
    tags do # タグをつけたい場合は書く
      t = [disposable ? '粗大ゴミ可' : '粗大ゴミ不可']
      t << '無料' if disposal_cost&.zero?
      t
    end
    customRanking ['desc(disposal_cost)'] # 検索結果の重みが同順位の場合更にソートしたい場合は書く（今回はシールの値段が高い順にならべました）
  end
```

インデックスを作成するためのコマンドを実行します。

```
rails c
> Trash.reindex
```

これで検索の準備は完了です。

Algoliaを使って検索するには、以下の通りです。 `Tarsh.search` でもよいのですが、 もし[ransack](https://github.com/activerecord-hackery/ransack)を入れている場合ぶつかりますので、 `algolia_search` がおすすめです。
paginationのページ番号は `page: ` にかいてください。

```app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
    @trashes = Trash.algolia_search(params[:query], hitsPerPage: 10, page: params[:page])
  end
end
```

あとは、普通のrailsと同じです。

ここまでのgithubのコードはこちらの[リリース](https://github.com/junara/fuchu_gomi_sticker/releases/tag/v1.0.0)です。 

## algoliaを使ってフロントエンドでオートコンプリート
サーバーサイドで検索しましたので、今度はフロントエンドから検索して、オートコンプリートを実現します。

基本的なやり方は [algolia/autocomplete.js](https://github.com/algolia/autocomplete.js/blob/master/README.md)をご覧下さい。railsで使うためのインストールを解説します。（別にCDNを使ってライブラリを使う場合は適当に読み飛ばしてください。）

必要なlibraryをインストールします。

```
yarn add autocomplete.js
yarn add algoliasearch
```

呼び出すためのjsファイルを追加します。

```app/javascript/packs/suggestionForm.js
const algoliasearch = require('algoliasearch'); // 呼び出す
const autocomplete = require('autocomplete.js'); // 呼び出す
const algolia_application_id = 'ALGOLIA_APPLICATION_ID'; // API KEY
const algolia_search_only_api_key = 'ALGOLIA_SEARCH_ONLY_API_KEY'; // API KEY

const client = algoliasearch(algolia_application_id, algolia_search_only_api_key);
const index = client.initIndex('Trash');
autocomplete('#query', {hint: false}, [ // id=queryの inputフォームを対象
  {
    source: autocomplete.sources.hits(index, {hitsPerPage: 5}), // 何件表示するか
    displayKey: function (suggestion) {
      return suggestion.name 
    },
    templates: {
      suggestion: function (suggestion) {
        return `${suggestion._highlightResult.name.value} / ${suggestion._highlightResult.name_furi.value}`; // 候補表示する文字列
      },
      empty: '検索キーワードを見直してください' // 検索結果がない時の表示
    }
  }
]).on('autocomplete:selected', function (event, suggestion, dataset, context) {
  console.log(event, suggestion, dataset, context);
  if (context.selectionMethod === 'click') {
    window.location.assign(`/?query=${suggestion.name}`); // 候補をクリックしたときの動作。今回は候補名をもとにGETリクエストを送ります。
    return;
  }
});
```

上記のjsファイルを呼び出します。

```app/views/home/index.html.slim
// inputフォームのidがquery。もしこれが異なる場合は上記の #queryを置き換えてください。
      = f.text_field :query, value: params[:query], class: 'input', autocomplete: 'off' 
// 省略
= javascript_pack_tag 'suggestionForm'
```

これで、autocompleteで表示されるようになります。

このままだと文字が重なって見にくいので、[autocomplete.jsで紹介されているcss](https://github.com/algolia/autocomplete.js/#look-and-feel)を追加しておきます。

```app/assets/stylesheets/algolia.css
.algolia-autocomplete {
    width: 100%;
}
.algolia-autocomplete .aa-input, .algolia-autocomplete .aa-hint {
    width: 100%;
}
.algolia-autocomplete .aa-hint {
    color: #999;
}
.algolia-autocomplete .aa-dropdown-menu {
    width: 100%;
    background-color: #fff;
    border: 1px solid #999;
    border-top: none;
}
.algolia-autocomplete .aa-dropdown-menu .aa-suggestion {
    cursor: pointer;
    padding: 5px 4px;
}
.algolia-autocomplete .aa-dropdown-menu .aa-suggestion.aa-cursor {
    background-color: #B2D7FF;
}
.algolia-autocomplete .aa-dropdown-menu .aa-suggestion em {
    font-weight: bold;
    font-style: normal;
}
```

ここまでの[リリースはこちら](https://github.com/junara/fuchu_gomi_sticker/releases/tag/v1.1.0)を参照ください。

## Bulmaを使って飾り付け

[Bulma](https://bulma.io/)をインストールします。

```
yarn add bulma
```

application.cssをapplication.scss にリネームしてBulmaをimporとする記述を追加する。

```app/assets/stylesheets/application.scss
/*
 * This is a manifest file that'll be compiled into application.css, which will include all the files
 * listed below.
 *
 * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
 * vendor/assets/stylesheets directory can be referenced here using a relative path.
 *
 * You're free to add application-wide styles to this file and they'll appear at the bottom of the
 * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
 * files in this directory. Styles in this file should be added after the last require_* statement.
 * It is generally better to create a new file per style scope.
 *
 *= require_tree .
 *= require_self
 */
@import "bulma/bulma"
```

あとは、以下のような感じで書きます。

```app/views/home/index.html.slim
h2.title 東京都府中市 粗大ごみの料金検索
div
  p 府中市が公開している「粗大ごみの料金表」より該当品のシール代金を表示します
  p 粗大ゴミとして廃棄したい物品の名前を入力して検索してください。
  p シールの購入方法はこちらの
  p = link_to '府中市粗大ごみの出し方', 'https://www.city.fuchu.tokyo.jp/kurashi/gomirisaikuru/dashikata/sodaigomidasikata.html'
  p からお願いします。
= form_with url: root_path, method: :get, local: true, autocomplete: 'off' do |f|
  .field.has-addons
    .control.is-expanded
      = f.text_field :query, value: params[:query], class: 'input', autocomplete: 'off'
    .control
      = f.submit '検索', class: 'button is-info'

.container
  div.columns.is-multiline
    - @trashes.each do |trash|
      div.column.is-6.is-12-mobile
        .card
          header.card-header
            p.card-header-title = trash.name
          .card-content
            .content
              p = "#{trash.disposal_cost} 円"
          footer.card-footer
            .card-footer-item
              = trash.updated_on


= paginate @trashes

= javascript_pack_tag 'suggestionForm'
```

以上。

# 今後
* geo locationをつかって、距離で検索もできるのでそちらを試します。 https://github.com/algolia/algoliasearch-rails#geo-search
* いい感じなので、そのうち、 https://www.dokode.work/ に適用します。