2014年10月9日木曜日

Html Agility PackとXPath

今回はC#でHTMLパースをしてみようと思います。

C#でHTMLをパースする方法をググると真っ先に出てくるのがHtml Agility Packというライブラリです。しかし、Documentationは何も書いておらず、ググって出てくる断片的な記事しか無いので、私も1つの断片的な記事を書いてみようと思います(

さて、このライブラリ、どうもXPathというものと切っても切れない関係があるようです。
XPathというものを知らなかった私は、ググりながら、同時にHtml Agility Packを使っていかなければなりませんでした。
XPathというのは、XMLにおいて特定の場所を指し示すための言語みたいですね。
すなわち、このライブラリでは、HTMLの中の特定のタグ等をXPathを使って検索し、そこから目的の属性とか内容を取得するといった流れになっているようです。

まずは、HtmlDocumentのインスタンスの生成です。このインスタンスに対していろいろいじくり回すので、まずはこれを作らなければなりません。

HtmlDocument html = new HtmlDocument();

using(WebClient wc = new WebClient())
using(Stream s = wc.OpenRead(URL)) {
    html.Load(s);
}

このライブラリは直接WebからHTMLを取得するということはしないようなので、このように適当にWebClientを使って読みだしてWebからHTMLを取得しています。ローカルの場合は、パスを指定するだけで読み出せるLoadメソッドのオーバーロードがあるのでそれを使えば良いでしょう。

では、実際にXPathで読みだしてみます。

HtmlNode head = html.DocumentNode.SelectSingleNode(@"//head//title");
Console.WriteLine(head.InnerText);

例えば上記のHtmlDocumentクラスの生成のときのURLをhttp://www.google.co.jpにしてこのプログラムを実行すると「Google」という文字列が表示されます。DocumentNodeプロパティはHTMLの中身全体を含むHtmlNodeクラスのインスタンスです。この子ノードを見てみると例えば<html>などを含んでいるのでそのことからもわかると思います。

HtmlNodeクラスは、一つのタグで表されるノードの内容を保持するクラスです。いろいろなプロパティがありますが、主に使うのは
  • Name(このノードの名前)
  • Attributes(このノードの属性のコレクション)
  • ParentNode(親ノード)
  • ChildNodes(子ノードのコレクション)
  • InnerText(そのノードに含まれるテキスト。子ノードを含む場合はタグなどを差し引いたテキスト部分全体)
  • InnerHtml(そのノードに含まれるHTML。子ノードを含む場合はタグ等もすべて含まれたHTMLがstringで格納されている)
あたりですかね。今回のテーマにXPathというのがありますが、それを完全に無視して、ルートノードからこれらのプロパティをLINQで探索して条件に合うノードのコレクションを抽出することはできなくはないですが、LINQのメソッドチェーンがとても長くなったり、ラムダ式が大量に入ったりして可読性が著しく悪くなります。多分。

次は、XPathでの検索のしかたです。XPathで検索するのによく使うメソッドを紹介します。
HtmlNodeクラスのメソッドでSelectNodes()メソッドとSelectSingleNode()メソッドです。これの唯一の引数に、XPathの構文のテキストを与えればおkです。この2つのメソッドの使い分けは簡単で、前者は条件に合うノードのコレクションを返し、後者は条件に合うコレクションの最初のノードを返します。すなわち、イメージとしてはSelectNodes().FirstOrDefault()って感じですね。はい、ここからもわかるように、SelectSingleNodeメソッドは該当するノードが無い場合はnullを返します。では、該当するノードが無いときにSelectNodes()はどうなるのかと言うと、空のコレクションではなくnullを返します。これは個人的にクソ仕様だと思っています。すなわち、SelectNodes().FirstOrDefault()という書き方をして該当するノードが無かった場合はFirstOrDefault()がnullを返すのではなく、null.FirstOrDefault()になるためにNullReferenceExceptionが発生します。これはぜひとも改善してほしいところですね。いや、こういう仕様にした拡張メソッドでも実装して自分でそれを使ってしまうのが一番なのかもしれませんが。


