こんにちは、今日もWebを見ながら生きているwakです。表題通りのことをやりました。
はじめに
目的
任意の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にしてくれるライブラリです。
同じような目的のライブラリとして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
の直下にはthead
かtbody
しか来ない」ことを知っているため、自動的にtbody
を補ってDOMを構築します。
一方、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>
一昔前の古いサイトではありがちな間違いで、ブラウザはこの程度なら(とはいえ膨大なルールが集積された結果のはずですが)意図通り終了タグを補って解釈してくれます。
しかし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で正しい情報を記載しておくことはその第一歩であり、より良いインターネットに繋がる道だと考えています。その間をXPathとLINQで上手に乗り越えてゆきましょう。
*1:1.8.11と1.8.12(2016/6/27時点)