諸事情で作る必要はなくなったのだけど、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 メソッドのチェーンで書けるようにします。
1 | 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 を作るところだけは、実験を繰り返しながらブレークスルーを目指します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | 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 を作れます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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 を使うのでこれにキャストをします。
このあたりのノウハウはおいおいと公開しています。