作成: 2017-12-19T00:00:00+09:00

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では変換できないので text の Encoding 以下の decodeUtf16LE を使って変換しています. CP932などが来たらtext-icuパッケージを使って定番のICUを使って変換します.

ネットワーク通信にはhttp-conduitを使うのがオススメです ネットワーク通信にはhttp-conduitの Network.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-typesに urlDecode のような関数があるのでそれを使います. ByteString を受け付けるのでstring-transformを使って簡単に変更すると使いやすいです.