• 作成:

Haskellによるwebスクレイピングの方法をdic-nico-intersection-pixivを例に書く

Webスクレイピング Advent Calendar 2017 - Adventarの19日目の記事です.

この記事では実際のwebスクレイピングの成果である, ncaq/dic-nico-intersection-pixiv: ニコニコ大百科とピクシブ百科事典の共通部分の辞書で書いた時の思考のログから, Haskellでwebスクレイピングを行う時の知見を抽出していきます.

コアのソースコードは dic-nico-intersection-pixiv/Main.hs at master · ncaq/dic-nico-intersection-pixiv の1ファイルにまとまっています.

なぜHaskell?

webスクレイピングに絡んだ特別な理由はないです. 単に私の1番得意な言語がHaskellだからというだけの選択ですね.

map, filter, foldのような高階関数が強く, 簡潔にデータ処理が出来て, それでいて型によるコードチェックが入るという優位点はあります. しかし, webスクレイピング限定ではないですね.

dic-nico-intersection-pixivとは

既存のニコニコ大百科IME辞書から, ピクシブ百科事典にもある単語のみを抽出して精度を高めた辞書です.

AIとかディープラーニングとかWord2Vecと言った高度な手法は使っていません.

自分で使ってそこそこ便利に使えています.

生成日付記録

このような定期的に更新するデータはクロールした時間を記録しておくと便利です.

読者の皆様はUTC時間で活動していますか? 私はJST時間で活動しているので, getZonedTimeを使います.

これで取得した時間をformatTimeで文字列型に変換できるので, それを記録しておきます.

unixのdateコマンドではdate +"%Y-%m-%dT%H:%M:%S%:z"と書くことで, コロンで区切る形式の日付指定はが出来ます. しかし残念ながら, timeパッケージのformatTimeはタイムゾーンをコロンで区切って出力することが出来ません. これではISO 8601に準拠できない… と書こうとしたのですが, 今定義を確認したら±hh:mmではなくて±hhmmでも良いらしいですね. コロンで区切られていないとダメだと思っていました.

私の勘違いはともかく, 生成日付は以下のようにしてISO 8601準拠の文字列で取得できます.

time <- formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%S%z" <$> getZonedTime

文字列型の変換にはstring-transformを使う

webスクレイピングだけの話では無いですが, Haskellでは主にString, ByteString, ByteString.Lazy, Text, Text.Lazyと言った文字列型が使われています. 他にもShortByteStringなどがありますがとりあえず置いておきます. Haskellでプログラミングをする時, 特にwebに関連する作業をする場合は, このたくさんの文字列型を組み合わせる必要があり, 混乱は必至です.

そこで私が作ったstring-transformを使えばコードが簡単になり, 読みやすくなります.

このライブラリは以下の関数を提供しており, どの型に変換してあるのか即座にわかるようになっています.

  • toString
  • toByteStringStrict
  • toByteStringLazy
  • toTextStrict
  • toTextLazy

型クラスの高度な機能をあえて使わないことで型をわかりやすくしています.

今日Haskell の (文字列) 変換パッケージ (convertible, convert, conversion) - Qiita という記事を見て他の文字列変換をするパッケージを知りました. これらのパッケージはconvertを統一して使って場合型指定をします. HaskellのLazy版の文字列モジュールが違うだけで型名は同じなので, convert :: ByteStringと書いてあってもStrictなのかLazyなのか即座に分からないので混乱すると思いました. なのであえて関数を単純にわけることで即座に何に変換しているのかわかるようにしています.

実はdic-nico-intersection-pixivを書いたときにはstring-transformを作っていなかったので, 頑張ってT.packなどと書いていたのですが, このパッケージを適用することでコードの見通しが多少良くなりました. 次からは最初から使います.

UTF-16の変換にはData.Text.Encoding.decodeUtf16LEを使う

string-transform は1つ設計上のトレードオフがあります.

