WSH で HTML を XPath したいんじゃあああぁぁ

CompleteX で文脈依存のヘルプを表示するために、各種ライブラリ (たとえば 田楽 DLL) のドキュメントを INI ファイル形式に変換したい。ただし、できるだけロバストな記述で*1。具体的には

  • 素の Windows + IE 環境で (不特定多数の一般ユーザーのマシンで*2 )
  • 必ずしも well-formed でない HTML 文書を対象として
  • XPath を使って内容をスクレイピングしたい

という、一見ありがちな要求。なんだけど……これが全く一筋縄では行かないどころか五筋縄以上かいくぐる羽目になりましたことよ。

結論

現在のところ Windows + IE だけでは不可能。サードパーティXPath 実装を使えば可能。

0 筋縄: 方針の確認

まず、対象が純粋な XML なら簡単にできることを確認。

var dom = WScript.CreateObject("MSXML2.DOMDocument");
dom.async = false;
dom.load("http://d.hatena.ne.jp/mobitan/rss");
var nodelist = dom.documentElement.selectNodes("/rdf:RDF/item/title");
var str = "";
for (i = 0; i < nodelist.length; i++) {
	str += nodelist[i].text + "\n";
}
WScript.Echo(str);

このブログのタイトルが列挙されたダイアログが表示されれば OK だ。dom.async = false; をしないと非同期で処理が進んじゃうのが罠と言えば罠。あと、load する前に validateOnParse = false; と resolveExternals = false; をしておくと速くなるらしい*3

1 筋縄: HTML を XHTML に変換する

MSXML2.DOMDocument オブジェクトに HTML ファイルをそのまま load させると documentElement が undefined になってしまう。MSXML はその名の通り XML しか扱えないらしい。ならば HTML を XHTML に変換してやろう。
探してみると 2 つの JavaScript 製プログラムが見つかった。今回は前者を WSH で動くように少し改変して使わせてもらう*4。後者は不明な要素 (nobr など) が現れると文書構造が崩れてしまうので今回の目的には使えなかった。

ダウンロードした h2x.js は UTF-8 (LF) で書かれていた。これをそのまま WSH で実行するとコンパイルエラーになる。最小コードは次のとおり。

function h2x(html, doctype) {
  // doctype選択
}

