Common LispでかんたんWebスクレイピング

WebスクレイピングとはWebから情報を自動的に集めてくるクローラを実装するということである。これを実現するにはHTTPクライアントとHTMLパーサ、そしてパースされた木構造から必要な情報を探索、抽出するセレクタがあればいい。Common Lispにはそれぞれに複数のライブラリがあるが、今回はHTTPクライアントにDexador、HTML/XMLパーサにPlump、CSSセレクタにCLSSを使う。これらのライブラリは全てQuicklispから入る。

(ql:quickload :dexador)
(ql:quickload :plump)
(ql:quickload :clss)

例としてこのロイターの記事 堅調地合い、1万8000円へ戻りを試す展開に=来週の東京株式市場 を分析してみる。

HTTPクライアント: Dexador

まずHTTPクライアントでHTMLを取ってくる。これにはdexadorのget関数を使う。

(defparameter article-html (dex:get "http://jp.reuters.com/article/idJPL3N0U325520141219"))

dex:getは取得したHTML文字列、ステータス、メタ情報のハッシュ表、URI、ストリームを多値で返す。

"<!doctype html><html><head>
<title>
            堅調地合い、1万8000円へ戻りを試す展開に=来週の東京株式市場
|ロイター</title>|
... 中略 ...
</html>
"
200
#<HASH-TABLE :TEST EQUAL :COUNT 14 {1003F285C3}>
#<QURI.URI.URI-HTTP http://jp.reuters.com/article/idJPL3N0U325520141219>
#<SB-SYS:FD-STREAM for "socket 192.168.11.12:43208, peer: 52.222.193.218:80" {1003DD4B13}>

HTMLパーサ: Plump

次に、plumpのparse関数でHTML文字列をパースする。これは木構造のルートに相当するCLOSオブジェクトを返す。

(defparameter parse-tree (plump:parse article-html))

;; => #<PLUMP-DOM:ROOT {1006E77F53}>

このオブジェクトの子を表示してみると、

(plump:children parse-tree)

;; #(#<PLUMP-DOM:COMMENT {1005D8C563}> #<PLUMP-DOM:TEXT-NODE {1005D8C853}>
;;   #<PLUMP-DOM:COMMENT {1005D8CF53}> #<PLUMP-DOM:TEXT-NODE {1005D8D253}>
;;   #<PLUMP-DOM:COMMENT {1005D8DB73}> #<PLUMP-DOM:TEXT-NODE {1005D8DE93}>
;;   #<PLUMP-DOM:COMMENT {1005D8E4A3}> #<PLUMP-DOM:TEXT-NODE {1005D8E773}>
;;   #<PLUMP-DOM:COMMENT {1005D8ECF3}> #<PLUMP-DOM:TEXT-NODE {1005D8F053}>
;;   #<PLUMP-DOM:DOCTYPE html> #<PLUMP-DOM:ELEMENT html {1005D8FDC3}>
;;   #<PLUMP-DOM:TEXT-NODE {1006274133}>)

このうちtext-nodeオブジェクトが文字列を持っている。木構造を走査してtext-nodeの持つ文字列だけを連結する関数を定義してみるとこうなる。

(defun node-text (node)
  (let ((text-list nil))
    (plump:traverse node
                    (lambda (node) (push (plump:text node) text-list))
                    :test #'plump:text-node-p)
    (apply #'concatenate 'string (nreverse text-list))))

普通に再帰で書いても行数はあまり変わらないと思うが、せっかくtraverse関数が用意されていたので使ってみた。

CSSセレクタ: CLSS

jQueryのように木構造からCSS要素を指定して部分木を抜いてくることができる。例えば、Plumpでパースした木からarticleTextというIDを持つ最初のノードを取り出すには以下のようにする。

(defparameter sub-tree (aref (clss:select "#articleText" parse-tree) 0))

この部分木に対して先ほど定義したnode-textを使うと記事の本文が得られる。

(node-text sub-tree)

;; "
;; [東京 19日 ロイター] - 来週の東京株式市場は堅調な地合いが続く見通しだ。 (以下略
;; "

同様に記事タイトルやジャンルなども取ってこられる。

(node-text (aref (clss:select ".article-headline" parse-tree) 0))
; => "堅調地合い、1万8000円へ戻りを試す展開に=来週の東京株式市場"

(node-text (aref (clss:select ".article-section" parse-tree) 0))
; => "Markets"

まとめとか

実際のページのソースを見てみると本文の部分はdivやspanが入り乱れているので単純な文字列のパターンマッチだとめんどくさそうに思えるが、HTMLをパースして木構造とすることで一気に扱いやすくなる。

ブラウザのインスペクタでクラス/IDを調べてclss:selectで指定するだけなので簡単。

ロイターの場合、サイトマップのXMLファイルがあるので上と同様に分析してURLのリストを取り出すことができる。