[WinRT] HttpClient+HtmlAgilityPack+XDocument+ExDoc で HTML データから抽出する話

Web API 華やかな昨今ですが、HTML形式からデータ抽出しないといけないパターンがあります。と言いますか、ちょっと前までは Excel VBA とか DOM を使って抽出したものですが、Wordpress のバックグランドの MySQL から直接取り出したり、そもそも Web API が用意されていたりして、それほど機会は多くないと思うのですが。まあ、ブラウザから手軽に拾えるのは良いかと。

WPF だったりすると、DOM を使って検索する方が早いのですが、ストアアプリだと DOM が取れません。わざわざストアアプリにする理由もないのですが、ダウンロードが手軽なのと、タブレットで使いやすいので敢えてストアアプリを使います。

アンケートツクレール http://enq-maker.com/ 自体が Web API を提供しているのかどうか?は別として、ブラウザから直接抽出を試みます。実は、HTML 形式から抽出する場合も、取り出しやすい HTML 形式と取り出しにくい HTML 形式があります。取り出しやすいのは、name が id が付いていたり、CSS を使ってスタイルが付いている場合ですね。id や name を頼りに検索すれば一発で目的のデータが取り出せます。
取り出しにくいのは、それらの名前がついてないパターンで、前後の文字列を見たり、場合によっては行数をみたりして検索をします。行数の決め打ちはあまり良くなくてデザインが変わってしまうと行数が変わるので非常に変更に弱い作りになってしまいます。大抵の場合は、独自のスタイルが付いてることが多いので、それを目的にして、次のタグとか親タグ、子タグを調べていけばできあがります。

データ自体が JSON や XML で取れるとそのままクラス化して抽出がしやすいのですが、HTML 形式のままだとそうはいきません。試行錯誤が必要になり、また試行錯誤の作業量に見合うデータが取れるかどうかの肝になります。ブラウザの手打ち部分を、そのまま自動化するところが目的ですね。また、見た目のブラウザしか用意されていないパターンでも、プロキシ的にライブラリを噛ませれば Web API にすることもできる、というパターンになります。

データ変換を組み合わせる

HTML 形式の適当なパーサーを書こうかなと思ったところ、HtmlAgilityPack があるのを思い出しました。半年前ぐらいに知って試してみようと思ってそのままだったのですが、先のアンケートアプリで使っています。

  1. HttpCilent クラスを利用して HTML 形式で取得
  2. HtmlAgilityPack ライブラリを利用して DOM 化
  3. XDocument を使って XML 形式に変換
  4. ExDoc を使ってワンライナーなクエリでデータ取得

という流れです。HtmlAgilityPack で XPath を指定してもよいのですが、自前の ExDoc を使いたいので XML 形式(XHTMLもどき)に変換します。

HttpClient でデータ取得

HttpClient オブジェクトを作って、GetStringAsync で全行持って来れば ok です。

var cl = new HttpClient();
var text = await cl.GetStringAsync(url);

この方式の場合は、javascript でデータ加工していた場合は取れないので、その場合は別途 WebView を使います。大抵の場合、jQuery が動いているので、下記のように body 配下を取ってきます。

string[] para = { "$('body').html();" };
string html = await web.InvokeScriptAsync("eval", para);
html = "<html><body>" + html + "</body></html>";
textHtml.Text = html;

どちらの場合も HTML 形式は文字列で取得することになるので、これを DOM にパースする必要があります。

HtmlAgilityPack で DOM を作る

HtmlAgilityPack にはいくつものパラメータがあるのですが、デフォルトの状態で大丈夫です。

var hdoc = new HtmlAgilityPack.HtmlDocument();
hdoc.LoadHtml(text);

HTMLの場合、閉じタグを必要としないタグ(brとかhrとか)があるので、これを適当に補完して貰います。はじめは正確に DOM の形式にならないと、と思っていたのですが、ある一定のアルゴリズムで同じ結果になれば良いわけで、このあたりは大ざっぱでも構いません。見た目が問題なのではなく、データとしての一貫性があればよいのです。