それはByteStringの変換で, string-transformはByteStringをUTF-8だと決め打っています. 本来ByteStringはただのバイト列なので, UTF-8だろうがUTF-16だろうがCP932だろうが突っ込めます. しかし実際ByteStringはUTF-8であることが圧倒的に多かったので決め打って変換できるようにしています.

実際ニコニコ大百科IME辞書のデータはUTF-16だったので, これはstring-transformでは変換できないので textEncoding以下のdecodeUtf16LEを使って変換しています.

CP932などが来たらtext-icuパッケージを使って定番のICUを使って変換します.

ネットワーク通信にはhttp-conduitを使うのがオススメです

ネットワーク通信にはhttp-conduitNetwork.HTTP.SimpleモジュールのhttpLBSを使うとよいです. Strict向けには2.2.4のhttpBSを使えます.

Simpleと書いてるだけあってgetResponseBodyでシンプルに情報を取得できますし, conduitパッケージと言うだけあって細かく帯域を制御したくなった時も拡張性があります.

zip-archiveを使ってパターンマッチでzipファイルの内部を取り出せる

zip-archiveを使うと

    Archive{zEntries = [_, msimeEntry@Entry{eRelativePath = "nicoime_msime.txt"}]} <-
        toArchive . getResponseBody <$> httpLBS "http://tkido.com/data/nicoime.zip"

のようにパターンマッチを使って一時ファイルとか一時変数とか使わずにサクッとパターンマッチでファイル内容を取り出せるんですよ. このパッケージすごくないですか?

unicode-transformsを使えばICUに依存せずにノーマライズ出来る

スクレイピングする上で表記揺れは面倒ですよね. unicode-transforms を使えばnormalize NFKCのように文字列ノーマライズが出来ます.

もちろん先程述べたtext-icuを使っても出来ますが, ICUは巨大なライブラリですし動的リンクのバージョン問題もあるのであまり依存したいものではありません. 機能は少ないですがノーマライズするだけならunicode-transformsを使えば十分です.

XMLの解析にはxml-conduitをdom-selectorを介して使う

xmlの解析にはxml-conduitが便利です. ただこれCSSセレクタ対応してなくてコンビネータを作るのが面倒なので, Stackage外ですがdom-selectorを使うのがオススメです. Template HaskellでCSSセレクタからコンビネータを生成してくれます.

mapMを使えばそんなに負荷がかかる心配はない

今回の件では高度なスクレイピングライブラリなどは使っていないのので, 並列に取得などは行いません. モナド則によって1つのIO処理が終了するまで次のIO処理が行われることはないので常にコネクションは1つまでに保たれるはずなので, そこまで相手側サーバに負荷をかける心配する必要はないでしょう.

いやまあ, シングルスレッドのプログラムなのでこれは当たり前のことなのですが, 他の言語でmapMのような文法でコードを書くと意図せずに並列に処理されることがある時もあるので, 一応書いておきます.

URLエンコードを取り扱うにはhttp-typesを使う

http-typesurlDecodeのような関数があるのでそれを使います. ByteStringを受け付けるのでstring-transformを使って簡単に変更すると使いやすいです.

ブラウザ実行が必要な時はSeleniumを使う

dic-nico-intersection-pixivでは使うことはありませんでしたが, 備考として. HaskellにもwebdriverパッケージがあってSeleniumが使えます.

Haskellでも十分にwebスクレイピング出来る

Haskellという言語自体が比較的マイナーなこともあって, あまり日本でHaskellでのスクレイピング事例は聞くことが無いですが, Haskellでも十分にwebスクレイピングが出来ることを示しました. 私は他にもクローズドソースですがweb周りの仕事をHaskellでよく行っていて, その威力を実感しています.

と言った感じで締める予定だったんですが, 今年のアドベントカレンダーの記事として Re: ゼロから作る ADVENTAR の Slack Bot (Haskell 編) というのが出てきて, これはSeleniumの解説とかもちゃんと行ってて, この記事の存在意義がわからなくなりました.