OCamlで公共スポーツ施設の空き状況をスクレイピングするスクリプトを書いてみた

この記事は RECRUIT MARKETING PARTNERS Advent Calendar 2017 の投稿記事です。

こんにちは。スタディサプリENGLSHでサーバーサイドとインフラを担当している松川です。

バスケットボールを趣味にしているのですが、体育館など公共スポーツ施設の予約がなかなか取れない…
都心近郊ですと人口に対してスポーツ施設が少ないため、すぐに枠が埋まってしまいがちです。稀にキャンセルが出ることもありますが、自治体のwebサイトにプッシュ通知やRSSといった類のものは滅多にありませんので、こまめに巡回しなくてはなりません。

流石にそれは効率的ではないので「何かクローラーでも書こうかなぁ…」と思っていたところ、同僚から「OCamlのlamdba-soupというライブラリがええで!」と紹介してもらったので、これを期にOCamlデビューしてみることにしました。

結論から言いますと、とても良い感じに書けたのでご紹介します!

OCaml とは

OCaml([oʊˈkæməl] oh-KAM-əl、オーキャムル、オーキャメル)は、フランスの INRIA が開発したプログラミング言語MLの方言とその実装である。MLの各要素に加え、オブジェクト指向的要素の追加が特長である。
( 中略 )
処理系としての特徴は、関数型言語としてはかなり高速に動作することが挙げられ、gccでコンパイルされたC言語と互角かやや遅い程度と言われる。

ロゴがPerlっぽい。

何はともあれ環境構築

環境情報

  • macOS Sierra(10.12.6)
  • Homebrew

macOSであればHomebrewだけで環境構築できます。以下のコマンドを順次実行します。

$ brew install ocaml
$ brew install opam
$ brew install hg
$ brew install darcs
$ opam init
$ eval `opam config env`
$ brew install rlwrap
$ echo 'alias ocaml="rlwrap ocaml"' >> ~/.zshrc
$ echo ~/.ocamlinit >> 'let printer ppf = Format.fprintf ppf "\"%s\"";;'
$ echo ~/.ocamlinit >> '#install_printer printer;;'
$ opam switch 4.04.2
$ ocaml
        OCaml version 4.04.2
# "hello world!!" ;;
- : string = "hello world!!"

ハマりどころ

結果、OCaml4.04.2を使うことで回避できたのですが、バージョン周りで少しハマりました。

tlsがインストール出来ない

OCaml4.06.0だと後述のtlsがインストール出来ませんでした。

インストールエラー 😇

=-=- Processing actions -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=  🐫
[ERROR] The compilation of asn1-combinators failed at "ocaml pkg/pkg.ml build --pinned false --tests false".
#=== ERROR while installing asn1-combinators.0.1.3 ============================#
# opam-version 1.2.2
# os           darwin
# command      ocaml pkg/pkg.ml build --pinned false --tests false
# path         /Users/ma2k8/.opam/4.06.0/build/asn1-combinators.0.1.3
# compiler     4.06.0
# exit-code    1
# env-file     /Users/ma2k8/.opam/4.06.0/build/asn1-combinators.0.1.3/asn1-combinators-66869-d8a4ae.env
# stdout-file  /Users/ma2k8/.opam/4.06.0/build/asn1-combinators.0.1.3/asn1-combinators-66869-d8a4ae.out
# stderr-file  /Users/ma2k8/.opam/4.06.0/build/asn1-combinators.0.1.3/asn1-combinators-66869-d8a4ae.err
### stdout ###
# [...]
# File "src/asn_prim.ml", line 120, characters 60-409:
# Error: Signature mismatch:
#        ...
#        Values do not match:
#          val random : ?size:int -> unit -> bytes
#        is not included in
#          val random : ?size:int -> unit -> t
#        File "src/asn_prim.ml", line 13, characters 2-37: Expected declaration
#        File "src/asn_prim.ml", line 128, characters 6-12: Actual declaration
# Command exited with code 2.
### stderr ###
# pkg.ml: [ERROR] cmd ['ocamlbuild' '-use-ocamlfind' '-classic-display' '-j' '4' '-tag' 'debug'
#      '-build-dir' '_build' 'opam' 'pkg/META' 'CHANGES.md' 'LICENSE.md'
#      'README.md' 'src/asn1-combinators.a' 'src/asn1-combinators.cmxs'
#      'src/asn1-combinators.cmxa' 'src/asn1-combinators.cma'
#      'src/asn_ber_der.cmx' 'src/asn_combinators.cmx' 'src/asn_random.cmx'
#      'src/asn_core.cmx' 'src/asn_prim.cmx' 'src/asn_writer.cmx'
#      'src/asn_cache.cmx' 'src/asn_time.cmx' 'src/asn_oid.cmx' 'src/asn.cmx'
#      'src/asn.cmi' 'src/asn.mli']: exited with 10
=-=- Error report -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=  🐫
The following actions were aborted
  ∗  install tls  0.8.0
  ∗  install x509 0.5.3