HTMLのDOMをXDocumentに直す

適当に作った拡張クラスで、HtmlAgilityPack の HtmlDocument を XDocument に変換します。コメントなどが「#comment」のようになっているので、これを「_comment」に直しています。後で利用しやすいようにテキスト(#text)は、最初の項目だけ要素の値にしておきます。

public static class HtmlDocumentExtenstions
{
    public static XDocument ToXDocument(this HtmlAgilityPack.HtmlDocument doc)
    {
        try
        {
            var xdoc = new XDocument();
            if (doc.DocumentNode.ChildNodes.Count == 1)
            {
                xdoc.Add(doc.DocumentNode.FirstChild.ToXElement());
            } else
            {
                foreach ( var it in doc.DocumentNode.ChildNodes) {
                    if (it.ChildNodes.Count > 0)
                    {
                        xdoc.Document.Add(it.ToXElement());
                    }
                }
            }
            return xdoc;
        }
        catch (Exception ex)
        {
            var msg = ex.Message;
            return null;
        }
    }
    public static XElement ToXElement(this HtmlAgilityPack.HtmlNode node)
    {
        if (node.Name.StartsWith("#"))
            return new XElement(node.Name.Replace("#", "_"));

        var el = new XElement(node.Name);
        foreach (var it in node.Attributes)
        {
            el.SetAttributeValue(
                it.Name, it.Value);
        }
        foreach (var it in node.ChildNodes)
        {
            if (it.Name == "#text")
            {
                el.Add(new XText(it.InnerText));
            }
            else
            {
                el.Add(it.ToXElement());
            }
        }
        return el;
    }
}

利用するときは、こんな感じで一発で変換します。

var xdoc = hdoc.ToXDocument();

XDocument を ExDocument に直す

ExDocument の Load メソッドを使うだけです。

var doc = ExDocument.Load(xdoc);

moonmile/ExDoc
https://github.com/moonmile/ExDoc

後で NuGet にもしておきます。

ExDoc で検索する

こんな風に、<td class=”qtext” … > 要素を取り出します。

var lst = doc * "td" % "class" == "qtext";

結果は List になっていて、マッチした要素をすべて取るのですが、ひとつだけと決まっているのであれば、

ExElement lst = doc * "td" % "class" == "qtext";

こんな風に、ExElement 型で受けることができます。List<ExElement> と ExElement の両方を受けることができるように暗黙のキャストを使ったトリックです。なので、型指定をするとその型に変換されるようになっていて、var で推論させるとコレクションが取れるのです。
実は、このトリックは F# と相性が悪くていちいち op_implict でキャストしないといけないのですが。まあ、C# オンリーの技ということで良しとしましょう。

もうちょっと複雑な例としては、

ExElement lpage = (doc * "div" % "id" == "left") * "img" % "class" == "image";

<div id=”left”> の中にある <img class=”image”> を取ってきます。括弧が必要なのがいまひとつなのですが、演算子 * と % を使って次々とつなげていけます。* 演算子は子孫要素を検索して、/ 演算子は子要素のみを検索対象にします(ちなみに / 演算子のほうはバグが入っているらしくうまくいかないこともあるので、* 演算子を使ったほうが無難です)。

親要素は Parent プロパティで取得ができるので、el.Parent.Parent のように親の親を示すこともできます。目的の要素を見つけたら、その親にさかのぼって周辺のデータを取得する、という方法にも使えます。
要は、かっちりした形式ではなくて、なんらかの試行錯誤が必要なときに使えるライブラリです。遅延処理にはなっていないので、いちいち List を作ってしまうのですが、HTML からパースする分にはこれで十分かなと。イミディエイトウィンドウで、ちまちま検索するのにも利用できます、ってことで。

サンプル

アンケートツクレールで作成したアンケートをストアアプリで投稿するツール
https://github.com/moonmile/NetLabEnquete

 

カテゴリー: C#, EXDoc, WinRT パーマリンク