CodeIQ:HTMLをスクレイプしてCSVに変換しよう

私自身が表題の問題を解いた時のプログラムについて解説します。
問題の詳細は「HTMLをスクレイプしてCSVに変換しよう」(CodeIQ)を参照してください。

問題の概要

問題を引用します。
あなたはとあるWebページに含まれるテーブルの中身をスクレイプして、Excelなどで集計がしやすいようCSVファイルに変換する仕事を任されました。
またその仕事は定期的に行う必要があるため、手作業で行うのではなく自動化したいとのこと。
そこで、HTMLからCSVに変換するプログラムを作ることになりました。

求められるプログラムの前提条件は、以下の通りとなります。

標準入力から、HTML準拠(Wikipedia参照)のテキストデータが送られる
入力されるHTMLはフォーマットが正しい、具体的にはW3Cの構文検証サイトでエラーがないことを前提とする
また、HTMLはUTF-8でエンコーディングされ、多バイト文字は含まれないものとする
HTMLには、tableタグが1つだけ含まれる(テーブルは単体である)
テーブル内のセルはtr, th, tdタグで構成され、セルが連結されることはない
HTML内のテーブルを表形式として抽出し、CSVフォーマット(Wikipedia参照)で標準出力に返すこと
出力するCSVはコンマ「,」(U+002C) 区切りで、すべてのフィールドをダブルクォート「"」(U+0022)で囲むこと
CSVのフィールドとして出力される文字列は、thまたはtdタグ内のテキストに限定すること
thおよびtdタグの扱いは、CSV出力時において変える必要はない
thおよびtdタグ内に、さらにタグが含まれる場合、タグ自体は除去し配下のテキストだけを抽出すること
文字列フィールドは文字実体参照(Wikipedia参照)を用いてアンエスケープ処理をすること
ただし、<(小なり記号)、>(大なり記号)、&(アンパサンド)のみの対応でよいものとする

以下、置換例となります。

【入出力サンプル】
標準入力
<!DOCTYPE html> <html lang="en">
<head>
<meta charset="utf-8"/>
<title>title</title>
</head>
<body>
<table>
<tr>
<th>header1</th>
<th>header2</th>
</tr>
<tr>
<td>100</td>
<td>this is <b>content</b></td>
</tr>
</table>
</body>
</html>

標準出力
"header1","header2"
"100","this is content"

私のプログラム

Rubyで解答しています。

#!/usr/bin/ruby

# 1行分のデータを配列にする
def splitRow(line)
	data = []
	splited = line.split("</td>")
	for s in splited
		# タグを削除
		ut = s.gsub(/<.+?>/, "")

		# アンエスェープ
		us = ut.gsub("&lt;", "<").gsub("&gt;", ">").gsub("&amp;", "&")
		data << '"' + us + '"'
	end

	return data
end

# htmlデータのテーブル内のデータを配列に変換する
def getTableRows(html)
	ret = []
	md = html.match(/<table.*?>(.*)<\/table>/)
	table = md[1].gsub(/<th.*?>/, "<td>").gsub(/<td.*?>/, "<td>").gsub(/<\/th>/, "</td>").gsub(/<tr.*?>/, "").split("</tr>")

	for t in table
		ret << splitRow(t)
	end

	return ret
end

# 配列をCSV形式で表示する
def printAsCsv(data)
	for d in data
		puts d.join(",")
	end
end

# main
html=""
while line = gets
	line.strip!
	if line.empty? then next end

	html += line
end

data = getTableRows(html)
printAsCsv(data)

解説

特に難しいわけではありません。正規表現に強ければもっと簡潔にかける気がします。

考え方

ポイントはフォーマットを可能な限り統一してから処理することだと思います。
入力値はどこで開業されているかわからないので、まず入力値から開業を取り払って全て連結してしまいます。その後、<table>〜</table>を抜き出します。
次に<th>l;と</th>l;は<td>l;と</td>l;に置き換えてしまいます。
そのデータに対し、<tr>〜</tr>を一つずつ取り出し、さらにそこから<td>l;と</td>l;を順次取り出します。そして、そのデータに含まれるタグを削除し、アンエスケープ処理してダブルクォーテーションマークで囲んでやれば良いわけです。

main

入力値を一つの文字列に連結し、getTableRows()に渡します。
getTableRows()は入力値からタグを取り去ったのテーブルの項目を行毎の配列として返すので、その結果をprintAsCsv()で印字します。

getTableRows(html)

htmlデータからタグを取り去ったのテーブルの項目を行毎の配列として返します。
22行目で<table>〜</table>の間を抜き出します。
23行目で<th>l;と</th>l;は<td>l;と</td>l;に置き換えます。
25〜27行目のループで1行ずつデータを取り出し、結果として記録します。

splitRow(line)

1行分のデータを配列に変換します。
</td>を区切り文字として分割します。すると1項目ずつの配列(<td>とその他のタグは残っている)になります。
その項目毎にループで(7〜14行目)次の処理をします。
<と>で囲まれた部分(最小マッチ)を除きます。これでhtmlタグを全て削除できます。
次にアンエスケープ処理を行います。
これで必要なテキストができるのでダブルクォーテーションマークで囲んで結果に追加します。

printAsCsv(data)

二次元配列を1行ずつコンマで連結して印字するだけです。

雑感

一応、タグに属性が記述されていたり、変なところ(<td>と</td>の間とか)で改行されていても大丈夫なようにしましたが、テストケースにはそういうデータはありませんでした。ちょっとテストケースが不十分ではないでしょうか?