さて、それでは実際にXPathの中身を見てみましょう。上記のXPathは
//head//title
となっています。 文頭の/はルートノードを表します。文中の/は、その子ノードを表します。WindowsやUNIX系とかのディレクトリ階層の表し方そっくりなので、ここは感覚的にも理解しやすいかと思います。そして、//になると、子だけではなく子孫ノード全体を表します。すなわち、子、孫、ひ孫など、いくら階層が下のノードでも条件に合致するようになるわけですね。

Googleのトップページのソースを見てみるとわかると思いますが、htmlノードの子のheadノードの下にmetaノードが何層かあって、そのあとにtitleノードがあります。丁寧にmetaノードを書くのが面倒なので、「htmlノードの子孫ノードのtitleノード」という意味で、このような構文にしてみました。


さて、今回は簡単にtitleノードを抽出するXPathでしたが、ほかにもいろいろとやりたいことは増えてくると思います。例えば、特定の子ノードを持つノードを抽出したいとか、特定の属性を持つノードを抽出したいとか、そういった話があるかと思います。
大丈夫です。そういった構文もXPathにはあります。

HtmlNode div = html.DocumentNode.SelectSingleNode(@"//div[@id=""hplogo""]");
Console.WriteLine(div.Attributes["style"].Value);

これも同じくGoogleのトップページをパースするプログラムです。これを実行すると
height:110px;width:276px;background:url(/images/srpr/logo9w.png) no-repeat
という出力が得られます。
//余談ですが、エスケープを無視するC#の文字列の@""っていうやつ、ダブルクオーテーション2個「""」で「"」を表すんですね。知りませんでした。
こちらのXPathでは鍵かっこ[]が出てきています。これが条件式です。鍵かっこ外に合致するノードのうち、鍵かっこ内の条件に合致するものが返されます。
ちなみに、鍵カッコ内の@ですが、これは属性を示す記号です。すなわち、@idでidという属性という意味ですね。[@id]でidという属性を持つノードという意味になりますが、="hplogo"でさらにその属性の値がhplogoに一致するという意味になります。何も難しくありません。
ちなみに、属性を示す@ですが、正式にはattribute::と書くことになっているようですが、この省略記法として@が使えるという立ち位置のようです。attribute::とかいちいち書いていると冗長なので、このような省略記法は積極的に使っていきたいですね。
というわけで、このXPathは、ルートノード以下のすべてのdivノードのうちidという属性を持ち、その値がhplogoのノードを検索するといった意味になります。次の行では、divの属性のうち、style属性の値を画面に表示しています。特に説明は無くても、C#erなら普通にわかる表現かと思います。

また、検索条件として、「特定の属性を持つ」だけでなく、「特定の子ノードを持つ」という条件を課したくなることも結構あると思います。それは、[child::hoge]と書くのがいわゆる省略しない記法です。省略記法は「何も書かない」なので[hoge]になります。

ちなみに、この条件式の鍵カッコは入れ子にもできますし([hoge[@id="foo"]]など)、複数並べるとandの意味になります([@id="hoge"][@style="foo"]など)。//node1[@id="hoge"]/node2みたいな書き方をして、特定の条件を満たすノードの子ノードなどを示すこともできます。

ここまでくればだいたいの条件式は書けるようになると思います。まだいろいろな条件の書き方はあるようですが、この辺の基本がわかっていればあとはググればなんとかなるでしょう。はい。

というわけで、XPathの導入をやってみました。
ちょっとしたウェブページの情報を読み出すソフトとかを作るときに使えそうな技ですね。

3 件のコメント:

  1. htmlのid属性は重複しないことになっているので、単一の要素を探すid('hplogo')の方が//div[@id="hplogo"]よりも良いです

    返信削除
  2. var ret = hoge.SelectNodes()?.FirstOrDefault();
    とすれば、SelectNodes()がnullを返した場合も例外になりません。

    返信削除
    返信
    1. 仰る通りです。しかしそれはC#6.0からの機能でまだ正式リリースされていませんので、その機能を大々的に使うのは少々時期尚早かと思います。私もC#6.0を早く触りたいという気持ちでいっぱいですが。

      また、そのnull伝搬演算子を使うのは対処療法的な話です。ここではそういった実装面の話を通り越して、設計思想として、コレクションを返すメソッド等は該当する項目が無ければnullではなくemptyを返すべきだという主張です。

      削除