Haskellでスクレイピングしてみた

資格勉強が終わったので久々に呑気にプログラミングをしている。もう後は入社を待つだけみたいな状態なので、喫緊で勉強しなければいけないことも特にない。業務で使う技術は業務で学べば良いし。 ということで、分厚い技術書を写経するだけで終わっていたHaskellに挑戦している。やはり何かしら動くものを作らないと技術の習得は難しい。かといって何を作ろうかと悩んでいたが、とりあえずスクレイピングでもしてみるかと思い立った次第である。スクレイピングならHTTPライブラリとHTMLのパース用のライブラリさえあれば良いし、結果もすぐに返ってくる。成功したかどうかも分かりやすい。しかし、Haskellの壁はなかなかに高かった。

ライブラリ選定

まずはライブラリ選定である。Haskellではライブラリを探す際はHackageというサイトから調べるのが良いらしい。なかなか検索体験が良い。

https://hackage.haskell.org/packages/browse

「http」と検索すると、http-clientとhttp-conduitがどうやらそれらしい。どちらにするか迷ったが、別に複雑なリクエストを送るわけでもないのでhttp-conduitのNetWork.HTTP.Simpleを使うことにした。

次はHTMLのパースライブラリだ。「html」で調べると、html-conduitというものが出てきた。これはどうやらHTML文字列をDocumentと呼ばれる型に変換してくれるらしい。そしてそのDocumentに対していろいろとと操作するにはxml-conduitが必要らしい。ということでこの2つを入れた。

実装

一気に作業してしまったので、ここまでの思考過程は省略する。出来上がったコードがこんな感じ、変数名はテキトー。

{-# LANGUAGE OverloadedStrings #-}
module Main where

import Lib
import Network.HTTP.Simple
import Text.HTML.DOM
import Text.XML.Cursor

main :: IO()
main = do
  request <- parseRequest "http://example.com"
  res <- httpLBS (request)
  let doc = parseLBS $ getResponseBody res
  let root = fromDocument doc
  let cs = root $// element "p" &/ content
  putStrLn $ show cs

これでpタグの中身のテキストを取り出すことができた。しかし....

はっきり言ってよく分からない。 型に注目して一つ一つ処理を追っていきたい。

 request <- parseRequest "http://example.com"
 res <- httpLBS (request)
 let doc = parseLBS $ getResponseBody res

ここは別に良い。parseRequest関数でURL文字列を指定し、Request型が返ってくる。それをhttpLBS関数に引数として渡す。 httpLBS関数はRequestを引数にとってResponse ByteStringを返す。これはResponseのコンテキストにあるByteStringと解釈できる。 ちなみに同じ型シグネチャでhttpBSという関数もある。名前の通り、httpBSは通常のByteString、httpLBSはLazy ByteStringを返す。 ByteStringもLazyByteStringも型としては同じByteStringなのか、どうやって見分け付けるんだろう...。(初心者並感) 次はgetResponseBodyでResponseコンテキストからByteStringを取り出し、parseLBS関数でDocumentを取り出す。 ここまではシンプルで非常に分かりやすい。なんだこんなものかと思ったが問題はここからだ。

let root = fromDocument doc

fromDocument関数はCursorという型を返す。なんだこの型は...。 xml-conduitにちゃんと説明があるので読む。

hackage.haskell.org

要約: Cursorは単一のノード、およびそのノードの場所を示すよ。自分の親、兄弟の場所も知ってるよ。

つまりこのノードを使って目的のノードの場所を探索していけば良いらしい。となると、rootが示すノードとは何だろう。 恐らく、rootのノードを示すということは<html>になるはずだ。ちょっと見てみよう。

elementNamen :: Node -> Name
elementNamen (NodeElement e) = elementName e

main :: IO()
main = do
  request <- parseRequest "http://example.com"
  res <- httpLBS (request)
  let doc = Text.HTML.DOM.parseLBS $ getResponseBody res
  let root = fromDocument doc
  let rootNode = node root
  putStrLn $ show $ elementNamen rootNode

これで合ってるかは分からないが、Cursorの中身をかっさばいてNodeを取り出し、それに対してelementNameという関数を適用してみた。elementNamenというアホみたいな命名はご愛嬌。

結果はこちら。

Name {nameLocalName = "html", nameNamespace = Nothing, namePrefix = Nothing}

nameLocalNameがhtmlになっている。恐らくこれがタグの名前を示すということで、やはりhtmlタグのはずだ。 ここまでは理解できた。Document型からhtmlタグを示すノードを取り出したのだ。

let cs = root $// element "p" &/ content

ここが鬼門だ。ネットに転がってるコードを参考にしたのだが、処理はよく理解できていない。 パっとコードを見た感じではやってることは分かる。rootに対してpタグを探してそのテキストの中身をcontentで取ってきている。 実際にどんな処理が行われているのかを調べるために、まずはこの式を定義にそって分解してみる。

descendant root >>= (element "p" >=> child >=> content)

定義を見ると、$//と&/はどちらもinfixr 1 が指定されている。これは右結合の演算子で、優先度が同じだということだ。今回の例では&/から先に評価されるため、上のコードでは右オペランドをかっこで括ってある。 さて、上の式を順に見てみる。

descendantはAxis型(Cursor -> [Cursor])なので、そこにroot(Cursor型)を適用することでCursorのリストが返ってくる。名前の通り、このCursorのリストはrootノードの子孫ノードの集合だと分かる。 左オペランドはCursorのリスト。では、右オペランドはどうなるか。 それぞれ型シグネチャはelement "p"はAxis, childはCursor node -> [Cursor node] (これはAxisとは違うのか?), contentはCursor -> Textとなっており、これらを>=>で繋いでいる。

この演算子モナドに定義されていた。

hackage.haskell.org

(>=>) :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c

'(bs >=> cs) a' can be understood as the do expression

do b <- bs a

cs b

そういえばMonadの定義に必要な関数に>>=(bind)があった。>=>は、あれと同じ要領で順繰りに処理をつないでいくためにあるようだ。 つまり、引数に対してelement "p", child, contentの順に処理が行われていく。 型シグネチャは以下のようになっている。

:t (element "p" >=> child >=> content)
:: Cursor -> [text-1.2.4.1:Data.Text.Internal.Text]

Cursorを引数に取り、その中からpタグのノードを取得し、それらの子要素のテキストを抜き出すという処理だ。なぜpタグからではなくpタグの子要素のテキストを取ることになっているのか。それはpタグの中にテキストが入っている場合、pタグの子要素としてNodeContentというものが入っており、そこにテキスト情報が入っているからのようだ。 では、ここで右オペランドの関数の引数に渡されるCursorとは何か。これが左オペランドの[Cursor]だ。 右オペランドの関数で要求する引数の型はCursorだが、左オペランドは[Cursor]となっている。これを上手く繋ぐのが >>= 演算子だ。 これにより、リストモナドのコンテキストで関数を適用し、最終的にテキストのリストが得られるということだ。

ま、モナドの理解が足りないよね

この記事を書くのに入門Haskell本のモナドの章を復習する必要があった。Cursorを引数に取る関数になぜ[Cursor]を渡せるかよく分からなかったからだ。理屈を理解してみると、なかなか便利な機能だなあと思う。モナドそのものが何なのかはよく分かっていないが、少なくともこの仕組みを使えばモナドという特殊なコンテキストを表す一部の型について、わざわざラッパーを作らずともモナドを引数に取らない関数を適用することができる。リストをあたかもリストじゃないかのように扱う、今の私の頭では理解するのが難しいがとんでもなく強力な機能だということは分かる。なんにせよ関数型言語は一般的なオブジェクト指向や手続き型の言語とは異なるパラダイムなので、コードを数書いて慣れていくしかなさそう。頑張る。