ファストフード検索というサービスを作った

この記事は CAMPHOR- Advent Calendar 13日目の記事です.

先日ファストフード検索というWebアプリを作ったので,そのことについて書きます.

ff.kuminecraft.xyz

経緯

ちょっといいメシ屋を探すとき,僕はFoursquareというアプリを使います.Foursquareスマホアプリを開くと「昼食」「カフェ」みたいなカテゴリーが表示され,いずれかを選択すると近隣のおすすめのお店のリストが表示されます.

便利に使っているのですが,Foursquareは "ちょっといいご飯" を教えてくれるサービスなので,例えばマクドナルドとか吉野家とは載ってません. しかし,僕はいつでもそういう気合の入った飯が食べたいわけではなく,どこで食っても同じ味がするファストフードを食いたいと思うこともあります.つまり,「ファストフード店に特化したFoursquare」みたいな概念を僕は求めているわけです.

ところが「現在地近くのファストフード店を,チェーンを横断してシュッと検索する手段」というのはなかなかありません. Google mapはまだそういうファジーな検索ができるほど賢くはなく,例えば「ファストフード」みたいな括りで検索をしてもハンバーガー屋しか出ません.

そういうわけで,現在地近くのファストフード店をシュッと表示してくれるサービスがあったら便利だろうと思い,「ファストフード検索」を作りました.

ff.kuminecraft.xyz

リンクを踏むとデバイスの位置情報を取得し,近くにあるファストフード店の一覧を表示します.

f:id:threetea0407:20181205222735p:plain
アプリのスクリーンショット

今の所,対応しているチェーンは以下の8つです.

このチェーンも追加してくれみたいなのがあったら @genya0407 にリプを飛ばしてください.

作り方

作り方は大きく以下の3段階に分かれています.

  1. スクレイピングする
  2. APIサーバーを作る
  3. クライアントサイドを作る

1. スクレイピングする

まず,店舗の名前と位置情報を取得する必要があります. 初めはぐるなびAPIとかを使おうと思ってたんですが,利用規約を見ると商用利用はできないとあるので断念*1. 結局各チェーンのWebサイトをスクレイピングして情報を集めました.

ここはElixirでやりました.フレームワークは用いずに,FlokiOPQ というライブラリを組み合わせてスクレイピングをやっていきます.

Floki

Floki は,CSS selectorとかを使ってDOMを探索できるやつです.こういう感じで使います.

html_string
|> Floki.find("#searchResult")
|> Floki.find("a")
|> Floki.attribute("href")
|> Floki.text()
|> String.trim()
#=> "https://twitter.com/"

セレクタをchainできるのがいいですね.

OPQ

OPQ はシンプルなin-memory queueで,ワーカーの数を指定したりインターバルを指定したりしてレート制限が実現できます.こういう感じで使います.

{:ok, opq} = OPQ.init(workers: 1, interval: 1000) # Queueを初期化

OPQ.enqueue(opq, fn -> IO.puts("hello") end) # タスクをenqueue
OPQ.enqueue(opq, fn -> IO.puts("world") end) # タスクをenqueue
# => hello
# |一秒停止|
# => world

例えばHTTPリクエストを全部OPQを介して投げることで,全てのリクエストの間に一定の時間間隔を設けることができます*2

クローラの設計

各チェーン店の店舗名と位置情報を取得し,JSONファイルに書き出すということをやっていきます.

大抵のチェーン店では,「店舗一覧」という画面があって,そこに「店舗詳細」へのURLが列挙されているという構造になっています. したがってスクレイピングは,

  1. 店舗一覧ページをスクレイピングして店舗詳細ページのURLを抽出する
  2. それぞれの店舗のURLをスクレイピングする
  3. 情報を集約してJSONに書き出す

という流れでやることになります.

せっかくElixirを使っているので並行処理でやっていきたい*3わけですが,ちょっと面倒なのが実行した結果を集約するところです.複数のプロセスから同時に書き出すとRace Conditionが起きて結果がめちゃくちゃになるので,各プロセスがスクレイピングしてきた情報を一箇所に集めて矛盾が生じないようにする必要があります.

