諸事情で作る必要はなくなったのだけど、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 を使うのでこれにキャストをします。
このあたりのノウハウはおいおいと公開しています。