[C#] HTMLをLINQで扱えるようにする(前哨戦)

諸事情で作る必要はなくなったのだけど、ExDoc の延長戦上にあるし、ということでぼちぼちと。
主旨としては、

Html Agility Pack
http://htmlagilitypack.codeplex.com/

と似たようなものです。Html Agility Pack を使ったことがないので(あとで観察するけど)正しい違いはどうか分からないのですが、今作っているものは、

・内部的には XML の整形式を使う。
・子ノードも含めて、LINQ(where)を簡単に実行できる。
・ノードの更新(Update/Remove/Insert)が簡単にできる。

を目標に作成しています。使い方の想定としては、既存のホームページを HTML 形式で抜き取った後に、HTML の整形、id や class などの無駄な属性の削除、javascript や comment などの削除、がさくっとできるツールをつくための、内部機関といったところです。

■目的

HTML 形式は XML 整形式ではないので、タグの入れ子などがややこしいのですが、最終的に PHP などで扱う場合には整形式にしておくと parse が楽なのです。XML 系のツールも使いやすいですからね。なので、ExDoc を少し改造した形で、内部を XML として扱います。

XML や HTML の子孫ノードは「//h1/div」のように XPath 形式が一番楽なのです。ですが、これは C#/VB では扱いにくい。コンパイルが通らないからね。なので、これに似た形で構文を書けるようにします。最初は、自作の ExDoc 形式「doc * “h1” / “div”」を考えていたのですが、更新作業を考えるとかなり面倒なので、LINQ 方式で Where メソッドのチェーンで書けるようにします。

doc.Where( x => x.TagName == "div" && x.Id == "m1" ).Vallue = "new message";

のような感じで、「<div id=”m1″></div>」の中身を「new message」に書き換えるパターンを、ワンライナーで書けるようにします。
無駄なタグを消したり、無駄な属性を消したりすることが多いことを考えて、HTML のタグを子孫ノードから直接見つけるようにします。何処にあるかわからないけど、ひとまず id を使って見つけられるという感じですね。

■手段

基本は、LINQ の where, select を使います。ただし、ツリー構造を追って探索するのは面倒なので、where メソッドをオーバーライドして子孫ノードまで探索するようにします。大抵は、class か id を使うのでこれで十分でしょう。

通常 where メソッドの戻り値は、リストか null になるのですが、プログラムが複雑になるので null は返さないようにします。そして、単数/複数を区別するのも嫌なので、HtmlElement あるいは HtmlElementCollection を返します。このあたりの制御は、ExDoc と同じように、暗黙のキャスト(implicit)を駆使します。

■仮実装

MSTest を使って、仮実装をします。基本的なメソッド名をだけを決めてテストコードを書いて実装、というテスト起動です。
ただし、最初の HtmlDocument を作るところだけは、実験を繰り返しながらブレークスルーを目指します。

namespace TestHtmlDom
{
    [TestClass]
    public class TestHtmlLinq
    {
        [TestMethod]
        public void TestTagName()
        {
            string html = @"<body><h1>title</h1>message</body>";
            HtmlDocument doc = new HtmlDocument(html);

            // var q = doc.documentElement.Children.Where(n => n.TagName == "h1");
            var q = doc.Where(n => n.TagName == "h1");
            Assert.AreEqual(1, q.Count);
            Assert.AreEqual("title", q[0].Value);
        }

        [TestMethod]
        public void TestTagName2()
        {
            string html = @"<body><h2>title1</h2>message<h2>title2</h2></body>";
            HtmlDocument doc = new HtmlDocument(html);

            var q = doc.Where(n => n.TagName == "h2");
            Assert.AreEqual(2, q.Count);
            Assert.AreEqual("title1", q[0].Value);
            Assert.AreEqual("title2", q[1].Value);
        }

        [TestMethod]
        public void TestTagName3()
        {
            string html = @"
<body>
    <h2>title1</h2>
        <span>message</span>
    <h2>title2</h2>
        <span>message2</span>
</body>
";
            HtmlDocument doc = new HtmlDocument(html);

            var q = doc.Where(n => n.TagName == "span");
            Assert.AreEqual(2, q.Count);
            Assert.AreEqual("message", q[0].Value);
            Assert.AreEqual("message2", q[1].Value);
        }

        [TestMethod]
        public void TestUpdate1()
        {
            string html = @"
<body>
    <h2>title1</h2>
        <span id='m1'>message</span>
    <h2>title2</h2>
        <span id='m2'>message2</span>
</body>
";
            HtmlDocument doc = new HtmlDocument(html);

            doc.Where(n => n.Attrs["id"] == "m2")
                .Update(n => n.Value = "new message");

            var q = doc.Where(n => n.TagName == "span");
            Assert.AreEqual(2, q.Count);
            Assert.AreEqual("message", q[0].Value);
            Assert.AreEqual("new message", q[1].Value);
        }

        [TestMethod]
        public void TestRemove1()
        {
            string html = @"
<body>
    <h2>title1</h2>
    <span id='m1'>message</span>
    <h2>title2</h2>
    <span id='m2'>message2</span>
</body>
";
            HtmlDocument doc = new HtmlDocument(html);

            doc.Where(n => n.Attrs["id"] == "m2").Remove();

            var q = doc.Where(n => n.TagName == "span");
            Assert.AreEqual(1, q.Count);
            Assert.AreEqual("message", q[0].Value);
        }

        [TestMethod]
        public void TestRemove2()
        {
            string html = @"
<body>
    <h2>title1</h2>
    <span id='m1'>message</span>
    <h2>title2</h2>
    <span id='m2'>message2</span>
</body>
";
            HtmlDocument doc = new HtmlDocument(html);
            var el = doc.Where(n => n.TagName == "span");
            doc.Remove( el );
            Assert.AreEqual(@"<body><h2>title1</h2><h2>title2</h2></body>", doc.Html );
        }

        [TestMethod]
        public void TestInsert1()
        {
            string html = @"
<body>
    <h2>title1</h2>
    <div id='m1'></div>
    <h2>title2</h2>
    <div id='m2'></div>
</body>
";
            HtmlDocument doc = new HtmlDocument(html);

            HtmlElement target = doc.Where( n => n.Attrs["id"] == "m1" );
            var el = target.AppendChild( new HtmlElement( "p", "new message" ));

            Assert.AreEqual("<body><h2>title1</h2><div id=\"m1\"><p>new message</p></div><h2>title2</h2><div id=\"m2\"/></body>", doc.Html );
        }
    }
}

まだ、検索系の where と更新系の update/remove/append を軽く実装しただけですが、結構いい感じに動いています。
テストを実行するために、Html プロパティを実装して、Assert.AreEqual をやりやすくしています。まだ Html プロパティを ReadOnly にしていますが、後々は書き込めるようにするということで。

■最初の HTML Document をどう作るか?

HTML が整形式ではない(タグの対応が揃っていない)ので、自前でパースしないといけないのか、とも思っていたのですが、mshtml.IHTMLDocument2 を使うことで比較手軽に HTML の DOM を作れます。

public HtmlDocument LoadHtml( string html )
{
#if false
    WebBrowser br = new WebBrowser();
    br.Navigate("about:blank");
    br.Document.Write(html);
    IHTMLDocument2 doc = (IHTMLDocument2)br.Document.DomDocument;
#else
    var doc = new HTMLDocument() as IHTMLDocument2;
    doc.write(new object[] { html });
#endif
    Load(doc);
    return this;
}

最初は、WebBrowser コントロールから DomDocument を取得しようと思ったのですが、mshtml.HTMLDocument クラスを使うことで HTML 文字列を直接扱えます。ノード自体を DOM で扱う場合には、IHTMLDocument2 を使うのでこれにキャストをします。
このあたりのノウハウはおいおいと公開しています。

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