65
52

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

LivesenseAdvent Calendar 2015

Day 3

Haskell で楽しい Web スクレイピング

Last updated at Posted at 2015-12-02

Haskell で Web スクレイピングしてみたらすごく楽しかったのでご紹介します.

Scalpel

Scalpel は Haskell のスクレイピングライブラリです.

著名なパーサコンビネータである parsec に影響を受けており, 宣言的・モナディックにスクレイピングスクリプトを書くことができます.

本記事では, Scalpel でのスクレイピングの実装例を紹介しながら, モナディックな記述の裏側, Alternative, MonadPlus 型クラスを利用した解析器の仕組み を説明したいと思います.

スクレイピングしてみる (基本編)

弊社の Qiita AdventCalendar の投稿一覧をスクレイピングしてみましょう.

Livesense_Advent_Calendar_2015_-_Qiita.png

スクレイピング結果例

こんな感じの結果を得ようと思います.

Just [
  masahixixi: 競馬における脆弱性を追求してみようと思った話~準備編~ (http://qiita.com/masahixixi/items/e6d5eaa3bd6efbf926aa),
  highwide:   Treasure DataとGoogleスプレッドシートで作るお手軽KPIダッシュボード (http://qiita.com/highwide/items/9a75428e8e8bda0325db),
  na-o-ys:    Haskell で Web スクレイピング,
  ...
]

DOM 構造

カレンダーのアイテムは投稿済み/未投稿によって二通りの構造をしています.

投稿済みアイテム

<div class="adventCalendarItem">
  <div class="adventCalendarItem_author">
    {author}
  </div>
  <div class="adventCalendarItem_entry">
    <a href="{url}">
      {title}
    </a>
  </div>
</div>

未投稿アイテム

<div class="adventCalendarItem">
  <div class="adventCalendarItem_author">
    {author}
  </div>
  <div class="adventCalendarItem_comment">
    {comment}
  </div>
</div>

投稿済みアイテムからは (author, url, title) を, 未投稿アイテムからは (author, comment) を取得したいと思います.

スクレイパーの実装

まずはカレンダーのアイテムを表す型 Item を定義します.

type Author  = String
type Title   = String
type URL     = String
type Comment = String

data Item
    = SubmittedItem   Author Title URL
    | UnsubmittedItem Author Comment

DOM 構造に準じて, Item を (Author, Title, URL) を持つ SubmittedItem, または (Author, Comment) を持つ UnsubmittedItem で定義します.

続いて, スクレイパー部分を実装します.

items :: Scraper String [Item]
items = chroots ("div" @: [hasClass "adventCalendarItem"]) item

item :: Scraper String Item
item = submittedItem <|> unsubmittedItem

submittedItem :: Scraper String Item
submittedItem = do
    author <- text        $ "div" @: [hasClass "adventCalendarItem_author"]
    title  <- text        $ "div" @: [hasClass "adventCalendarItem_entry"] // "a"
    url    <- attr "href" $ "div" @: [hasClass "adventCalendarItem_entry"] // "a"
    return $ SubmittedItem author title url

unsubmittedItem :: Scraper String Item
unsubmittedItem = do
    author  <- text $ "div" @: [hasClass "adventCalendarItem_author"]
    comment <- text $ "div" @: [hasClass "adventCalendarItem_comment"]
    return $ UnsubmittedItem author comment

使っている関数の簡単な説明です.

chroots
選択したタグをルートとした DOM ツリーを子スクレイパーに渡す
<|>
2 つのスクレイパーを結合する
texts, attr
選択したタグのインナーテキスト / 属性を取り出す

itemschroots 関数によって「"adventCalendarItem" クラスを持つ DIV タグ」を全て検索し, それをルートとした DOM ツリーを子スクレイパー item に渡します.
Scraper String [Item] は 「String をスクレイピングして Item のリストを生成しますよ」 という意味です.

item では, 二つのスクレイパー submittedItemunsubmittedItem を演算子 <|> で結合しています.
演算子 A <|> B によって, 「まず A でスクレイピングし, 失敗したら B でスクレイピングする」 という機能を実現しています.

submittedItem, unsubmittedItem では, タグのインナーテキストを取り出す text 関数 と, タグの属性を取り出す attr 関数 を使って, ほしい情報を取り出しています.

Scraper strMonad のインスタンスなので, 上記のように do 構文でつなぐことができます.

動くコードの全体像はこちらです.
https://gist.github.com/na-o-ys/b491ca3939acefb51a39#file-calendar-hs

スクレイピングしてみる (発展編)

続いて, AtCoder の提出履歴をスクレイピングしてみましょう. 先ほどの例よりも複雑な処理が必要となります.

All_submissions_-_AtCoder_Beginner_Contest_031___AtCoder.png

スクレイピング結果例

  AcSubmission {
    createdTime = "2015/11/25 15:38:16 +0000",
    title = "D - 語呂合わせ",
    user = "na_o_ys ",
    language = "C++14 (Clang++ 3.4)",
    score = "100",
    sourceLength = "1400 Byte",
    status = "AC",
    execTime = "35 ms",
    memoryUsage = "932 KB"
  },
  ...,
  FailedSubmission {
    createdTime = "2015/11/25 14:36:25 +0000",
    title = "C - 数列ゲーム",
    user = "na_o_ys ",
    language = "C++14 (Clang++ 3.4)",
    score = "0",
    sourceLength = "1019 Byte",
    status = "WA"
  },
  ...

DOM 構造

カレンダーの例と同じく, Accepted / Failed で二種類の DOM 構造があります.

Accepted に存在する実行時間, メモリ使用量の TD タグが Failed には存在しません.

Accepted の場合

<tr>
  <td> {投稿日時} </td>
  <td> {問題名} </td>
  <td> {ユーザ名} </td>
  ...
  <td> {状態} </td>
  <td> {実行時間} </td>
  <td> {メモリ使用量} </td>
</tr>

Failed の場合

<tr>
  <td> {投稿日時} </td>
  <td> {問題名} </td>
  <td> {ユーザ名} </td>
  ...
  <td> {状態} </td>

スクレイパーの実装

DOM 構造に準じて型 Submission を定義します. (簡単のために AcSubmission は user, execTime (実行時間) を, FailedSubmission は user のみを持つものとします.)

data Submission
    = AcSubmission {
          user     :: String,
          execTime :: String
      }
    | FailedSubmission {
          user     :: String
      }

続いて, スクレイパー submissions, submission を定義します.

submissions :: Scraper String [Submission]
submissions = chroots "tr" submission

submission :: Scraper String Submission
submission = acSubmission <|> failedSubmission

acSubmission, failedSubmission はどのように実装すれば良いでしょうか. DOM 構造を見ると分かる通り, Accepted/Failed には TD タグの個数以外に違いがありません.

Scalpel ライブラリでは attribute の有無について条件付けを行うことはできますが, 選択結果の個数やインナーテキストの内容で条件付けを行う機能は用意されていません.

自作してみましょう!

先立って型クラス Alternative, MonadPlus を理解することで, スクレイピングの成功/失敗を自由に制御できるようになります.

Alternative / MonadPlus

データ型定義 にある通り, Scraper は型クラス Alternative, MonadPlus のインスタンスです. これらの型クラスが スクレイピングの成功/失敗<|> によるスクレイパーの結合 を表現する上で本質的な役割を担っています.

Alternative

Alternative 型クラスでは関数 empty, <|> が提供されます.

class Applicative f => Alternative f where
  empty :: f a
  (<|>) :: f a -> f a -> f a

これらは以下の制約 (laws) を満たすことが要求されます.

empty <|> x = x
x <|> empty = x

このように, 演算子 <|> は引数のうち一方が empty であれば他方を返します.

型 Scraper においては, empty をスクレイピングの失敗とみなす ことで, 演算子 A <|> B によるスクレイパーの結合「まず A でスクレイピングし, 失敗したら B でスクレイピングする」を表現しています.

参考: 9.4 Other monoidal classes: Alternative, MonadPlus, ArrowPlus | Typeclassopedia

MonadPlus

"スクレイピングの失敗" の正体が型クラス Alternative で提供される empty であることが分かりました.

実は empty は, Scraper モナドの bind チェイン (という言い方が正しいのか分からないですが) 内で引き継がれるという性質を持っています.
Scraper が属するもう一方の型クラス, MonadPlus の定義を追うことでその理解が容易になります.

MonadPlus 型クラスでは関数 mzero, mplus が提供されます. 今重要なのは mzero です.

class Monad m => MonadPlus m where
  mzero :: m a
  mplus :: m a -> m a -> m a

mzero は次の制約を満たすことが要求されます.

mzero >>= f  =  mzero
v >> mzero   =  mzero

<|> の場合とは逆に, bind の左右どちらか一方でも mzero であれば, 結果は mzero になります.

型 Scraper のインスタンス宣言では, mzero = empty定義されています.
つまりスクレイパーの実装においては, do 構文中の ある一箇所で empty が返っていれば, その他の演算内容に関わらず empty が返る ということが分かります.

再び実装

リストの要素数をチェックして, 一致していなければ失敗させる関数 nItems を定義してみます.

要素数が合っていれば引数の Scraper をそのまま返し, 違っていれば empty を返します.

nItems :: Int -> Scraper s [a] -> Scraper s [a]
nItems n scraper = do
    items <- scraper
    if length items == n
      then return items
      else empty

関数 mfilter を使うと nItems をよりシンプルに定義できます.

nItems :: Int -> Scraper s [a] -> Scraper s [a]
nItems n = mfilter ((==n) . length)

この関数 nItems を使って, スクレイパー acSubmission, failedSubmission を次のように定義できます.

acSubmission :: Scraper String Submission
acSubmission = do
    cols <- nItems 10 . texts $ "td"
    return AcSubmission { user = cols !! 2, execTime = cols !! 7 }

failedSubmission :: Scraper String Submission
failedSubmission = do
    cols <- nItems 8 . texts $ "td"
    return FailedSubmission { user = cols !! 2 }

acSubmission, failedSubmission は TD タグがそれぞれぴったり 10 個 / 8 個でなければ nItemsempty を返すため, スクレイピングに失敗します.

解説したコードの全体像: https://gist.github.com/na-o-ys/b491ca3939acefb51a39#file-atcoder-hs

まとめ

Haskell / Scalpel を使うことで, 宣言的でシンプル・モナディックなスクレイピングスクリプトを実装できることが分かったと思います.

僕は不勉強ですが, AlternativeMonadPlus を使って解析器の結合, 成功/失敗を表現するテクニックは, Haskell の代表的なパーサコンビネータである parsec でも駆使されているようです.

みなさんも Web スクレイピングに Haskell を取り入れてみては如何ですか?

65
52
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
65
52

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?