ElixirでWebスクレイピングのためのライブラリを作った

RubyにはMetaInspectorというwebスクレイピングのためのgemがあります。複雑なparseに使うことは出来ませんが、基本的な情報やOpenGraphなどのmeta情報を取得する際に効果を発揮します。

MetaInspectorよろしく、webスクレイピングを手軽にできるライブラリをElixirで使いたかったので自分で作りました。

github.com

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つの理由があります。

  1. ElixirにおけるStringの定義はエンコーディングUTF-8である
  2. リクエスト先である阿部寛さんのホームページはShift-JISでエンコードされている

つまり、今回のように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枠の空きがあるので、奮ってご参加ください

qiita.com