このコードで「'}' がありません」と言われる。お前は何を言ってるんだ (AA 略
試行錯誤の結果、どうやら行末の「択」(UTF-8 で E6 8A 9E) によって次の文字 (この場合 0A) がエスケープされて、次行がコメント扱いになっちゃうみたい。9E がエスケープ文字とは思えないんだけど…? また、UTF-8 (CRLF) に変換しても別の日本語のところでコンパイルエラーが出た。とにかく WSH で実行するスクリプトShift_JIS (CRLF) にするのが無難なようだ。

<!-- wsf -->
<job>
<script type="text/javascript" src="h2x.js"></script>
<script type="text/javascript">
var fso  = WScript.CreateObject("Scripting.FileSystemObject");
var f = fso.OpenTextFile("hoge.html");
var content = f.ReadAll();
f.Close();
content = h2x(content, "XHTML 1.0 Transitional");
var f = fso.OpenTextFile("hogex.html", 2);
f.Write(content);
f.Close();
</script>
</job>

ここまでで、必ずしも well-formed でない HTML を強引に XHTML 化したファイルができた。

2 筋縄: XHTMLMSXML に食わせる

XHTML は立派な XML だから MSXML にも食えるはず。

var dom = WScript.CreateObject("MSXML2.DOMDocument");
dom.async = false;
dom.load("hogex.html");
var nodelist = dom.documentElement.selectNodes("/");

ところが、「'dom.documentElement' は Null またはオブジェクトではありません」と言われてしまった。typeof dom.documentElement は "object" を返すんだけど…?
これ以上はどうやって追求すればいいのかわからない。ただ、仮にここが突破できたとしても、XSXML で XHTML に対して XPath を使うには Namespace Resolver を自前で実装しなきゃダメとかで面倒らしい*5。ここは一旦保留にして別の道を探ろう。

3 筋縄: HTML を HTML のまま扱う

HTML を食うのは本来、MSXML じゃなくて MSHTML の仕事らしい。じゃあ MSHTML に食わせてみよう。

var dom = WScript.CreateObject("MSHTML.HTMLDocument");

ダメだ、「MSHTML.HTMLDocument という名前のオートメーションクラスが見つかりませんでした」と言われた。どうやら、ふつうは IE を起動してから HTMLDocument をたぐり寄せるものらしい*6

var ie = WScript.CreateObject("InternetExplorer.Application");
ie.Visible = true;
ie.Navigate("file:///E:/Temp/hoge.html");
while (ie.Busy || ie.ReadyState != 4) WScript.Sleep(100);
var document = ie.Document;

ダメだ、「起動されたオブジェクトはクライアントから切断されました」と言われた。どうやら IE の保護モードがらみの問題らしい*7。そもそもスクレイピングのためだけに IE のプロセスを起動するってのはあまりにもイケてない。さらに調べたところ、インプロセスで MSHTML を使うにはこんな方法があるようだ*8 *9

var f = fso.OpenTextFile("hoge.html");
content = f.ReadAll();
f.Close();

var document = WScript.CreateObject("htmlfile");
document.write(content);

var nodes = document.getElementsByTagName("h3");
var str = "";
for (var i = 0; i < nodes.length; i++) {
	str += nodes[i].innerText + "\n";
}
WScript.Echo(str);

やった―! ついに HTML の DOM にアクセスできたぞー。

4 筋縄: DOM に外部スクリプトを注入する

ここまで来ればあと一歩、getElementsByTagName() の代わりに XPath で要素を選択できれば目標達成だ。Firefox などでは document.evaluate() でできるらしいが、IE ではできないらしい*10。それを IE でも可能にするサードパーティJavaScriptXPath 実装があったので、今回はこれを使わせてもらう。

このスクリプトは解析対象ページに初めから埋め込んでおくことが想定されている。WSH で外部から操作するには、ブックマークレットと同じ方法で対象ページに後からこのスクリプトを注入してやればよさそうだ。

var f = fso.OpenTextFile("hoge.html");
content = f.ReadAll();
f.Close();

var document = WScript.CreateObject("htmlfile");
document.write(content);

var scr = document.createElement("script");
scr.src = "file:///E:/Temp/javascript-xpath-latest.js";
scr.type = "text/javascript";
document.getElementsByTagName("head")[0].appendChild(scr);

var result = document.evaluate('//h3', document, null, 7, null);
var str = "";
for (var i = 0; i < result.snapshotLength; i++){
	str += result.snapshotItem(i).innerHTML + "\n";
}
WScript.Echo(str);

でーきーたー!!! XPath 表現「//h3」を使って HTML 文書内のすべての h3 要素を列挙できたぞー!! ちなみに document.evaluate() の詳しい使い方は MDC *11 に書いてあるよー!

5 筋縄: ファイルのエンコーディングを変換する

ところで、テストケースとして使った田楽 DLL のドキュメントは JIS (iso-2022-jp) で書かれている。これを安直に FileSystemObject で読み込むと文字化けする、っていうか、スクリプト中に書かれた文字列リテラルとの間で比較や検索ができない。FileSystemObject の代わりに ADODB.Stream を使うとファイル入出力時にエンコーディング変換ができる。読み込み時の自動判定も可能だ。

function loadTextFile(filename, encoding) {
	var ado = WScript.CreateObject("ADODB.Stream");
	ado.Charset = encoding || "_autodetect";
	ado.Open();
	ado.LoadFromFile(filename);
	var content = ado.ReadText();
	ado.Close();
	return content;
}

function saveTextFile(content, filename, encoding) {
	var ado = WScript.CreateObject("ADODB.Stream");
	ado.Charset = encoding || "UTF-8";
	ado.Open();
	ado.WriteText(content)
	ado.SaveToFile(filename, 2); // adSaveCreateOverWrite
	ado.Close();
}

ただし、拡張子 .js で "ADODB.Stream" という文字列を含むファイルはウィルス対策ソフトによってはウィルスと見なされる場合があるようだ*12。もうシラネーヨ ( ´ー`)

感想

あっれー? やってることは結局 HTML を XPathスクレイピングするだけなのに、なんでこんな簡単なことでこんな苦労してんだっけ??
ともかく、ここまでの成果を関数化しておけば、明日から WSHスクレイピングしまくりだぜ〜♪

補足

  • CSS 風のセレクタXPath の代わりに使う向きもあるようだ*13。しかし、私は XPath の方が堅牢で筋の良い規格だと思う。
  • jQuery はドキュメントツリーのトラバースをメソッドチェインで実現する。XPath も使える。しかし、私は jQuery 自体の正統性、規格としての安定性に不安を感じる。*14
  • ECMAScript for XML (E4X) は JavaScript のソース上でネイティブにドキュメントツリーをトラバースできる*15。これは良さそうに思えるが、IE ではまだサポートされていない。