Clojure で HTML スクレイピングしてみる


Clojure で HTML スクレイピングしてみる

この記事は、Lisp アドベントカレンダー 2013 12/14 の記事です。

まずはじめにお断り...

まずはじめにお断りさせていただきます...

  • やりたかったことは達成できていません(時間切れ...)
  • lisp そんなに関係ありません(というか Java のありがたいライブラリに大きく依存してますし...)

というわけで内容も薄い記事になっちゃいますが、期限ぎりぎりなのでもう公開してしまいます。しばらくは引き続きやりたいことを継続することでしょう(きっと)。

やりたかったこと

スクレイピング、と書けばかなり曖昧な表現なのですが、実はやりたかったことはかなり具体的だったりします。それは、

  • Wikipedia の駅のページから、特定の情報を抜き出す

ということです。今回の「特定の」の中身ですが、いろいろ欲しい情報はあるのですがまずは「キロ程」情報をターゲットにします(他にも属性として整理したいことはいろいろあるのですが...)。「キロ程」とは、ある路線での始点からの距離、と思っておけば良いです(例えば米原駅( Wikipedia )によると、東海道本線では445.9km(東京起点)、北陸本線では0.0km(米原起点)となっています)。

整理された公開されたデータベースがあればよいのですが、どうやらなさそうなので Wikipedia に頼ってみよう、というわけです。

(それ以外にも、とある船のオンラインゲームに必要(?)な艦船の情報も集めてみたい、とかやりたいことは他にもあるので、勉強しておく価値はあると思いました)

方針

自分は Clojure がいちばん楽なのでその前提で考えた時、まずは使えそうなライブラリを探してみます。HTML をお手軽に読み込めそうな(良さげな)ものとして HTML Cleaner が良さそうです。Maven リポジトリにも公開されているので、project.clj に以下を追加します。

[net.sourceforge.htmlcleaner/htmlcleaner "2.2"]

使い方はざっくり言うと、

  1. org.htmlcleaner.HtmlCleaner のインスタンスを作る
  2. いくつかのプロパティを設定する
  3. String な HTML ソースを食わせる

という感じになります。Clojure で書くと

(defn html->node
  [cleaner html-src]
  (doto (.getProperties cleaner)
    (.setOmitComments true)        ;; HTML のコメントは無視する
    (.setPruneTags "script,style") ;; <script>, <style> タグは無視する
    (.setOmitXmlDeclaration true))
  (.clean cleaner html-src)) ;; cleaner.clean(string) でパース

(html->node (HtmlCleaner.) page-src)
;; => node オブジェクト(org.htmlcleaner.TagNode)

こんな感じです。

Clojure で扱いやすいように XML に変換

ところが、得られた nodeorg.htmlcleaner.TagNode クラスのインスタンスを返します。中身は読み込んだ HTML の構造に沿った形の tree 構造になっています。Java のオブジェクトをいちいち手繰っていくのは面倒なので、どうにかして Clojure で単純にアクセスしたいと思います。ここは一旦 XML に変換してからその XML を Clojure で読み込むことにしましょう。

(defn node->xml
  [cleaner node]
  (let [props (.getProperties cleaner)
        xml-serializer (CompactXmlSerializer. props)]
    (-> (.getAsString xml-serializer node) ;; node を XML の String に変換
        java.io.StringReader.
        org.xml.sax.InputSource.
        xml/parse)))                    ;; clojure.xml/parse で Clojure 内部表現に変換

ここまでのまとめ

エラー処理等一切気にしなければ、

(defn test01
  [url]
  (let [cleaner (HtmlCleaner.)
        page-src (slurp url)
        node (html->node cleaner page-src)
        xml (node->xml cleaner node)]
    ;; ここで xml の処理...
    xml
  ))
(def x (test01 "http://qiita.com/advent-calendar/2013/lisp"))
x
;; => {:tag :html, :attrs nil, :content [{:tag :head, :attrs nil, :content [{:tag :meta, :attrs {:charset "UTF-8"}, :content nil} ...

あとは map なりシーケンスなりの処理、なのでいけそうです。

ところが

世の中、そう甘くはありません。米原駅 のキロ程情報が表示されている付近を見てみましょう。

キロ程には、路線の情報とその路線における数値情報がセットで必要ですが、<table> でレイアウトされ単純に キロ程 というキーワード 付近 の情報だけを抜く、というのはあまり単純には書けません(それでもちょっとがんばれば書けるのですが...)。

すんませんすんません

本当はここから、

  • XML の tree 情報から、キロ程を含む情報をピックアップ
  • あとは適当にリスト操作して目的の情報をゲット!!!

というところを書きたかったのですが、時間切れしてしまいました。ここから先がほんとうは面白いのですが...また時間を見つけて挑戦してみます(もちろんツッコミも歓迎します)。

なんかしまらない感じになってしまったので、せめてものお詫びに、バナナマン日村 替え歌「ココロオドル」 東海道本線ver の URL を貼っておきます(東海道線の東京から神戸までの全駅を、「ココロオドル」の替え歌で歌っています)。

最後に全体のソースを貼っておきます。

それでは良いクリスマスを!!!

(ns html-parser.core
  (:require [clojure.xml :as xml])
  (:import [org.htmlcleaner HtmlCleaner CompactXmlSerializer]))

;; 参考 https://gist.github.com/sids/391818

(defn html->node
  [cleaner html-src]
  (doto (.getProperties cleaner)
    (.setOmitComments true)        ;; HTML のコメントは無視する
    (.setPruneTags "script,style") ;; <script>, <style> タグは無視する
    (.setOmitXmlDeclaration true))
  (.clean cleaner html-src)) ;; cleaner.clean(string) でパース

(defn node->xml
  [cleaner node]
  (let [props (.getProperties cleaner)
        xml-serializer (CompactXmlSerializer. props)]
    (-> (.getAsString xml-serializer node) ;; node を XML の String に変換
        java.io.StringReader.
        org.xml.sax.InputSource.
        xml/parse)))                    ;; clojure.xml/parse で Clojure 内部表現に変換

(defn test01
  [url]
  (let [cleaner (HtmlCleaner.)
        page-src (slurp url)
        node (str->node cleaner page-src)
        xml (node->xml cleaner node)]
    ;; ここで xml の処理...
    xml
  ))

;; (def x (test01 "http://qiita.com/advent-calendar/2013/lisp"))
;; => ...