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