この記事は 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 -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= <img draggable="false" class="emoji" alt=" src="https://s.w.org/images/core/emoji/2.3/svg/1f42b.svg">[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 -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= <img draggable="false" class="emoji" alt=" src="https://s.w.org/images/core/emoji/2.3/svg/1f42b.svg">The following actions were aborted ∗ install tls 0.8.0 ∗ install x509 0.5.3The following actions failed ∗ install asn1-combinators 0.1.3No 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: loadedError: 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_lineendfunctionaugroup ocaml_formatautocmd!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 sercohttp-lwt 1.0.0 An OCaml library for HTTP clients and sercohttp-lwt-unix 1.0.0 An OCaml library for HTTP clients and serconduit 1.0.0 Network conduit libraryconduit-lwt 1.0.0 Network conduit libraryconduit-lwt-unix 1.0.2 Network conduit librarytls 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 Coreopen Lwtopen Cohttpopen Cohttp_lwt_unixopen 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 ^ "&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 = 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 |
実行
$ ./hogeok |
施設WEBサイトのスクレイピング時にハマったこと
Cookie取得
対象のサイトはTopページでset-cookieヘッダからCookieを取得するのですが、{key名=deleted}となって取得出来ないことがあったので、その場合は例外投げるようにしました。
exception Cookie_Fetch_Errorlet 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がとても優秀だったので正規表現ゴリゴリ書いてスクレイピングしていた経験を思い出すと・・・(遠い目
素敵ライブラリなので皆さんも是非使ってみてはいかがでしょう。