Elixirの並行処理クローラの動作は以下のようになります.

  • indexプロセスは,「一覧ページをスクレイピングして,detailプロセスに詳細ページのURLを送信する」というタスクをOPQにenqueueします
  • detailプロセスはは,「詳細ページをスクレイピングして,店舗の情報をJSONプロセスに送信する」というタスクをOPQにenqueueします
  • JSONプロセスは,店舗の情報をJSONファイルに書き出します

緑の丸がプロセスです.各プロセスがOPQ(赤い丸)にタスクをenqueueして,タスクが次のプロセスに結果を送信するという気持ちです.

f:id:threetea0407:20181205215312p:plain
ポンチ絵

このようにすることで,クローラ全体のHTTPリクエストの頻度を制御することが可能になります.

ここで疑問に思う方もいると思うのですが,JSONプロセス以外はプロセスを分ける必要はありません*4. 例えば,OPQのタスクから直接OPQにタスクを入れる設計にしてもいいわけです.

f:id:threetea0407:20181205220436p:plain
ポンチ絵2

しかし,このような設計にすると,「「「タスクを発行するというタスク」を発行するというタスク」を発行する」みたいな意味のわからんことになります. 初めはそういう設計にしていたのですが,複雑過ぎて頭が爆発てしまったため,一段階ごとに情報を集約するように設計し直して複雑度を減少させました.

ファストフードチェーンを増やすのが大変という話

そういう感じでスクレイピングの方針は立ったわけですが実は大きな問題があって,それはファストフードチェーンごとに全然ページの構造が違うということです.

前節ではあたかも,全てのファストフードチェーンが「一覧ページ → 詳細ページ」のような構造になっているかのような書き方をしました. しかし実際には,そもそも一覧ページが無かったり,「都道府県一覧ページ → その都道府県にある店舗一覧ページ → 詳細ページ」のように三段階になっていたり,「都道府県一覧ページ (→ もしその都道府県にある店舗が10件以上であれば市区町村一覧ページを表示する) → 詳細ページ」のように二〜三段階になっていたりします.

このようにチェーンごとにWebページの構造が全然違うので,チェーン毎にスクレイピングのコードをほとんど全部書き換えることになりました. アプリケーションを作成する上で,ここが一番面倒くさかったです.

余談ですが,店舗一覧などのデータを共有されることに企業側もデメリットはないはずなので,そういうのが 商用利用可能な形で 公開される未来が来てほしいと強く思いました.スクレイピングした結果を公開していいなら今すぐにでもできるんだけど,そういう法律になっていないので難しいですね.

2. APIサーバーを作る

店舗情報が集まったのでAPIサーバーを作っていきます.Elixirでやります.

APIサーバーの仕事は店舗を検索して返すことです. デバイスからユーザーの現在地(緯度と経度)が飛んできたとき,APIサーバーは近くの店舗を距離順にソートして返します.

構想の段階ではPostgreSQLPostGIS を使えばいけるやろと思ってたんですが,PostgreSQLに拡張を入れるのが面倒くさかったのと,よく考えたらmutableなデータを持つわけじゃないからDB必要ないし,全部プログラムの中でやったほうが高速になりそうだし,スケールしそうだし簡単じゃんということに気づいてDBは使わないことにしました.

店舗情報の読み込み

じゃあどうやって店舗情報を読み込むんだという話になります. 一番最初はリクエストがくるたびに店舗情報の入ったJSONを読みに行ってたんですが,変化しない情報なので毎回読みに行く必要がないなと気付き,コンパイル時に店舗情報を実行ファイルに混ぜ込むことにしました.

コンパイル時にデータを混ぜ込む方法についてはQiitaに記事を書いたので,いいねして徳を積んでください*5

qiita.com

店舗の検索

店舗情報が読み込めたので,あとはユーザーの現在地と店舗位置から距離を計算してソートして,半径10km以内とかの条件でフィルタして,店舗一覧をJSONで返してやればいいわけです.

こはちょっと計算量を落とす工夫があって*6ソートしてからフィルタすると店舗数nに対して計算量が O(n^ 2 \log(n)) になるんですが,順番を逆にしてフィルタしてからソートしてやると計算量がO(n^ 2 d \log(nd))になります.ここでdは店舗の "密度" です.

