Haskellでスクレイピング - html-conduit/xml-conduitの使い方

Haskellでウェブサイトのスクレイピングをしたくていろいろ調べていた。 HaskellのHTML/XML パッケージは乱立気味*1であるが、WebフレームワークのYesodで採用されている以下のパッケージが良さそうだ。Conduitという効率的な新しいIOベースで(このところよく知らないが、http://tanakh.jp/posts/2012-07-01-conduit-0.5.htmlとかhttp://d.hatena.ne.jp/kazu-yamamoto/20120113/1326446266とかの解説が良さそう)、また探索用の関数も一通りそろっている。

  • 読み込み
    • xml-conduit
    • html-conduit
  • 書き出し
    • blaze-html

(追記:CSSセレクタでスクレイピングの出来るdom-selectorライブラリを作った。http://hackage.haskell.org/package/dom-selector cabal でインストールできる。)

今回は読み込みにフォーカスする。HTMLをファイルから読み込んで出力するために最低限必要なコードは以下のようになる。

{-# LANGUAGE OverloadedStrings #-}

import Text.XML.Cursor
import qualified Text.HTML.DOM as H (readFile)
import qualified Data.Text as T (Text)
import qualified Data.Text.Lazy as TL (Text,concat)
import qualified Data.Text.Lazy.IO as TI (writeFile)
import Text.Blaze.Html (toHtml)
import Text.Blaze.Html.Renderer.Text (renderHtml)

test :: Int -> IO ()
test n = do
  doc <- H.readFile "input.html"
  let c = fromDocument doc    -- fromDocumentはText.XML.Cursorで定義された関数
  let cs = f n c
  putStrLn $ (show n) ++ ": " ++ (show (length cs)) ++ " elements"
  TI.writeFile ("output."++show n++".html") (render cs)

-- Axisの定義
f :: Int -> Cursor -> [Cursor] -- f :: Int -> Axisとも書ける
f 1 c = c $// element "div" -- CSSセレクタ 'div' に相当
f 2 c = c $// element "div" &/ element "li"   -- 'div > li'
f 3 c = c $// attributeIs "class" "novel" &// attributeIs "id" "natsume"
               >=> followingSibling >=> anyElement -- '.novel #natsume ~ *'
f 4 c = c $// attributeIs "class" "list" >=> attributeIs "id" "language" -- ".list#language"
f 5 c = c $// element "div" &// element "li"

render :: [Cursor] -> TL.Text
render cs = TL.concat $ map (renderHtml . toHtml . node) cs

以下のようなHTMLをinput.htmlとして用意。

<!DOCTYPE HTML>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title></title>
</head>
<body>
  <div class='info'>
    <div class='list' id='language'>
      <ul id='morning'>
        <li class='German'>Guten Morgen.</li>
        <li class='Japan'>おはようございます</li>
        <li class='USA'>Good morning.</li>
      </ul>
      <ul id='day' class='highlight'>
        <li class='German'>Guten Tag.</li>
        <li class='Japan'>こんにちは</li>
        <li class='USA'>Good afternoon.</li>
      </ul>
      <ul id='evening'>
        <li class='German'>Guten Abend.</li>
        <li class='Japan'>こんばんは</li>
        <li class='USA'>Good evening.</li>
      </ul>
    </div>
    <div class='novel'>
      <ul id='novel'>
        <li id='natsume'>夏目漱石</li>
        <li>森鴎外</li>
        <li>志賀直哉</li>
      </ul>
    </div>
  </div>
</body>
</html>

実行すると、

*Main> mapM_ test [1..5]
1: 3 elements
2: 0 elements
3: 2 elements
4: 1 elements
5: 24 elements

おおむねいい感じ。*2
output.3.html

<li>森鴎外</li><li>志賀直哉</li>

output.4.html

<div class="list" id="language"><ul id="morning"><li class="German">Guten Morgen.</li>
        <li class="Japan">おはようございます</li>
        <li class="USA">Good morning.</li>
      </ul>
      <ul class="highlight" id="day"><li class="German">Guten Tag.</li>
        <li class="Japan">こんにちは</li>
        <li class="USA">Good afternoon.</li>
      </ul>
      <ul id="evening"><li class="German">Guten Abend.</li>
        <li class="Japan">こんばんは</li>
        <li class="USA">Good evening.</li>
      </ul>
    </div>

文字列の型

Haskellでは文字列型はString, ByteString, Textの大きく分けて3つがある。

  • Stringはtype String = [Char]という定義なのでhead, tail, concatなどリスト処理関数がすべて使える。ただし大きい文字列を扱うと遅い。

パフォーマンス改善のために作られたのが以下の二つ。

  • ByteStringはその名の通り、バイナリなどにも使い、まさにバイト列。
  • TextはUnicode文字列を表現するためのもの。UTF-16の内部表現を持つ。

さらに、ByteStringとTextはそれぞれstrictとlazyの2バージョンがあり、ライブラリによってどれを取るか異なるので、異種間を繋ぐには変換が必要。(このあたりコンパイル時に型が合わないことで分かるので、慣れればただ面倒なだけなのだが、初心者には理解までの障壁高い。)同種の操作は同名の関数で定義されていることが多いので、違うパッケージからimport XXX as Yとして区別しなければならない状況が頻繁に発生する。*3

今回は出力で、Data.Text.Lazyモジュールで定義されているlazyなTextを使っている。

要素の探索(traversing)

探索にはText.XML.Cursorモジュールを使う。型がけっこう難しく、最初なかなかコンパイルが通らなかった。

Cursor型とAxis型が重要。CursorはDOMツリーとその中のノードへのポインタのセット*4。AxisはCursorを取ってCursorのリストを返す関数の型。目的の要素のCursorを得た後、欲しい値(子要素数、innerText、attribute、など)を得るという流れ。

type Cursor = Text.XML.Cursor.Generic.Cursor Text.XML.Node
type Axis = Cursor -> [Cursor]

Text.XML.Cursorモジュール内に様々なAxisが定義済みで、それを&//, >=>などの演算子(コンビネータ)で繋いで探索処理をするのが基本。

よく使うAxis

  • element :: Name -> Axis
  • attributeIs :: Name -> Text -> Axis *5
  • checkElement :: Boolean b => (Element -> b) -> Axis *6

など。

>=>はAxisとAxisをつなぐ役割。>=>は一般にはモナドのKleisli compositionというものだそうで、Control.Monadで定義されている。圏論はよく知らなくても、型を見ると意味が分かる(実装よりも型を見た方が意味が分かりやすい。これぞ型の威力。)

(>=>)       :: Monad m => (a -> m b) -> (b -> m c) -> (a -> m c)
f >=> g     = \x -> f x >>= g

ここではAxis(= Cursor -> [Cursor])を>=>でつなぐので、mは[]に相当する、よって以下の意味。

(>=>) :: (a -> [b]) -> (b -> [c]) -> (a -> [c])
(f >=> g) x = concatMap g (f x) -- リストにおいては x >>= f = concatMap f x

もしAxisに対して単に他のAxisを順次適用していくと、リストの入れ子が深くなってしまうところ、>=>を使うことでAxisの結果の入れ子をflattenする効果がある。なので、

g :: Axis
g c = c $// element "div" >=> attributeIs "id" "language" >=> attributeIs "class" "list"  -- CSSセレクタ 'div#language.list' に相当

と好きなだけAxisをつなげていくことができ、最終的な型は[Cursor]となる。

&/, &//, &.// はそれぞれ子要素(children)、子孫要素(descendants)、自分自身+子孫要素に対して右辺のAxisを適用するコンビネータ。(これらはみな右結合。)

(&/),(&//),(&.//) :: Axis node -> (Cursor node -> [a]) -> (Cursor node -> [a])
f &/ g = f >=> child >=> g
f &// g = f >=> descendant >=> g
f &.// g = f >=> orSelf descendant >=> g

左辺の結果にそのまま右辺を適用するのが>=>であり、これら3つはその拡張といえる(aをCursor nodeと置き換えれば&/, &//, &.//の型は>=>と一致する)。右辺の関数の返り値は[Cursor]でなくても良い。例えば、

f2 :: Cursor -> [T.Text]
f2 c = c $// element "ul" >=> attributeIs "id" "evening" &.// content

とすることで、要素ごとのinnerTextのリストが取得できる。

一方で、$/, $//, $.//は左辺にAxisでなく、一つのCursorをとる。(これらもみな右結合。)よって、

c $// element "div" &/ element "li"  -- CSSセレクタ 'div > li'に相当。

は正しいが

c $// element "div" $// element "li" -- element "div" は Cursor -> [Cursor] であり、$//の左辺であるべきCursorに適合しない。

は型エラーとなる。

これらのコンビネータを使うことで、リストを返す関数の逐次適用の表記が簡潔になるのだが、基本はCursorあるいは[Cursor]に対してAxis(=Cursorをとって[Cursor]を返す関数)を順次適用するというだけの話である。よくわからなくなったらmapやconcat, concatMapに立ち返ればよい。

*1:しかも別のパッケージが同じモジュール名前空間(Text.XMLなど)を使うので新参者を混乱させる

*2:test 5だけが結果がおかしい。input.htmlでdivがネストしているため、その子孫要素の li をダブってカウントしてしまう。

*3:このあたりこれらに共通の型クラスが用意されれば楽になるのか? IsStringみたいなノリで多相の相互変換が自動で出来たりすればいいのかなと。

*4:元々CursorはText.XML.Cursor.Genericモジュール内に任意のノード型を取る形式で定義されていて、それをText.XML.Cursorモジュールがtype Cursor = Text.XML.Cursor.Generic.Cursor Text.XML.Nodeとして用いることでCursorの定義を上書きしている。ややこしいが、この上書きされたCursorがAxisの引数。

*5:NameはIsStringのインスタンスで、OverloadedStringsプラグマを有効にすれば文字列リテラルから自動的に変換される

*6:BooleanはBool, List, Maybe, Eitherがインスタンスであり、空でないリストはTrue/空リストはFalse、Just aはTrue/NothingはFalse、といった具合に対応する。