SGMLReader + XPathでスクレイピングする

こんにちは、今日もWebを見ながら生きているwakです。表題通りのことをやりました。

f:id:nurenezumi:20160620161016j:plain

はじめに

目的

任意のHTMLをSystem.Xml.Linq.XDocumentに格納してXPathで目的の要素を探してスクレイピングすることです。

HTMLはXMLじゃない

HTMLはXMLではありません。したがって、

<link href="mystyle.css" rel="stylesheet" type="text/css">

のように「終了タグがない」ことが定められていたり、条件付きで省略が認められていたりします。こういった文書はXMLとしては不正なので、System.Xml.Linq.XDocumentにパースさせてもエラーになってしまいます。

SgmlReader

そこで登場するのがSgmlReaderで、これは

SgmlReader is a versatile C# .NET library written by Chris Lovett for parsing HTML/SGML files using the XmlReader API.

とあるように、XMLとしては不正なHTMLを食べてXMLにしてくれるライブラリです。

github.com

同じような目的のライブラリとしてHtml Agility Packがあり、こちらの方がずっとメジャーなようなのですが、読み込んだドキュメントが独自のクラスになることに抵抗があって今回は利用しませんでした。

SgmlReaderの使い方

導入

NuGetで提供されているので、NuGetパッケージマネージャーコンソールから

Install-Package SgmlReader

と入力すれば導入できます。開発はGitHubで進められており、こちらの方が微妙に新しい*1ので、こちらを使っても良いと思います。まず上記のリポジトリを丸ごと落としてきてビルドし、出てきたSgmlReaderDll.dllを参照に追加して終わりです(あるいはSgmlReader以下の*.csを2件直接プロジェクトに突っ込むだけでも構いません)。またSgmlReaderはApache License 2.0でライセンスされていますので、もしアプリケーションを再配布する際はその旨明記しておきます。

読み込み

文字列→XDocument

次のようなコードを書くだけでOKです。

public static XDocument Parse(string content)
{
    using (var reader = new StringReader(content))
    using (var sgmlReader = new SgmlReader { DocType = "HTML", CaseFolding = CaseFolding.ToLower, IgnoreDtd = true, InputStream = reader })
    {
        return XDocument.Load(sgmlReader);
    }
}

ファイルでもWeb上のコンテンツでもStreamのことが多いと思いますし、それならば本当はTextReaderから直接流し込めるのですが、前もってHTMLの手直しをする必要に迫られたときのために一度stringにしています。

XPathEvaluate()で型を指定する

後でSystem.Xml.XPath.Extensions.XPathEvaluate()を多用しますが、このメソッドはXPathならなんでも渡せるので(つまり戻り値はXElement, XText(), XAttributeのどれでもあり得るので)戻り値はobjectになります。戻り値の型があらかじめ分かっているときにはこんな拡張メソッドを追加しておくと簡単になります。

public static IEnumerable<T> XPathEvaluate<T>(this XElement element, string xpath)
{
    return ((IEnumerable<object>)element.XPathEvaluate(xpath)).Cast<T>();
}
public static IEnumerable<T> XPathEvaluate<T>(this XElement element, string xpath, IXmlNamespaceResolver resolver)
{
    return ((IEnumerable<object>)element.XPathEvaluate(xpath, resolver)).Cast<T>();
}
public static IEnumerable<T> XPathEvaluate<T>(this XDocument document, string xpath)
{
    return ((IEnumerable<object>)document.XPathEvaluate(xpath)).Cast<T>();
}
public static IEnumerable<T> XPathEvaluate<T>(this XDocument document, string xpath, IXmlNamespaceResolver resolver)
{
    return ((IEnumerable<object>)document.XPathEvaluate(xpath, resolver)).Cast<T>();
}

ドキュメント全体から要素を検索する

System.Xml.XPath.Extensions.XPathSelectElement()またはXPathSelectElements()を使います。たとえばページ内の全てのa要素(そのうちhref属性があるもの)を取得したければこのようになります。

XDocument document = Parser.Parse(content);
IEnumerable<XElement> links = document.XPathSelectElements("//a[@href]");

マッチする要素のうち先頭1件だけで良ければXPathSelectElement()(単数形)を使います。0件ならnullが戻ります。

XElement firstLink = document.XPathSelectElement("//a[@href]");

ドキュメント全体から何かを検索する

要素(XElement)以外を検索するときはXPathEvaluate()を使います。このメソッドの戻り値の型はobjectで使い勝手が悪いので、さっきの拡張メソッドを使うと綺麗に書けます。たとえばページ内の全てのa要素のhref属性(つまりXAttributeです)の値を取り出したければこうなります。

IEnumerable<string> linkUris = document.XPathEvaluate<XAttribute>("//a[@href]/@href").Select(x => x.Value);

「ページ全体からh3タグの中身(テキスト、すなわちXText)をリストアップする。ただし最初の1個は不要」ならどうでしょうか。

