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を使って簡単に変更すると使いやすいです.
ブラウザ実行が必要な時はSeleniumを使う
dic-nico-intersection-pixivでは使うことはありませんでしたが, 備考として. HaskellにもwebdriverパッケージがあってSeleniumが使えます.
Haskellでも十分にwebスクレイピング出来る
Haskellという言語自体が比較的マイナーなこともあって, あまり日本でHaskellでのスクレイピング事例は聞くことが無いですが, Haskellでも十分にwebスクレイピングが出来ることを示しました. 私は他にもクローズドソースですがweb周りの仕事をHaskellでよく行っていて, その威力を実感しています.
と言った感じで締める予定だったんですが, 今年のアドベントカレンダーの記事として Re: ゼロから作る ADVENTAR の Slack Bot (Haskell 編) というのが出てきて, これはSeleniumの解説とかもちゃんと行ってて, この記事の存在意義がわからなくなりました.