ja ja en en ja en ja ja ja ja en ja ja ja ja ja zh ja ja ja ja en ja ja ja ja ja ja ja ja en ja en ja en ja en ja ja ja ja en ja ja ja ja en ja ja ja en zh ja ja en ja en ja ja en ja en ja ja ja pt ja en ja ja ja ja en ja en ja ja ja ja ja ja ja ja ja ja ja ja ja ja ja ja ja pl nl ja en ja ja ja ja ja nb ja ja ru ja ja ru ja ja ja ja zh en ja ja ja ja ja ja ja ja ja ja ja ja ja ja ja jbo en en ja nl nb en ko it en it it it en fr it ja ja en la fi ja it it it pt it de en ja en en it fr it en it nb it ja ja ja ja en ja ja ja ja ja ja ja ja ja
SGMLReader + XPathでスクレイピングする - Sanwa Systems Tech Blog
HTMLはXMLではありません。したがって、
のように「終了タグがない」ことが定められていたり、条件付きで省略が認められていたりします。こういった文書はXMLとしては不正なので、System.Xml.Linq.XDocumentにパースさせてもエラーになってしまいます。
そこで登場するのが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  XPathEvaluate (this XElement element, string xpath) { return ((IEnumerable )element.XPathEvaluate(xpath)).Cast (); } public static IEnumerable  XPathEvaluate (this XElement element, string xpath, IXmlNamespaceResolver resolver) { return ((IEnumerable )element.XPathEvaluate(xpath, resolver)).Cast (); } public static IEnumerable  XPathEvaluate (this XDocument document, string xpath) { return ((IEnumerable )document.XPathEvaluate(xpath)).Cast (); } public static IEnumerable  XPathEvaluate (this XDocument document, string xpath, IXmlNamespaceResolver resolver) { return ((IEnumerable )document.XPathEvaluate(xpath, resolver)).Cast (); }
ドキュメント全体から要素を検索する
System.Xml.XPath.Extensions.XPathSelectElement()またはXPathSelectElements()を使います。たとえばページ内の全てのa要素(そのうちhref属性があるもの)を取得したければこのようになります。
XDocument document = Parser.Parse(content); IEnumerable  links = document.XPathSelectElements("//a[@href]");
マッチする要素のうち先頭1件だけで良ければXPathSelectElement()(単数形)を使います。0件ならnullが戻ります。
XElement firstLink = document.XPathSelectElement("//a[@href]");
ドキュメント全体から何かを検索する
要素(XElement)以外を検索するときはXPathEvaluate()を使います。このメソッドの戻り値の型はobjectで使い勝手が悪いので、さっきの拡張メソッドを使うと綺麗に書けます。たとえばページ内の全てのa要素のhref属性(つまりXAttributeです)の値を取り出したければこうなります。
IEnumerable  linkUris = document.XPathEvaluate ("//a[@href]/@href").Select(x => x.Value);
「ページ全体からh3タグの中身(テキスト、すなわちXText)をリストアップする。ただし最初の1個は不要」ならどうでしょうか。
IEnumerable  midashi1 = document.XPathEvaluate ("/descendant::h3[position()>1]/text()").Select(x => x.Value); IEnumerable  midashi2 = document.XPathEvaluate ("//h3/text()").Skip(1).Select(x => x.Value);
どちらでも同じ結果が得られますが、XPath初心者の私にとってはLINQを使った後者の方が見やすく感じられます。なお//h3[position()>1]/text()ではダメです(各々の親要素の中で最初の1個が除外されてしまいます)。
特定の要素の下から何かを検索する
これも同じくXElementに対してXPathEvaluate()を呼びます。たとえば次のようなHTMLがあったとして、
A1   B1   B2       A2   B3
まず1個目のdiv要素は
XElement top = document.XPathSelectElement("//div[@id='top']");
で取れます。この下からspan.bを検索したければ、
// 直下から検索 IEnumerable  spanB1 = top.XPathEvaluate ("span[@class='b']"); // 子要素または孫要素から検索 IEnumerable  spanB2 = top.XPathEvaluate (".//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があったとします。
たま  3歳  黒と白     ぶち  2歳  茶色と白     しろ  8歳  白
これをChromeに読み込ませると、Chromeは「tableの直下にはtheadかtbodyしか来ない」ことを知っているため、自動的にtbodyを補ってDOMを構築します。
一方、SgmlReaderはそこまで細かい処理は行いません。したがって、要素はtable > tr > thのように並ぶことになり、tbodyがあることを前提にXPathを書くと失敗します。
SgmlReaderが補完に失敗する場合
別にこれはSgmlReaderが悪いわけではないのですが、HTMLに間違いがあるとさらに面倒なことになります。たとえばこんなHTMLを考えます。
間違えないように入力してね!
一昔前の古いサイトではありがちな間違いで、ブラウザはこの程度なら(とはいえ膨大なルールが集積された結果のはずですが)意図通り終了タグを補って解釈してくれます。
しかしSgmlReaderはこれをこのように解釈してしまい、
間違えないように入力してね!
//form/input[@type='text']というXPathを評価すると、最初の「猫の名前」1件がヒットし、他のinput要素は一見取得できていないように見えてしまいます(実際には順次入れ子になって取得できています)。これに気付かないと散々悩むことになります。
これを防ぐには、前もってHTMLを修正しておくか、XPathを工夫してロバストなクエリを書くように心がけるかしかありません。スクレイピングに付きものの悩みではあります。
一頃流行した「セマンティック・ウェブ」という言葉はあまり聞かなくなってしまいましたが、Webページになるべく正しいHTMLで正しい情報を記載しておくことはその第一歩であり、より良いインターネットに繋がる道だと考えています。その間をXPathとLINQで上手に乗り越えてゆきましょう。
*1:1.8.11と1.8.12(2016/6/27時点)
nurenezumi 2016-06-27 18:09
石器時代みたいなWebアプリ開発をしていたエンジニアがReact.jsに導かれて2016年にやってき…
はじめに 社内向けのシンプルなWebアプリケーションを作成する…
C#で他アプリケーションを操作するための基礎知識
こんにちは、大昔はVC++でWindowsプログラミングをしていたwak…
CSSで横スクロールテーブルをつくる
こんにちは、95(くごー)です。私のチーム内では最近、タスク完…
PowerShellでXMLファイルの内容を検索する
こんにちは、PowerShellが大好きなwakです。今日もPowerShellの…
GASで監視メールをSlackに流す
こんにちは、Slack依存症のwakです。 監視メールが多くて困る …
« grepコマンドとPowerShellのsls (Select-S… API Gateway + LambdaでSlackのCustom Com… »