IEnumerable<string> midashi1 = document.XPathEvaluate<XText>("/descendant::h3[position()>1]/text()").Select(x => x.Value);
IEnumerable<string> midashi2 = document.XPathEvaluate<XText>("//h3/text()").Skip(1).Select(x => x.Value);

どちらでも同じ結果が得られますが、XPath初心者の私にとってはLINQを使った後者の方が見やすく感じられます。なお//h3[position()>1]/text()ではダメです(各々の親要素の中で最初の1個が除外されてしまいます)。

特定の要素の下から何かを検索する

これも同じくXElementに対してXPathEvaluate()を呼びます。たとえば次のようなHTMLがあったとして、

<div id="top">
  <span class="a">A1</span>
  <span class="b">B1</span>
  <span class="b">B2</span>
</div>
<div id="middle">
  <span class="a">A2</span>
  <span class="b">B3</span>
</div>

まず1個目のdiv要素は

XElement top = document.XPathSelectElement("//div[@id='top']");

で取れます。この下からspan.bを検索したければ、

// 直下から検索
IEnumerable<XElement> spanB1 = top.XPathEvaluate<XElement>("span[@class='b']");
// 子要素または孫要素から検索
IEnumerable<XElement> spanB2 = top.XPathEvaluate<XElement>(".//span[@class='b']");

となるわけです。「#topの子要素または孫要素のspan.b」のつもりで//span[@class='b']と書いてしまうとドキュメント全体からの検索になるので気をつけましょう。

Chromeのコンソールと注意点

Chrome$x()でお手軽XPath

XPathは複雑になってくると暗号みたいになるのでなるべくお手軽にテストができた方が便利です。Chromeでは特にアドオンなどを必要とせず、開いているドキュメントに対してコンソールから$x("任意のXPath")でXPathのテストができる機能があります。

Console Utilities API reference - Chrome Developers

$x("任意のXPath")だけでなく、$$("任意のCSSセレクタ")というショートカットもあります。活用しましょう。

Chromeが勝手に要素を補う場合

この機能はXPathで要素を検索してスクレイピングに役立てる際に非常に便利なのですが、HTMLの内容によってはハマることがあります。たとえば次のようなHTMLがあったとします。

<!-- tbodyがない -->
<table id="neko">
  <tr><th>たま</th><td>3歳</td><td>黒と白</td></tr>
  <tr><th>ぶち</th><td>2歳</td><td>茶色と白</td></tr>
  <tr><th>しろ</th><td>8歳</td><td></td></tr>
</table>

これをChromeに読み込ませると、Chromeは「tableの直下にはtheadtbodyしか来ない」ことを知っているため、自動的にtbodyを補ってDOMを構築します。

f:id:nurenezumi:20160617110248p:plain

一方、SgmlReaderはそこまで細かい処理は行いません。したがって、要素はtable > tr > thのように並ぶことになり、tbodyがあることを前提にXPathを書くと失敗します。

SgmlReaderが補完に失敗する場合

別にこれはSgmlReaderが悪いわけではないのですが、HTMLに間違いがあるとさらに面倒なことになります。たとえばこんなHTMLを考えます。

<form action="post">
  <!-- inputタグ閉じ忘れ -->
  <input type="text" placeholder="猫の名前">
  <input type="text" placeholder="年齢">
  <input type="text" placeholder="色">
  <span>間違えないように入力してね!</span>
  <input type="submit" value="送信">
</form>

一昔前の古いサイトではありがちな間違いで、ブラウザはこの程度なら(とはいえ膨大なルールが集積された結果のはずですが)意図通り終了タグを補って解釈してくれます。

f:id:nurenezumi:20160617115351p:plain

しかしSgmlReaderはこれをこのように解釈してしまい、

<form action="post">
  <input type="text" placeholder="猫の名前">
    <input type="text" placeholder="年齢">
      <input type="text" placeholder="色">
        <span>間違えないように入力してね!</span>
        <input type="submit" value="送信"></input>
      </input>
    </input>
  </input>
</form>

//form/input[@type='text']というXPathを評価すると、最初の「猫の名前」1件がヒットし、他のinput要素は一見取得できていないように見えてしまいます(実際には順次入れ子になって取得できています)。これに気付かないと散々悩むことになります。

これを防ぐには、前もってHTMLを修正しておくか、XPathを工夫してロバストなクエリを書くように心がけるかしかありません。スクレイピングに付きものの悩みではあります。

おわりに

一頃流行した「セマンティック・ウェブ」という言葉はあまり聞かなくなってしまいましたが、Webページになるべく正しいHTMLで正しい情報を記載しておくことはその第一歩であり、より良いインターネットに繋がる道だと考えています。その間をXPathLINQで上手に乗り越えてゆきましょう。

*1:1.8.11と1.8.12(2016/6/27時点)