日本の国土面積が大体40万平方km なので,半径10kmという条件でフィルタすると大体 d \approx 1/1000 ぐらいになります*7. 実際ベンチマークを取ると,n \approx 10000 のときに1.5倍ぐらいの速度差が出ます.この差はnが大きくなると広がるようです*8

余談ですが,こういうことを考えるようになったのは,CAMPHOR-でやっていた「競プロ入門書輪読会」の影響が大きいと思います*9*10. それまでは,遅くなったらキャッシュに載せるということしかできなかったのですが,今回の問題は計算結果をキャッシュするのが難しいのでそれでは高速化できなかっただろうと思います.

この記事を読んだ関西の学生はCAMPHOR-で一緒に勉強しましょう!(ダイレクトマーケティング)

Webフレームワーク

ちなみに,DB使わないのでORMもいらないし,エンドポイントが2個しかないのでコントローラーもいらないということで,Phoenixは使わずtrotという小さいWebフレームワークを使っています.こういう感じで書けます.

defmodule Web do
  use Trot.Router
  use Trot.Template

  static("/js", "js")
  static("/images", "images")

  get "/" do
    {200, render_template("index.html.eex", [val: "hoge"])}
  end
end

3. フロントエンドを作る

普段はフロントエンドなんて本当に適当に済ませてしまう人間なんですが,今回はサービスの性質上SPAっぽくせざるを得ない*11のでVue.jsをチョット書きました.

一応PWAになっていて,二回ぐらい見ると「ホーム画面に追加しますか?」みたいなプロンプトが出ると思います. このアプリは思い立った瞬間にぱっと開けるというのが重要なので渋々実装しました.

デザインはBootstrapを適当にやりました. Bootstrapは,デザインセンスがない人間が最低限のUIを作るには本当に便利なフレームワークだなと最近強く思います.

また,レイアウトとかはFoursquareスマホアプリをかなり参考にしました. 画像の位置とか店名の文字色とか,普段使ってる上質なUIは違和感がないので何も思わないですが,いざ自分で似たようなものを作ろうとするとハチャメチャな見た目になるので,デザイナーってすごいという気持ちになりました.

あとアイコンはちょっと頑張って作りました*12. マップピンの頭部がハンバーガーになっているという気持ちです.

f:id:threetea0407:20181126233936p:plain:w200

参考にしたブログ記事:

tomokortn.hatenablog.com

思ったこと

よく考えてみたら,不特定多数の人間が使うことを前提としたサービスを真面目に作るのは初めて*13でした.今までは寮生が使う業務アプリケーション的なものを作ったことしかなかったので.

鍛えたWeb力でサービスをガシガシ開発して不労所得で暮らしたい.

追記

既にスマホアプリが存在していた...

http://jfmap.com/

*1:将来広告張ったりするかもしれないし...

*2:クローラが一秒一回を超えてリクエストを投げるのはマナー違反. http://ascii.jp/elem/000/001/177/1177656/

*3:一般にクローラを書くときに並行処理でやっていくのは,IOを大量にやるというクローラの特性からするとパフォーマンスが高くなるので良さそうですが,今回の場合は同一のサーバーに連続してリクエストを投げるので,各リクエストの間に1秒以上間隔を開ける必要があり,そっちのほうが律速になるので,並行処理でやってもそんなにパフォーマンスは変わらない気もします.

*4:JSONプロセスはファイル書き出しを排他制御するために必須です

*5:メタプログラミングですね.母語Rubyの人間なのですぐにメタプログラミングをしたくなります.

*6:CSクソ雑魚マンなので間違ったこと言ってたらごめんなさい

*7:ただしファストフードの店舗は日本の国土に均一に存在しているものとする

*8:30msが18msになるとかの速度感なんであんまり意味ないッスけどね...

*9:競プロ入門書輪読会 - CAMPHOR- Blog

*10:螺旋本輪読会に参加した話 - zunda2nd’s diary

*11:JavaScriptで現在位置を取得してサーバーにリクエストを投げないといけない

*12:溢れ出る00年代感...

*13:前に作った「嫌いになった企業ランキング 理由検索(今はelasticsearchが落ちてるせいで動きません)」というサービスもありますが,あれはジョークで作ったものなので自分の中では別カテゴリーです.質問箱クローンも自分が使うためのものなので別カテゴリー.