Skip to content

Instantly share code, notes, and snippets.

@nebuta
Last active July 31, 2022 17:32
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nebuta/4376042 to your computer and use it in GitHub Desktop.
Save nebuta/4376042 to your computer and use it in GitHub Desktop.
HaskellでWebスクレイピング - Haskell Advent Calendar 2012

HaskellでWebスクレイピング

この記事はHaskell Advent Calendar 2012の26日目の記事です。Haskell Advent Calendar初参加です。コメントなどお待ちしております。

前提知識 - Haskellでの文字列処理

HaskellではString([Char]の別名)が文字列の基本型で、これはリストであるためにパターンマッチ・再帰やPreludeやData.Listにあるリスト用の関数を使って処理ができるという利点があります。ただし、実用上はパフォーマンスが低いため、TextByteStringが代わりに用いられます。Textはいわゆるユニコード文字列、ByteStringはバイト列という区別です。この使い分けに関してはちょうどAdvent Calendarの@brain_appleさんのこの記事で解説されました。StackOverflowでの質問も参考になります。

HaskellでのHTMLパーシング用ライブラリの概観

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-conduithttp-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モジュール内にある)を適用することで内容や属性を取得する。

do記法の利用

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

専用のコンビネータ(&|, &/, $|, $/など)があるのであまりdo記法を使う旨みはないかも。ただ私はこれらのコンビネータの型を理解するのに最初は苦労したので、上記のような標準的なモナドの記法のほうが分かりやすいかも。

CSSセレクタの利用

これらのコンビネータを使ってスクレイピングをしていて、やっぱり若干冗長なのは否めないし、入れ子になった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は本当にモナドだったなど)のですが、それに倣ってこのライブラリもモナドにするといいのかもしれません。もうちょっとモナドについて理解したらやってみたいと思います。

Ruby+Nokogiriとの速度の比較

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アプリを作ったりするアイデアなどもいろいろ湧いてきますね。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment