Ruby で HTML をパースしたいんですが。

今日は PHP の関数辞書を作ってみましょう。
http://www.php.net/download-docs.php
PHP のドキュメントは↑からダウンロードできます。本日の最新版は v5.1.3 となっております。 Single HTML は解凍すると 14MB になる巨大な HTML ファイルです。これを Ruby で切ったり焼いたりします。切ったり焼いたりすることを割烹というのでしたね。
はじめに、単純なテキストフィルタで関数名を拾えるかと思って試してみました。パッと見た感じ、どうやら関数名の部分は <B CLASS="methodname">〜</B> でマークアップされてるようです。そこで、 CLASS="methodname" を Grep してみます。そしたら 5226 個出てきました。ほんまかいな?
辞書である以上、関数名を過不足なく網羅していなければなりません。そこで、末尾に付いてる「関数一覧」も情報源として使うことにしました。関数一覧の範囲を切り出して CLASS="function" を Grep すると 3843 個でした。あれれ、だいぶ少ないぞ!
さらに、関数のなかには SWFTextField->moveTo() みたいなクラスメソッド(って PHP で呼ぶのかどうか知らんけど)があって、これらはそのまま書くものではないので除外します。その判定には、「関数一覧からリンクが張られてるかどうか」が情報源として使えそうです。これで関数は 3192 個になりました。
最終的に、「関数一覧からリンクが張られてるものを抽出 → リンク先のセクションから説明文を抽出 → 両者を合わせて辞書ファイルに出力」という手順を踏むことになりそうです。単純なテキストフィルタではとても歯が立ちませんね。
ドキュメントの配布形式は HTML です。 HTML を解析して必要な情報を抽出しなければなりません。ほんなら DOM ツリーっぽいもんを作ればいいんじゃないのという気がしてきますね。 RubyXML/HTML をパースするモジュールはいくつかあります*1 *2 *3 *4PHP のドキュメントは XHTML ではないので、そのままでは XML パーサは食べてくれそうもありません。それどころか、よーく見ると HTML としても全然なっちょらんじゃありませんか! おいおい、これじゃ HTML パーサだって食えねーよ!
こうなったら仕方ありません。かろうじてドキュメント構造を正しく反映してると思われる <H1> で全体をセクションに分割し、1セクションを1行にまとめて一時ファイルに書き出します。あとは一時ファイルを1行ずつ読み込みながら正規表現でマッチングして…という由緒正しい方法に持ち込みましょう。使ってるのは Ruby なのに、やってることは Perl くさいです。にんともかんとも。

#!/usr/bin/ruby -Ku
# 	PHP リファレンス(単一 HTML 版)から CompleteX 用の辞書とヒントファイルを作る
# 	2006/05/03

$singlehtmlfile = "php5.1.3_manual_ja_single.html"	# 勝手にリネーム
$funcreffile  = "funcref.html"
$funcancfile  = "funcanc.txt"
$funclistfile = "sample.dic"
$funchintfile = "sample.hint"

$funcref_bgnmark  = /VI\. 関数リファレンス/
$funcref_endmark  = /VII\. PHP および Zend エンジンの内部構造/
$funclist_bgnmark = /付録 T\. 関数一覧/
$funclist_endmark = /<\/BODY/

# 巨大 HTML から関数リファレンス部分と関数一覧部分を切り出す
def cutpart()
	$stderr.puts "cutting..."
	File.open($singlehtmlfile) do |file1|
		while sect = file1.gets("<H1")
			break if $funcref_bgnmark =~ sect
		end
		file2 = File.open($funcreffile, "w")
		while sect = file1.gets("<H1").chomp("<H1")
			break if sect =~ $funcref_endmark
			file2.puts "<H1" + sect.gsub(/\s+/, " ").gsub(/ >/, ">")
		end
		file2.close
		while sect = file1.gets("<H1")
			break if $funclist_bgnmark =~ sect
		end
		file2 = File.open($funcancfile, "w")
		file3 = File.open($funclistfile, "w")
		while sect = file1.gets("<DT").chomp("<DT")
			next if sect =~ /関数一覧/
			line = "<DT" + sect.gsub(/\s+/, " ").gsub(/ >/, ">")
			%r!<A HREF="#(function\..*?)"! =~ line
			next if $1 == nil
			file2.puts $1
			%r!<B CLASS="function">(.*?)</B>! =~ line
			file3.puts $1
			break if sect =~ $funclist_endmark
		end
		file2.close
		file3.close
	end
end

# 関数一覧の各関数に対し、リファレンスから引数と返り値を抜粋
def compose()
	ancstr = IO.read($funcancfile)
	refstr = IO.read($funcreffile)
	desc = Hash.new("")
	
	# 先にリファレンスからハッシュを構築しておく
	$stderr.puts "building..."
	refstr.each do |line|
		%r!<A NAME="(function\..*?)"! =~ line
		next if $1 == nil
		anchor = $1
		line.sub!(%r!<P>(手続き.*?)</P>(.*?)<P>(オブジェクト指向.*?)</P>!) {
			"#{$1} #{$2}\\n#{$3} "
		}
		%r!<DIV CLASS="refsect1">.*?</H2>(.*?)<P>(.*?)</P>! =~ line
		content = ""
		content << $1.html_to_plain if $1
		content << '\n'
		content << $2.html_to_plain if $2
		desc[anchor] = content
	end
	
	# 関数名と説明を書き出す
	$stderr.puts "writing..."
	file = File.open($funchintfile, "w")
	ancstr.each do |anchor|
		anchor.chomp!
		file.puts desc[anchor]
		if desc[anchor] == ""
			$stderr.puts "no description: #{name}\tfunction.#{anchor}"
		end
	end
	file.close
end

class String
	def html_to_plain
		self.gsub(/<.*?>/, "").gsub(/&#([0-9a-f]+);/i) {
			$1.to_i.chr
		}.gsub(/&lt;/, "<").gsub(/&gt;/, ">").gsub(/&quot;/, "\"").gsub(/\s+/, " ").strip.gsub(/&nbsp;/, " ")
	end
end

cutpart
compose