HTMLテーブルはシンプルに見えます。<table>、<tr>、<td>。何が問題になるのでしょうか?
数千もの実際のテーブルを処理してきたテーブルエクスポートツール HTML Table Exporter を開発した経験から言えることは:問題だらけです。この記事では、素朴なパーサーを壊すエッジケースとその対処法を紹介します。
一見シンプルなケース
完璧なテーブルはこんな感じです:
<table>
<thead>
<tr>
<th>名前</th>
<th>売上</th>
</tr>
</thead>
<tbody>
<tr>
<td>株式会社Acme</td>
<td>¥1.2億</td>
</tr>
</tbody>
</table>
これをパースするのは簡単です:
const rows = table.querySelectorAll('tr');
const data = [...rows].map(row =>
[...row.querySelectorAll('td, th')].map(cell => cell.textContent.trim())
);
これで完了?いいえ、全然です。
問題1:セル結合(colspan/rowspan)
実際のテーブルにはセル結合が頻出します。
<tr>
<td rowspan="3">2024年 Q1</td>
<td>1月</td>
<td>¥100万</td>
</tr>
<tr>
<td>2月</td>
<td>¥120万</td>
</tr>
<tr>
<td>3月</td>
<td>¥90万</td>
</tr>
素朴にパースすると、こうなります:
行1: ["2024年 Q1", "1月", "¥100万"]
行2: ["2月", "¥120万"] // 最初の列が欠落!
行3: ["3月", "¥90万"] // 最初の列が欠落!
解決策:位置マトリクスの構築
前の行のrowspanによって「占有」されているセルを追跡する必要があります:
function parseTableWithMergedCells(table) {
const rows = table.querySelectorAll('tr');
const matrix = [];
const rowspanTracker = []; // 列ごとのアクティブなrowspanを追跡
rows.forEach((row, rowIndex) => {
matrix[rowIndex] = [];
let colIndex = 0;
// 前のrowspanで占有されている列をスキップ
while (rowspanTracker[colIndex] > 0) {
matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
rowspanTracker[colIndex]--;
colIndex++;
}
row.querySelectorAll('td, th').forEach(cell => {
while (rowspanTracker[colIndex] > 0) {
matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
rowspanTracker[colIndex]--;
colIndex++;
}
const colspan = parseInt(cell.getAttribute('colspan')) || 1;
const rowspan = parseInt(cell.getAttribute('rowspan')) || 1;
const value = cell.textContent.trim();
for (let c = 0; c < colspan; c++) {
matrix[rowIndex][colIndex] = value;
if (rowspan > 1) {
rowspanTracker[colIndex] = rowspan - 1;
}
colIndex++;
}
});
});
return matrix;
}
これは簡略化版です。実際の実装では、colspan内にネストされたrowspanも処理する必要があり、かなり複雑になります。
問題2:データテーブルではないテーブル
すべての <table> がデータを含むわけではありません。多くのサイト(はい、2024年になっても)がレイアウトにテーブルを使用しています:
<table>
<tr>
<td><nav>メニュー</nav></td>
<td><main>コンテンツ</main></td>
</tr>
</table>
あるいはフォーム用:
<table>
<tr>
<td><label>メール:</label></td>
<td><input type="email"></td>
</tr>
</table>
解決策:ヒューリスティクス
「本物の」データテーブルを検出するために、いくつかのシグナルを使います:
function isDataTable(table) {
const rows = table.querySelectorAll('tr');
const cells = table.querySelectorAll('td, th');
if (rows.length < 2 || cells.length < 4) return false;
if (table.querySelector('input, select, textarea, button')) return false;
const links = table.querySelectorAll('a');
const textContent = table.textContent.length;
const linkText = [...links].reduce((sum, a) => sum + a.textContent.length, 0);
if (linkText / textContent > 0.7) return false;
const colCounts = [...rows].map(row =>
row.querySelectorAll('td, th').length
);
const variance = Math.max(...colCounts) - Math.min(...colCounts);
if (variance > 3) return false;
return true;
}
どれも完璧ではありません。エッジケースは常に存在します。
問題3:隠しコンテンツ
セルには可視テキスト以上の内容が含まれることがあります:
<td>
<span class="value">1,234</span>
<span class="sort-key" style="display:none">1234</span>
</td>
Wikipediaではソート可能なテーブルでこれが頻出します。textContent をそのまま取得すると "1,234 1234" になります。
解決策:可視テキストのみ抽出
function getVisibleText(element) {
const clone = element.cloneNode(true);
clone.querySelectorAll('[style*="display: none"], [style*="display:none"], .hidden, [hidden]').forEach(el => el.remove());
return clone.textContent.trim();
}
問題4:数値なのに数値じゃない
"$1,234.56" は数値です。"1.234,56"(ヨーロッパ形式)も数値です。"(1,234)"(会計のマイナス表記)も数値です。"1,234 M"(接尾辞付き)も数値です。
スプレッドシートで計算するには、実際の数値が必要です。
解決策:ロケール対応のパース
function parseNumber(value) {
if (!value || typeof value !== 'string') return value;
let cleaned = value.replace(/[$€£¥₹\s]/g, '').trim();
if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
cleaned = '-' + cleaned.slice(1, -1);
}
const suffixes = { 'K': 1e3, 'M': 1e6, 'B': 1e9, 'T': 1e12 };
const suffixMatch = cleaned.match(/([0-9.,]+)\s*([KMBT])$/i);
if (suffixMatch) {
cleaned = suffixMatch[1];
var multiplier = suffixes[suffixMatch[2].toUpperCase()];
}
const lastComma = cleaned.lastIndexOf(',');
const lastDot = cleaned.lastIndexOf('.');
if (lastComma > lastDot && lastComma > cleaned.length - 4) {
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
} else {
cleaned = cleaned.replace(/,/g, '');
}
let num = parseFloat(cleaned);
if (multiplier) num *= multiplier;
return isNaN(num) ? value : num;
}
これで約90%のケースに対応できます。残りの10%はきっと驚かされるでしょう。
問題5:文字エンコーディング地獄
UTF-8で解決したと思いましたか?そうはいきません。
実際のテーブルには以下が含まれます:
- ノーブレークスペース(
/\u00A0)—見た目はスペースだが、スペースではない - ゼロ幅文字 — 文字列比較を壊す
- Windows-1252文字がUTF-8に化けたもの
- 古いパーサーを壊す絵文字
- 多言語テーブルの右から左への制御文字
解決策:すべてを正規化
function normalizeText(text) {
return text
.normalize('NFC')
.replace(/\u00A0/g, ' ')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
Excel用にCSVをエクスポートする際は、UTF-8 BOMを先頭に付加します:
const BOM = '\uFEFF';
const csvContent = BOM + generateCSV(data);
BOMがないと、ExcelはUTF-8ファイルをWindows-1252と解釈し、特殊文字を破壊する可能性があります。
問題6:ネストされたテーブル
はい、テーブルの中にテーブル。通常はレイアウト用ですが、データ用の場合もあります:
<table>
<tr>
<td>商品A</td>
<td>
<table>
<tr><td>Sサイズ</td><td>¥1,000</td></tr>
<tr><td>Mサイズ</td><td>¥1,200</td></tr>
</table>
</td>
</tr>
</table>
解決策:戦略を決める
選択肢:
- フラット化:ネストテーブルをテキストに変換("Sサイズ: ¥1,000, Mサイズ: ¥1,200")
- 個別抽出:ネストテーブルを別のエクスポートとして扱う
- 行の展開:ネストされた行ごとに親行を複数作成
私は選択肢2(個別抽出)を採用し、深くネストされたケースには選択肢1をフォールバックとしました。完璧な答えはありません。ユースケース次第です。
現実
これらすべてのケースに対処した結果、テーブルパーサーは約800行のJavaScriptになりました。それでもすべてを完璧に処理できるわけではありません。
いくつかの厳しい現実:
- 完璧なパーサーは存在しない。 実世界のHTMLは乱雑です。
- ヒューリスティクスは失敗する。 ユーザー向けのエスケープハッチが常に必要です。
- パフォーマンスは重要。 50以上のテーブルがあるページもあります。パースは高速でなければなりません。
- エッジケースは無限。 95%のケースで動くものをリリースし、その後改善を繰り返しましょう。
ツールとリソース
同様のものを開発している方へ:
- SheetJS (xlsx) — Excelファイル生成のための堅実なライブラリ
- Papa Parse — 高速なCSVパースと生成
-
Chrome DevTools — コンソールで
$('table')を使ってテーブルを素早くインスペクト
Chrome拡張機能トップ5の比較は、HTMLテーブルスクレイパー比較ガイドもご参照ください。
あるいは、何も構築せずにテーブルをエクスポートしたいだけなら:使い捨てスクレイパーを書くのに疲れたので HTML Table Exporter を作りました。上記のエッジケースすべてに対応しています。
詳しくは gauchogrid.com/ja/html-table-exporter をご覧いただくか、Chrome Web Store で無料でお試しください。
あなたが遭遇した奇妙なテーブルのエッジケースは何ですか? パーサーを壊す新しいテストケースを常に探しています。
Top comments (0)