ElixirでWebスクレイピングのためのライブラリを作った
RubyにはMetaInspectorというwebスクレイピングのためのgemがあります。複雑なparseに使うことは出来ませんが、基本的な情報やOpenGraphなどのmeta情報を取得する際に効果を発揮します。
MetaInspectorよろしく、webスクレイピングを手軽にできるライブラリをElixirで使いたかったので自分で作りました。
MetaInvestigatorって名前が呼びづらいので、さっそく名前を変えたくなってる
目次
- MetaInvestigatorの内部的な話
- 開発中に直面した問題
- おわりに
MetaInvestigatorの内部的な話
MetaInspector本家は、faradayを使ってHTTPリクエストを投げています。 それに倣って実装するため、ElixirにはどんなHTTPクライアントがあるのかを調べることにしました。 どうやらHTTPoisonとHTTPotionという2つのライブラリが有名なようです。
どちらか一方が抜きん出ていれば選びようもあるのですが、GitHubのstar数を見ても分かる通り、ほとんど差がありません。 考える(調べる)のが面倒になってしまったので、もういっそのこと利用者に好きなHTTPクライアントを使ってもらうことにしました。
そういった経緯から、MetaInvestigatorを使う時は以下のように、リクエストを投げる処理とparseする処理に分けて使います。 試しに、GitHubの僕のページにリクエストを投げます。
# HTTPクライアントでリクエストを投げて、レスポンスのHTMLを取得する iex(1)> html = HTTPoison.get!("https://github.com/nekova").body "<!DOCTYPE html>\n<html lang=\"en\" class=\"\">\n <head prefix=\"og: ....... " # その結果をparseする iex(2)> page = MetaInvestigator.fetch(html) %{best_image: "https://avatars1.githubusercontent.com/u/3464295?v=3&s=400", best_title: "nekova (ಠ_ಠ) · GitHub", images: ["https://avatars3.githubusercontent.com/u/3464295?v=3&s=460", "https://assets-cdn.github.com/images/spinners/octocat-spinner-128.gif"], meta: %MetaInvestigator.Meta{charset: "utf-8", keywords: nil, og_image: "https://avatars1.githubusercontent.com/u/3464295?v=3&s=400", og_title: "nekova (ಠ_ಠ)", og_type: "profile", og_url: "https://github.com/nekova"}, title: "nekova (ಠ_ಠ) · GitHub"}
MetaInvestigatorの主な機能の実装は簡単でしたが、少しばかり面倒な問題にぶつかりました。
開発中に直面した問題
例を見せるために、阿部寛さんのホームページにHTTPリクエストを投げてみましょう。
# HTTPリクエストを投げて、レスポンスのHTMLを取得する iex(1)> html = HTTPoison.get!("http://homepage3.nifty.com/abe-hiroshi/").body <<60, 104, 116, 109, 108, 62, 10, 60, 104, 101, 97 ...>>
すると、さきほどとは異なり、戻り値が<<>>
で囲われたBinaryで返ってきているのが分かるでしょうか。
これらの違いが生まれるのには2つの理由があります。
つまり、今回のようにUTF-8で宣言されていないページのhtmlはStringとして扱うことが出来ず、単なるBinaryとして扱わなくてはなりません。
MetaInvestigatorが依存しているparserは、Stringのparseを想定しています。 このままではUTF-8以外で宣言されたページをparseする度にエラーが起きてしまうので、ガードの中で引数がStringであることを確かめたいですね。
def fetch(html) when is_string(html), do: parse(html)
しかし、残念ながらElixirにis_string/1などという関数は存在しません。
この話を理解するためには知っておいて欲しいことがあります。
僕は当初、「is_stringがないなら実装すればいいじゃない」と考えていましたが、それは間違いでした。
なぜなら、Erlang VM
はガードの中で呼び出せる関数をあらかじめ制限しているからです。ガードの中で使えるのは、or
などのブール表現、is_binary
などのガード述語、==
などの比較演算子と、その他のいくつかの関数だけになります。
たとえElixirの言語上にis_string/1を実装したとしても、BEAMが許してくれない限り、その関数をガードの中で呼び出すことはできません。 ちなみに、自作の関数などをガードの中で実行すると、 cannot invoke local function/1 inside guardというエラーが起きます。
さて、解決方法です。
と言っても、ガードの中では何も出来ません。is_string/1
を呼ぶのは諦めて、関数内でString.valid?/1
を呼んでcase文で分岐して処理してください。
最初からこうすれば良いのではないか、と思うかも知れませんが、僕はどうしてもguardで書きたかったんです........。
def fetch(html) when is_binary(html) do case String.valid?(html) do true -> parse(html) false -> raise some_error end end
「HTTPクライアントに依存しないように開発を進めた」と書きましたが、Elixirという言語の特性上、エンコーディングをチェックする必要があるみたいです。こればかりは、HTTPリクエストのレスポンスを参照するのが楽なので、いずれかのHTTPクライアントに依存しなくてはならないようです。
おわりに
少し長くなってしまいましたが、Elixirのライブラリを作った話は終わりです。
Elixir Advent Calendarというものを作りました。あと11枠の空きがあるので、奮ってご参加ください