この記事はHaskell Advent Calendar 2012の26日目の記事です。Haskell Advent Calendar初参加です。コメントなどお待ちしております。
HaskellではString([Char]の別名)が文字列の基本型で、これはリストであるためにパターンマッチ・再帰やPreludeやData.Listにあるリスト用の関数を使って処理ができるという利点があります。ただし、実用上はパフォーマンスが低いため、TextやByteStringが代わりに用いられます。Textはいわゆるユニコード文字列、ByteStringはバイト列という区別です。この使い分けに関してはちょうどAdvent Calendarの@brain_appleさんのこの記事で解説されました。StackOverflowでの質問も参考になります。
Hackageを見ると、
- hxt
- tag-stream
- HaXml
- hexpat-tagsoup
- xhtml
- xmlhtml
- xml-conduit / html-conduit
などが見つかります。html-conduitがWebフレームワークYesodの作者のMichael Snoyman氏が作っていて勢いがあり、入出力にByteString, Textをちゃんと使っている、インターフェイスもシンプルでわかりやすいなど、良さげなのでこれを使います。
ちなみに、今回は触れませんが、HTML出力のためにはblaze-htmlという良い感じのコンビネータライブラリがあります。
まずhtml-conduitとhttp-conduitをcabalでインストールしてください。
sudo cabal install html-conduit http-conduit
以下が最小限のコードです。
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Text.XML.Cursor
import Text.HTML.DOM as H
import Network.HTTP.Conduit
import Control.Applicative
main :: IO ()
main = do
doc <- parseLBS <$> simpleHttp "http://ja.wikipedia.org/wiki/Haskell"
let root = fromDocument doc
let cs = root $// element "ul" &/ element "li"
putStrLn $ show (length cs) ++ " elements."
実行結果
Prelude> :l first.hs
[1 of 1] Compiling Main ( first.hs, interpreted )
Ok, modules loaded: Main.
*Main> main
138 elements.
html-conduitは読み込みの関数のみを提供していて、実際のDOM探索はベースとなるxml-conduitのText.XML.Cursorモジュール内の関数を使って行います。
- Cursor型がDOMツリーとその中での位置を保持する。
- 探索する関数の多くはAxis型を持つ。AxisはCursor -> [Cursor]の別名であり、たとえばelement関数の場合、要素名(Name型、IsStringのインスタンスなのでOverloadedStringsプラグマ指定によって文字列リテラルから自動変換される。)を与えるとAxisを返す。
- AxisをCursorに適用してやることで[Cursor]が得られ、それにさらに他のAxisを順次適用することで目的の要素を得る。そのままだとどんどんリストの階層が深くなってしまうので、&/などのコンビネータや、concatMapやdo記法などを使うことで階層を平たく保つ。
- 得られたCursorに、contentやattributeという関数(Text.XML.Cursorモジュール内にある)を適用することで内容や属性を取得する。
Axisの型はCursor->[Cursor]なので、AxisにCursorを与えてやると[Cursor]が返ってきます。複数の探索関数をチェインするのに、concatMapを使ったり、またリストがモナドであることを利用し、do記法, >>=などのモナドの標準関数を使っても良いです。以下はdo記法で探索関数を順次適用した例。
{-# LANGUAGE OverloadedStrings, QuasiQuotes #-}
module Main (main) where
import Text.XML.Cursor
import Text.HTML.DOM as H
import Network.HTTP.Conduit
import Control.Applicative
main :: IO ()
main = do
doc <- parseLBS <$> simpleHttp "http://ja.wikipedia.org/wiki/Haskell"
let root = fromDocument doc
let cs = getElems root
putStrLn $ show (length cs) ++ " elements."
getElems :: Cursor -> [Cursor]
getElems root = do
cs <- descendant root
cs2 <- element "ul" cs
cs3 <- child cs2
element "li" cs3
専用のコンビネータ(&|, &/,
これらのコンビネータを使ってスクレイピングをしていて、やっぱり若干冗長なのは否めないし、入れ子になったXPathの探索を型を合わせつつ書くのは面倒だなと感じたので、CSSセレクタの記法でDOM探索のできるdom-selectorライブラリを作りました。QuasiQuoteでCSSセレクタを埋め込むことができ、そうするとTemplate Haskellによってコンパイル時にCSSセレクタをコンパイルできます(セレクタの文法チェックが出来、実行時のセレクタ文字列のパースによるオーバーヘッドもない)。
{-# LANGUAGE OverloadedStrings, QuasiQuotes #-}
module Main (main) where
import Text.XML.Cursor
import Text.HTML.DOM as H
import qualified Data.Text.IO as TI (putStrLn)
import Text.XML.Scraping
import Text.XML.Selector.TH
import Network.HTTP.Conduit
import Control.Monad (forM_)
import Control.Applicative
main = do
doc <- parseLBS <$> simpleHttp "http://ja.wikipedia.org/wiki/Haskell"
let cs = queryT [jq| div#p-lang li |] (fromDocument doc)
forM_ cs $ \c -> do
TI.putStrLn $ innerText [c]
putStrLn $ "Haskell is very popular! " ++ (show $ length cs) ++ " languages in Wikipedia."
queryTは[jq| ... |]という形のQuasiQuoteをとってAxis型を返します。 自分で言うのも何ですが、なかなかクールだと思います。(むしろどうやってこのTH/QQを実装したかについて書いたほうが皆に有用だったかもしれません。Text.XML.Selector.THモジュールのソースを読んでもらえればわかりますが、th-liftパッケージのderiveLiftというものを使っています。)
セレクタを使った要素の削除もText.XML.Scrapingモジュールの関数で出来ます。
jQueryはモナドっぽい、というような話があちこちでされている(jQuery is a Monad(訳), jQueryは本当にモナドだったなど)のですが、それに倣ってこのライブラリもモナドにするといいのかもしれません。もうちょっとモナドについて理解したらやってみたいと思います。
html-conduitはpure Haskellなので、Cで書かれたライブラリと比較するのは若干不公平ではありますが、Rubyの標準的なスクレイピングライブラリのNokogiriと比較してみます。環境はiMac Mid 2010 (Intel Core i3 3.06 GHz, 4 GB RAM), Mountain Lion, Haskell platform 2012.4.0, Ruby 1.8.7です。
(遅延評価)
サイズの大きい単一HTMLファイルとして、HTML5の仕様書(サイズは5.5 MB)を読み込み、ul要素直下のli要素の数(1371個)をカウントします。誤差を減らすために10回繰り返しました。Haskellのコードは-O2オプション付きでGHCでコンパイルしてあります。
Haskell + html-conduit + dom-selector コードは以下。
{-# LANGUAGE OverloadedStrings, QuasiQuotes #-}
module Main (main) where
import Text.XML.Cursor
import Text.HTML.DOM as H
import qualified Data.Text.IO as TI (putStrLn)
import Text.XML.Scraping
import Text.XML.Selector.TH
import Network.HTTP.Conduit
import Control.Monad (forM_)
import Control.Applicative
main = forM_ [1..10] $ \n -> main1
main1 = do
doc <- parseLBS <$> simpleHttp "http://www.w3.org/html/wg/drafts/html/master/single-page.html"
let cs = queryT [jq| ul > li |] (fromDocument doc)
putStrLn $ show (length cs) ++ " elements."
実行結果
real 1m20.353s
user 0m17.303s
sys 0m1.567s
Ruby + Nokogiri(コード)では
real 1m6.005s
user 0m12.293s
sys 0m1.714s
HaskellではRubyの1.4倍ほどのUser時間がかかりました。実時間は1.2倍程度。pure Haskellであることを考えるとなかなか悪くないと思います。(ただ、正直なところこれくらい短い単純なプログラムだったらRubyで書いたほうが楽な気もします。特にHaskellではimportをたくさん書かなくてはならず面倒。JavaのIDEみたいに自動補完が使えれば楽なのでしょうが。なにかいい開発環境があったら教えて下さい。私はvimで手書きしています。)よく使うインポートを一括でやるアイデアについてはAdvent Calendarに丁度タイムリーにkei_qさんの記事が出ました。
Haskellでスクレイピングは簡単にできる。Yesodと組み合わせてWebアプリを作ったりするアイデアなどもいろいろ湧いてきますね。