最近、仕事で他のメンバーが書いたHTMLスクレイパーみたいなコードの大幅な手直しをしているのだけど、ちょっとこれは書いておこうと思ったネタを公開しようと思う。それは.NETでHTMLを解析する、より真っ当な方法のことだ。
一言で言うなら、HtmlAgilityPackを使うより、SgmlReaderを使ったほうが良い。理由も簡潔に言うなら、HTMLはSGMLに準拠して設計された仕様だから、SGMLの流儀に従ってロジカルにマークアップを解析できるパーサーを使った方が適切に処理できるし、実際HtmlAgilityPackの解析はSgmlReaderより雑だ。
ちょっと待った。何が「雑」なんだろう? 雑というのはちょっといい加減な物言いだ。HTMLを解析するというのは、そんなに雑だったり厳密だったりするものだろうか? 厳密すぎるHTMLパーサーというのはかえって実用性が低かったりするんじゃないの?
実用性は、そうかもしれない。でもそれは正しいHTMLをまともに処理できるようになってから言うべきことだ1。HtmlAgilityPackは、正しいHTMLをまともに処理できない。とりあえずこれがSgmlReaderを選好する一番の理由だ。
SgmlReaderは、じゃあHtmlAgilityPackに比べて何が良いのか? どうまともにHTMLを処理できるのか? キーポイントはHTMLがSGMLである、という点にある。
SGMLをちゃんと解析するのは、XMLを解析するほど簡単ではない2。SGMLでは、一定の条件のもとで、閉じタグを省略することができる。閉じタグだけじゃなくて開きタグも省略できることがある(!)。各タグが省略できるかどうかは、DTDで決まる。XMLのDTDとは異なり、SGMLでは要素型宣言()で、これらの省略可否を指定できる(しかもなんと両方省略することもできる)。この特徴をなくして、代わりにDTDを完全にオプションの地位に追いやったのが、XMLの功績のひとつだ。DTDの情報が無いと、どこで開きタグや閉じタグが省略されたのかわからないから、SGMLではDTDは必須だ。
さて、この必須のDTDあるいはそれに相当する情報が無いと、HTMLを「正しく」解析することはできない。何が起こるかというと、たとえば次のようなHTMLがあるとする:
| ABC | DEF | GHI |
このHTMLのツリー構造は、まあこんな感じだろう、と(HTMLを解する)人間ならわかる:
table
tr
td
ABC
td
DEF
td
GHI
でもこの2番目のtdが何でtrの下に着くのか説明できるだろうか? HTMLのDTDがあれば、tdは閉じタグが省略可能で、tdの下にtdが来ないことも、 の内容モデルの定義からわかる。だけど、DTDの情報が無ければ、これは分かりようがない。わからなければ、とりあえずtdの下にtdをくっつける等の処理を進めることになる。
このDTDに相当する情報が、HtmlAgilityPackにはない。だから、実際にHtmlAgilityPackにHTMLを解析させてみると、tdの下にtdが来るようなツリー構造が生成されてしまうことになる。
HtmlAgilityPackのソースをざっと見た限り、DTDを解析するためのコードは見当たらないし、それに相当する文書型を実現するコードも見当たらない。文書型定義に照らして妥当なツリーを構築しないことになる。そして、これは単純な数行〜数十行バグフィックスでどうにかなるレベルの問題じゃなくて、根本的な設計の問題だ。「単純なHTMLが解析できるお手軽なパーサー」を作りたかったということなら、それはそれでアリだと思う。だけど、それでは万人向けにはならないというか、少なからぬ人が妙なところで躓くことになると思う。
HtmlAgilityPackに、かっちりしたパーサーを実装するくらいなら、SgmlReaderを使ったほうが早いと思う。幸いなことに、かつては不明ライセンスで使えなかったSgmlReaderも、Microsoftから公式にMS-PLで公開され、MindTouchによってApacheライセンスでメンテされている。
ちなみにHTMLパーサーの代替についてはこのブログポストがいろいろ書いているのだけど、SgmlReaderに関するコメントが事実に反するので(同エントリの著者が何でXHTMLに準拠していないといけないと思ったのかは謎)、とりあえずSgmlReaderが現実解として使えると思う、という自分の意見に変わりはない。(あと実は閉じタグ処理周りではSgmlReaderもたぶんバグを抱えているんじゃないかと思うことがあるのだけど、ちゃんと検証していないのでまだわからない。)
小ネタ数行で終わらせるつもりが、なんか無駄に長くなってしまった気がするので、今日はこの辺で。
コメント
himajin100000 — 09/24/2014 16:24:53
(それは)(.NETでHTMLを解析する)(より真っ当な方法のことだ。) という文章だと思うのだけれど、句点がないせいで (それは)(.NETでHTMLを解析するより)(真っ当な方法のことだ。) と自分の頭では解析されてしまう。
atsushieno — 09/24/2014 16:27:53
ぉぉ、ホントだ。直します。
himajin100000 — 09/24/2014 16:27:55
×句点→○読点
a13 — 12/29/2015 03:27:55
SgmlReaderの閉じタグ周り云々という所については、NuGet版のSgmlReader v1.8.11 に存在するバグが関係するかもしれません? このバージョンでは、Hrefプロパティで読ませる場合は良いのですが、InputStreamから読ませた場合にちゃんとHTMLとして読んでくれません。 現時点では、GitHub上の v1.8.12 で修正されていますが、まだNuGetパッケージ版が作られていないようなのですよね。 SgmlReader内のリソースを読めていない問題ないので、暫定対処としては自分でリソースをロードしてDtdプロパティを設定するとひとまずは回避が出来るようです。 htmlParser.DocType = “HTML”; var htmlDtdStream = typeof(Sgml.SgmlReader).Assembly.GetManifestResourceStream(“Html.dtd”); if (htmlDtdStream != null) { using (var dtdReader = new StreamReader(htmlDtdStream)) { htmlParser.Dtd = Sgml.SgmlDtd.Parse(null, “HTML”, dtdReader, null, htmlParser.WebProxy, null); } } htmlParser.CaseFolding = Sgml.CaseFolding.ToLower; htmlParser.InputStream = htmlReader;
atsushieno — 12/30/2015 00:32:15
なるほどなるほど、確かに自前で要素定義している箇所があると(あるいはhrefで取ってきたやつをさらに処理する場合もかな)、その問題に引っかかることがありそうな感じですね。情報ありがとうございます。