Structural scraping with Skyscraper

日本語だと構造的スクレイピングになる?今日のお題は「 Web サイトを構造的にスクレイピングする」です。

あまり長々と書くつまりはないけど、スクレイピングをするときに単ページだけであれば非常に簡単です。ですが、特定のサイトをすべてスクレイピングしようと思うと骨が折れます。

例えばはてなブログ、特定の誰かのブログ記事のタイトル一覧がほしいとしたら、ブログのアーカイブページを表示してそこからタイトル一覧をスクレイピングして取得、さらにページが続くので次のページのリンクをそこから取得して、また同様にしてタイトル一覧をスクレイピングして…という風になると思います。またタイトル一覧があるのでそこからエントリに入り込んで、エントリの文章を全てスクレイピングで取得して…など同じことをやりたいときがありますよね( API を使うとか言わないで><)。

これらを 0 から書くのはめんどくさいですが、もし簡単に出来るとすればそれに越したことはないですよね。それを簡単に出来るようにしたものが Skyscraper です。これは現代の Web サイトであれば大凡綺麗な木構造で作られていることに注目して、特定の Web サイトを綺麗にスクレイピング出来る代物です。

はてなブログの開発者ブログの記事一覧を取得するコードは以下の通りです。

(ns ss-demo.core
  (:require [net.cgrand.enlive-html :as html]
            [skyscraper :as s]))

(def seed
  [{:page "1"
    :url "http://staff.hatenablog.com/archive"
    :processor :archive-page}])

(s/defprocessor archive-page
  :cache-template "hateblo/archive/:page"
  :process-fn (fn [res context]
                (let [titles (mapv html/text (html/select res [:a.entry-title-link]))
                      next-page (or (s/href (html/select res [:span.pager-next :a])) "")
                      page-no (last (re-find #"page=(:?\d+)" next-page))]
                  [{:titles titles}
                   (when (seq page-no)
                     {:page page-no
                      :url next-page
                      :processor :archive-page})])))

(defn main []
  (apply concat (map :titles (s/scrape seed :processed-cache false))))

嘘でしょ?っていうくらい簡素ですよね。少しずつ解説していきましょう。

seed はただのマップ要素を持つベクタです。最終的にはこのベクタにスクレイピングした結果がどんどん結合されていきます。そしてこの最初のベクタの持つマップにはスクレイピングを開始する始点となる :url とそれをスクレイピングする :processor を指定してやる必要があります(今回 :page はアーカイブページのページをめくっていくのでそれのことですね)。

s/defprocessor で始まっているところは実際にスクレイピングをする関数などを定義する部分ですね。 :process-fn が実際の関数でこれはレスポンスとコンテキストを受け取りますが、レスポンスというのは Enlive でパースされたあとの形になっている Web ページのデータです。コンテキストはそのページにきたときのデータですね。分かりにくいと思うのでもう少し踏み込んで説明するとこの関数がベクタを返しているのは見ての通りですが、ふたつのマップデータを含めています。片方はタイトルだけを含めたマップ、もう片方は seed と同じキーを持つものです。 Skyscraper はこのマップデータを元にさらに別のページをスクレイピングするかを確認していて、 :url:processor が次のページをスクレイピングするために最低限必要な情報です。次のページをスクレイピングできるときにあまりのパラメタが次の処理を行うときのコンテキストとして関数に渡されています。 :url:processor がないマップデータはそのまま結果となって返ります。

- root
  - leaf
  - node
    - leaf
    - node
      - leaf
      - node
        - leaf

今回の例はこういう感じですね。 Skyscraper の結果は最終的に leaf の集まりになるということです。 :url:processor を含むマップは node を指定するためのものです、と。

また今回は再帰的に同じ processor を呼び出していますが、勿論 entry-page などといった自分で別の processor を定義してそれを使用するようにしてもいいです。

それでレスポンスは Enlive のそれなので Enlive のセレクタを使うことで簡単に要素の取得ができます。この辺は Enlive のチュートリアルなどを参照してください。

さらにこのライブラリの良いところ(であり悪いところ)はデフォルトでキャッシュ機能があることです。ひとつはダウンロードしたものをローカルストレージへ保存するキャッシュ、もうひとつはスクレイピング結果のキャッシュ。 厄介なことに現状はスクレイピング結果のキャッシュをオフにすることしかできません。ダウンロードしたファイルの削除をしないと同じものだとみなされた場合にキャッシュされます。

ただ、開発時においてダウンロードファイルのキャッシュは嬉しいですよね。開発してテストしているときに何度もダウンロードされると萎えます。これを任意でオフにさえできれば…。まぁ ~/skyscraper-data に入っているはずなので邪魔になったらごそっと消してしまいましょう。 同じパスを何度も読みに行くけど、時間などによって表示されるものが変わるようなページをスクレイピングするものを書いて動かしたいときはプログラム側で制御する必要がありそう。それかキャッシュの生成先をユニークになるようにするか(まだライブラリも自体が WIP なのでガンガン PR 出したら良くなりそう)。

そんな感じでいい感じのスクレイピングツールが作れそうです。

参考