The following actions failed
  ∗  install asn1-combinators 0.1.3
No changes have been performed

REPLでCohttp(httpクライアント)を読んでくれない

OCaml4.03.0だとCohttpがREPLで読み込めませんでした。

ロードエラー😇

# #require "Cohttp";;
/Users/ma2k8/.opam/4.03.0/lib/base/caml: added to search path
/Users/ma2k8/.opam/4.03.0/lib/base/caml/caml.cma: loaded
/Users/ma2k8/.opam/4.03.0/lib/base/shadow_stdlib: added to search path
/Users/ma2k8/.opam/4.03.0/lib/base/shadow_stdlib/shadow_stdlib.cma: loaded
/Users/ma2k8/.opam/4.03.0/lib/sexplib/0: added to search path
# #require "Cohttp";;03.0/lib/sexplib/0/sexplib0.cma: loaded
Error: Reference to undefined global `Ephemeron'

vimで書くための設定

必要なパッケージをインストールします。

$ opam install merlin
$ opam install ocp-indent

.vimrcに以下を追記します。

"*****************************************************************************
" OCaml
"*****************************************************************************"
let g:opamshare = substitute(system('opam config var share'),'\n$','','''')
execute 'set rtp+=' . g:opamshare . '/merlin/vim'
let g:syntastic_ocaml_checkers = ['merlin']
execute 'set rtp^=' . g:opamshare . '/ocp-indent/vim'
function! s:ocaml_format()
let now_line = line('.')
exec ':%! ocp-indent'
exec ':' . now_line
endfunction
augroup ocaml_format
autocmd!
autocmd BufWrite,FileWritePre,FileAppendPre *.mli\= call s:ocaml_format()
augroup END

お目当てのライブラリをインストール

以下のコマンドを順次実行します。

$ opam install conduit # Stream
$ opam install tls # ssl拡張
$ opam install cohttp lwt js_of_ocaml cohttp-lwt-unix # httpクライアント
$ opam install lambdasoup # htmlパーサー
$ opam install core # 標準ライブラリの拡張
$ opam list |grep 'conduit\|tls\|cohttp\|lambdasoup\|core'
cohttp 1.0.0 An OCaml library for HTTP clients and ser
cohttp-lwt 1.0.0 An OCaml library for HTTP clients and ser
cohttp-lwt-unix 1.0.0 An OCaml library for HTTP clients and ser
conduit 1.0.0 Network conduit library
conduit-lwt 1.0.0 Network conduit library
conduit-lwt-unix 1.0.2 Network conduit library
tls 0.8.0 Transport Layer Security purely in OCaml

お勉強

詳細は割愛しますが、以下のドキュメントが参考になりました。

REPLでちょろっと触ってみる

# #require "lwt";;
# #require "cohttp";;
# #require "cohttp-lwt-unix";;
# #require "lambdasoup";;
# open Lwt
# open Cohttp
# open Cohttp_lwt_unix
# open Soup
# (* ocaml.jpのbodyを取得*)
# let body = Lwt_main.run (Client.get(Uri.of_string "http://ocaml.jp/") >>= fun (resp,body) -> body |> Cohttp_lwt.Body.to_string);;
# (* title取得 *)
# (parse body) $ "title" |> R.leaf_text;;
- : string = "OCaml.jp "
# (* ul class=list1以下のli要素内のテキストを取得 *)
# (parse body) $ ".list1" |> fun ul -> ul $$ "~ *" |> elements |> iter (fun li -> trimmed_texts li |> String.concat "\n" |> print_endline);;
~略~
OCaml4.01.0の変更点
変更点の概観は
OCaml 4.01.0 変更点 - Oh, you `re no (fun _ → more)
も参考になります。
2012/10/05 Version 4.00.1 リリース
OCaml4.00.1の変更点
2012/07/26 Version 4.00.0 リリース
OCaml4.00.0の変更点
2012/05/14 和訳マニュアルを更新しました。
過去のニュース
- : unit = ()

サクッと要素にアクセスできて気持ち良い…!

下記のようなテーブルのtd要素のテキストを取得したいとします。

<table class="calender">
<tbody>
  <tr>
    <th>日</th>
    <th>月</th>
    <th>火</th>
  </tr>
  <tr>
   <td>hoge1</td>
   <td>hoge2</td>
   <td>hoge3</td>
  </tr>
  <tr>
    <td>hoge4</td>
    <td>hoge5</td>
    <td>hoge6</td>
  </tr>
 </tbody>
</table>

こちらは 👇の1行だけで完結してしまいます。これは便利ですね。

# (parse raw_html) $ ".calender" $$ "td" |> to_list |> List.map (fun x -> trimmed_texts x);;
- : string list list =
[["hoge1"]; ["hoge2"]; ["hoge3"]; ["hoge4"]; ["hoge5"]; ["hoge6"]]

