極力Scalaの標準機能を使って、HTMLをスクレイピングしてみようと思っていろいろ試したメモ。手探り状態なので、かなりうだうだとしています。
HTMLパーサはいろんな意味で手に負えないので、Validator.nu HTML Parserを使用。
Scalaは標準でXPathっぽくXMLを扱う機能が用意されている。ので、Webスクレイピングという用途にはわりと向いていると思う。
フルセットのXPathのような多様な指定ができるわけではないので、専用のライブラリを使った方がかゆいところに手は届く部分も多いけど、細かいライブラリの使い方を勉強しなくても、普段使ってるScalaのCollectionみたいな気分で要素を操作できるのは、なかなかに心地良い。
ScalaでXMLを扱う場合は、scala.xml配下のXML、Elem、Node、NodeSeqあたりのクラスのお世話になることが多い。
scala.xml.XMLは下記コードのような感じで、文字列やファイルをElemクラスに変換してくれる。ElemからXML文字列を出力することもできる。
// 文字列 → Elem
import scala.xml.XML
XML.loadString("<parent><child>a</child><child>b</child></parent>")
//=> scala.xml.Elem = <parent><child>a</child><child>b</child></parent>
// URL → Elem
import java.net.URL
XML.load(new URL("http://blog.mwsoft.jp/index20.rdf"))
//=> scala.xml.Elem = <rss version="2.0" xmlns:itunes="http://www.itun..."
// ファイル → Elem
import java.io.File
XML.loadFile(new File("foo.xml"))
//=> scala.xml.Elem = <parent><child>a</child><child>b</child></parent>
// おまけ : コード上にXMLを書いてもElemが取れたりする
val xml = <parent><child>a</child><child>b</child></parent>
//=> scala.xml.Elem = <parent><child>a</child><child>b</child></parent>
次に、Elem、Node、NodeSeqの3つについて。これらは名前の通り要素を扱うクラス。
Elem → Node → NodeSeqという継承関係になっているので、3つとも大枠は同じようなものとして扱える。
こいつらはSeqLikeとかTraversableも継承しているので、Listなんかを使う時のお馴染みの機能も実行可能になっている。
この3つには「\」や「\\」という関数が用意されている。「\」や「\\」は指定した要素名や属性名にマッチする要素を抽出する、スクレイピングで重宝する機能。
たとえば下記のようなXMLがあったとする。
<parent>
<child id="a">A</child>
<children>
<child id="b">B</child>
</children>
<child id="c">C</child>
</parent>
このXMLに対して「\ "child"」と指定すると、parentの直下の要素から、child要素を抽出してくれる。
「\」は直下だけを見るので、childrenの下にいるchild(B)は取れず、AとCのchildだけが取れる。
// elemは上のXMLを読み込んだElemクラスのインスタンス
elem \ "child"
//=> scala.xml.NodeSeq = NodeSeq(<child id="a">A</child>, <child id="c">C</child>)
「\\ "child"」と指定した場合は、再帰的に子要素を見に行く。ので、A〜Cすべての要素が取れる。
elem \\ "child"
//=> scala.xml.NodeSeq = NodeSeq(<child id="a">A</child>, <child id="b">B</child>, <child id="c">C</child>)
「\」や「\\」は連結して書ける。ので、childrenの下のchildのように指定したい場合は、こんな風に書く。
elem \ "children" \ "child"
//=> scala.xml.NodeSeq = NodeSeq(<child id="b">B</child>)
@を指定すると、属性の値を取ってくる。id属性の値を取りたい場合は以下のような書き方になる。
elem \\ "@id"
//=> scala.xml.NodeSeq = NodeSeq(a, b, c)
// children配下の属性を取ってみる
elem \ "children" \\ "@id"
//=> scala.xml.NodeSeq = NodeSeq(b)
すべての要素を指定する場合は、要素名とかを指定していたところに「_」を指定する。
こんな感じで、ScalaではXMLを割と気軽に処理できる。
(elem \\ "_").foreach(e => println(e.label))
//=> parent
//=> child
//=> children
//=> child
//=> child
ValidなXHTMLであればScalaの機能でさらっとパースができるわけだけど、世の中に整形式になってるサイトなんて滅多に存在しないし、HTMLだってパースしないといけない。
ので、Webページのパースは専用のライブラリに任せる。
Java製のHTMLパーサはいくつか存在している。Jericho HTML Parserあたりが有名だろうか。
今回はValidator.nu HTML Parserを使う。Firefox4のHTML5パーサとして採用されていたり、Liftの中でも利用されているなど、割と実績がある子。
The Validator.nu HTML Parser
http://about.validator.nu/htmlparser/
2011/6/17現在、最新バージョンはMavenやSBTでは落とせないらしい。1個前のバージョンなら取得可能。
Maven Repository: nu.validator.htmlparser
http://mvnrepository.com/artifact/nu.validator.htmlparser/htmlparser
ということで、さっそくValidator.nuさんを使って、XML文書をScalaのNodeクラスに変換してみる。
setNamePolicyにXmlViolationPolicy.ALLOWを設定しておくと、たいていのHTMLはなんとか解釈してくれる。設定しないと「崩れてるぞ」といって例外を起こす。
import java.io.StringReader
import scala.xml.Node
import scala.xml.parsing.NoBindingFactoryAdapter
import nu.validator.htmlparser.sax.HtmlParser
import nu.validator.htmlparser.common.XmlViolationPolicy
import org.xml.sax.InputSource
def toNode(str: String): Node = {
val hp = new HtmlParser
hp.setNamePolicy(XmlViolationPolicy.ALLOW)
val saxer = new NoBindingFactoryAdapter
hp.setContentHandler(saxer)
hp.parse(new InputSource(new StringReader(str)))
saxer.rootElem
}
これでNodeが取れる。引数は文字列で渡しているけど、InputSourceはInputStreamも引数に取れるので、直接InputStreamを渡すことの方が多いかもしれない。
試しに、はてなブックマークのトップページから、一覧に出ているエントリーのタイトルを取得してみる。
はてなブックマークのHTMLの構造を見てみると、「div class="entry-body"」要素の下の、「h3」の「a」の下にタイトルは入っているっぽい。
まずは先程の関数を使ってNodeを取る。
import scala.io.Source
val body = Source.fromURL("http://b.hatena.ne.jp/").mkString
val node = toNode(body)
普通はSourceを後で閉じるけど割愛。
次に「class="entry-body"」が指定されたdiv要素を取る。属性はText型で返ってくるので、Text(entry-body)で比較してあげる。
import scala.xml.Text
val entryBody = node \\ "div" filter (_ \ "@class" contains Text("entry-body"))
最後にentryBodyの下のh3の下のaを取る。上の行と繋げて1行で書いても良かったのだけど、折り返すと見づらくなるので分割した。
val titles = entryBody \ "h3" \ "a"
これでtitlesの中にはエントリーの一覧が入る。もちろん普通はRSS使う。
見た感じ、attributeの判定が少し面倒。そこをXPath使っていい感じにやりたければ、Javaのライブラリを使う必要がある。
ライブラリを使わなくても、適当にスクレイピング向けの関数を2〜3作ってimplicit conversionで後付けしても十分な気もする。この辺は後述。
ElemとNodeとNodeSeqはSeqLikeとTraversableを継承しているので、Listを使うときにお馴染みの機能も利用できるようになっている。
たとえばdropとかdropRightで、要素をSeqから落とすことができる。ので、table配下のtrを取ってきた時に、一番上のヘッダとか一番下のフッタを落とす表記が割と綺麗に書ける。
val table = <table>
<tr><td>へっだ</td></tr>
<tr><td>1</td></tr>
<tr><td>2</td></tr>
<tr><td>ふった</td></tr>
</table>
// tr要素たちを取得して、一番前と後ろを1個ずつ落とす
(table \ "tr") drop 1 dropRight 1
//=> scala.xml.NodeSeq = NodeSeq(<tr><td>1</td></tr>, <tr><td>2</td></tr>)
あとは要素の数をカウントするのが楽だったり。
// td要素の数
(table \\ "td").size
//=> Int = 4
最初とか最後の要素を取得したり。
// 最初のtd要素を取得する
table \\ "td" head
//=> scala.xml.Node = <td>へっだ</td>
// 最後のtd要素を取得する
table \\ "td" last
//=> scala.xml.Node = <td>ふった</td>
map関数でListを作ったり。
table \\ "tr" map( node => node.text )
//=> Seq[String] = List(へっだ, 1ぎょうめ, 2ぎょうめ, ふった)
2つのテーブルをUnionしてみたり。
(table \\ "td") union (table \\ "td")
と、まぁ、なかなかに自由に振る舞える。
実例が少ないので、もう少しいろいろやってみる。たとえばこんなHTML(の一部)があったとする。
<div>
<div>
<big><a>項目1</a></big><br />
<span>foo</span>
<big><a>項目2</a></big><br />
<span>bar</span>
<big><a>項目3</a></big><br />
<span>baz</span>
</div>
</div>
これを、Map(項目1→foo, 項目2→bar, 項目3→baz)のように変換したい場合はどう書くと良いだろう。
とりあえずdiv配下を取ってみる。こういう時はchild関数を使っても取れるけど、childは空白とかをPCDataとして取ってきたりするのでイマイチ。尚、PCDataやTextはisAtomがtrueなので簡単にフィルタリングはできる。
val children = (xml \ "div" \ "_")
children map (_.label)
//=> Seq[String] = List(big, br, span, big, br, span, big, br, span)
big, br, spanの順で3つ取れている。このくらい規則正しいHTMLだったら、for文で3ステップずつ進みながらyieldすれば楽な気がする。
( for( i <- 0 to (children.size - 1, 3) )
yield children(i).text -> children(i + 2).text ).toMap
//=> Map[String,String] = Map(項目1 -> foo, 項目2 -> bar, 項目3 -> baz)
でも、世の中こんなに素直なHTMLは稀なので、ちゃんと要素名を見たりしてチェックしながら入れていった方が良いのだろうな。
Scalaにはせっかく要素に対してもパターンマッチが使えるので、そいつを使ってみるとこんな感じとか。
val buf = scala.collection.mutable.ListBuffer[(String, String)]()
val node = (html \ "div" \ "_").iterator
def setItem(big: String = null): Unit = if (node.hasNext) node.next match {
case <big>{ n }</big> => setItem(n.text)
case <span>{ n }</span> => { if (big != null) buf += big -> n.text; setItem() }
case _ => setItem(big)
}
setItem()
buf.toMap
//=> Map(項目1 -> foo, 項目2 -> bar, 項目3 -> baz)
なんかもっといい書き方があるような気もする。うーん。
まぁ、HTMLのパースはどのみち表記揺れに悩まされていろいろゴニョゴニョすることになるので、このくらいで良いか。
簡単にattributeのフィルタがかけられるように、attrFilter(attrName, value)という関数で、「指定属性名=値」でフィルタをかけられるようにしてみる。
ついでに\\@(nodeName, attrName, value)という属性フィルタ付きの\\も実装してみる。
package sample.xmlhelper
import scala.xml.NodeSeq
object XmlFilter {
implicit def nodeSeqToMyXmlFilter(nodeSeq: NodeSeq): XmlFilter =
new XmlFilter(nodeSeq)
}
class XmlFilter(that: NodeSeq) {
import XmlFilter._
/** 属性用のFilter */
def attrFilter(name: String, value: String): NodeSeq = {
that filter (_ \ ("@" + name) exists (_.text == value))
}
/** attrFilter付き\\ */
def \\@(nodeName: String, attrName: String, value: String): NodeSeq = {
that \\ nodeName attrFilter (attrName, value)
}
/** attrFilter付き\ */
def \@(nodeName: String, attrName: String, value: String): NodeSeq = {
that \ nodeName attrFilter (attrName, value)
}
}
試しに使ってみる。
// implicit conversion使ってるのでまずimportする
import sample.xmlhelper._
// こんなXMLがあったとさ
val xml = <foo><bar id="a">A</bar><bar id="b">B</bar></foo>
// 要素名がbarでid要素の値がaの要素を抽出する
xml \\@ ("bar", "id", "a")
//=> > <bar id="a">A</bar>
まぁ、それなりに書きやすくはある。「\\ "bar[@id=b]"」とか書きたいという欲求はあるけど。
試しに手書きでclassFilter(HTMLのclass指定用のフィルタ)とか、regexFilter(正規表現で判定するフィルタ)とか作ってみたら、少ないコード量の割には便利と便利に使えた。
JavaのSDKにはXPathを扱う機能も入っている。その辺りを使えば特にライブラリとかは使わずにこんな風に書ける。
import java.io.{ IOException, ByteArrayInputStream }
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathFactory
val xmlStr = "<parent><child id=\"A\">A</child><child id=\"B\">B</child></parent>"
val builder = DocumentBuilderFactory.newInstance.newDocumentBuilder
val doc = builder.parse(new ByteArrayInputStream(xmlStr.getBytes))
val xPath = XPathFactory.newInstance.newXPath
xPath.evaluate("//parent/child[@id='A']", doc)
//=> A
けど、scalaのElemとJavaのElementは構造からして違うものなので、上の記述とScalaのXMLを連携させるとパフォーマンス的にあまり現実的じゃないことになりそう。
かといってJavaの機能そのままでガリガリ書くくらいなら、ScalaのXML使った方が楽。
それならJavaのXPathImplとかを参考にScalaでXPathを実装しちゃえYO!みたいな気持ちになるかもしれないけど、XPathの実装なんてどんだけ面倒だか分からない。com.sun.org.apache.xpathを読んだだけでお腹いっぱい。
スクレイピング目的なら、jQueryっぽい方向で攻めるライブラリを考えた方が良いかもと思ったりもした。そっちの方が実装は楽そうだし、知名度高いし。