Have fun!!!!!!

施設予約クローラーのソース公開したら施設に迷惑をかけてしまいかねないので、https://ocaml.orgをパースした内容をSlackに通知するまで↓↓

open Core
open Lwt
open Cohttp
open Cohttp_lwt_unix
open Soup
(* SlackへPost *)
let send_slack body =
  let webhook_token = "xxx" in
  let channel = "%23xxx" in
  let webhook_url = "https://xxx.slack.com/services/hooks/slackbot?token=" ^ webhook_token ^ "&amp;channel=" ^ channel  in
  let params = [body] in
  Client.post
    ~body:(Cohttp_lwt.Body.of_string_list params)
    (Uri.of_string webhook_url) >>= fun (resp, body) ->
  let code = resp |> Response.status |> Code.code_of_status in
  body |> Cohttp_lwt.Body.to_string >|= fun body ->
  body
(* HTMLを取得 *)
let fetch_html =
  let target_url = "https://ocaml.org/" in
  Client.get
    (Uri.of_string target_url) >>= fun (resp, body) ->
  let code = resp |> Response.status |> Code.code_of_status in
  body |> Cohttp_lwt.Body.to_string >|= fun body ->
  body
(* HTMLパース *)
let parse_html raw_html =
  Lwt.return((parse raw_html) $ "title" |> R.leaf_text)
(* main *)
let () =
  let res = fetch_html
    >>= parse_html
    >>= send_slack in
  print_endline ("result\n" ^ Lwt_main.run (res))

OCamlにbindとかないかなーと探してみると、サンプルコードにHaskellと同じ>>=があったのでシュッとかけました。

※追記: Lwtライブラリの提供している>>=で、OCaml自体には備わっていないものでした。

ビルド

ocamlfindがとても便利でした。

OMakeだとビルドできず諦めました。ubuntuならサクっと出来そうなので、気が向いたらDocker化します。

$ ocamlfind ocamlopt -thread -linkpkg -package cohttp,lwt,cohttp-lwt-unix,tls,lambdasoup main.ml -o hoge

実行

$ ./hoge
ok

スクレイピングしてきた内容をSlackに通知できました。

施設WEBサイトのスクレイピング時にハマったこと

Cookie取得

対象のサイトはTopページでset-cookieヘッダからCookieを取得するのですが、{key名=deleted}となって取得出来ないことがあったので、その場合は例外投げるようにしました。

exception Cookie_Fetch_Error
let fetch_cookie =
  let target_url = "xxx" in
  Client.get
    (Uri.of_string target_url) >>= fun (resp, body) ->
  let headers = resp |> Cohttp_lwt.Response.headers in
  let cookie_header = Header.get headers "set-cookie" in
  let cookie = match cookie_header with
    |Some v when v != "xxxx=deleted" -> List.hd (String.split_on_char ' ' v)
    |v -> raise Cookie_Fetch_Error in
  Printf.printf "Cookie: %s" cookie;
  Lwt.return (cookie)

セッション周り

対象のサイトがcookieベースでサーバー側に状態を持ってセッションを管理しており、決まった導線を辿らないと目的のページが開けなかったので再帰で辿って対象のページを開けるcookieを作りました。

let follow_path cookie =
  let target_url = "xxx" in
  let load_params = [
    "n1"; (* 検索方法選択画面 *)
    "n2"; (* 施設検索画面 *)
    "n3";  (* 室場選択画面 *)
  ] in
  let rec loop xs =
    match xs with
    | [] -> Lwt.return cookie
    | x::xs ->
      Lwt_unix.sleep 1; (* 負荷かけすぎないように *)
      Client.post
        ~headers:(Header.of_list (create_headers cookie "close"))
        ~body:(Cohttp_lwt.Body.of_string x)
        (Uri.of_string target_url) >>= fun (resp, body) ->
      Printf.printf "Cookie: %s" cookie;
      Printf.printf "Body: %s\n" (Lwt_main.run (body |> Cohttp_lwt.Body.to_string));
      loop xs in
  loop load_params

施設予約状況確認クローラー実行の様子

本当は日の詳細ページまでパースして開いてる時間帯まで出したかったのですが、ひとまずここまでとします・・・。個人参加でも行っているので大会,休館日が把握できるようになったのがとてもうれしい💪

感想

初めてのOCamlでしたが、ザクザクと繋いで書けるので気持ちのよい言語でした。Scalaと同じく、オブジェクト指向的にも書けるようなので色々触ってみたいと思います。

クローラーを書いたのも久しぶりでしたが、lamdbasoupがとても優秀だったので正規表現ゴリゴリ書いてスクレイピングしていた経験を思い出すと・・・(遠い目

素敵ライブラリなので皆さんも是非使ってみてはいかがでしょう。