HTMLテーブルのパースは、実世界のデータに遭遇するまでは簡単に思えます。Wikipediaのテーブルにはナビゲーション行があり、金融サイトは複雑なrowspanを使い、スポーツ統計サイトはヘッダーを2階層にネストしています。
数千の異なるサイトで使用されているテーブル抽出ツール HTML Table Exporter を開発する中で、ほとんどのパーサーを壊すエッジケースをカタログ化してきました。それぞれの対処法を紹介します。
問題1:rowspanの展開
rowspan="3" のセルは、現在の行と次の2行に垂直方向のスペースを占有します。row.cells を素朴に反復処理すると、列がずれます。
壊れた出力:
| 国 | 2020 | 2021 | 2022 | <- ヘッダー
| 日本 | 100 | 200 | 300 | <- 期待通り
| 150 | 250 | 350 | <- "日本" が欠落(rowspan継続)
修正: 仮想グリッドで占有位置を追跡します。
function expandRowspans(table) {
const rows = Array.from(table.rows);
const grid = [];
rows.forEach((rowEl, rowIndex) => {
if (!grid[rowIndex]) grid[rowIndex] = [];
let colIndex = 0;
Array.from(rowEl.cells).forEach(cell => {
// 次の未占有列を見つける
while (grid[rowIndex][colIndex] !== undefined) {
colIndex++;
}
const text = cell.textContent.trim();
const rowSpan = parseInt(cell.rowSpan, 10) || 1;
const colSpan = parseInt(cell.colSpan, 10) || 1;
// この要素がスパンするすべてのセルをマーク
for (let r = 0; r < rowSpan; r++) {
const targetRow = rowIndex + r;
if (!grid[targetRow]) grid[targetRow] = [];
for (let c = 0; c < colSpan; c++) {
grid[targetRow][colIndex + c] = text;
}
}
colIndex += colSpan;
});
});
// 行の長さを正規化
const maxCols = Math.max(...grid.map(r => r.length));
return grid.map(row => {
const normalized = new Array(maxCols).fill("");
row.forEach((val, i) => normalized[i] = val ?? "");
return normalized;
});
}
重要な洞察: 仮想グリッドが信頼の源です。DOMセルはグリッドを埋めるための命令に過ぎません。
問題2:ネストテーブル
Wikipediaのインフォボックスには、テーブルセル内にテーブルが含まれることがよくあります。再帰的なアプローチではゴミが抽出されます:
<table>
<tr>
<td>国</td>
<td>
<table> <!-- ネスト! -->
<tr><td>人口</td><td>1.26億</td></tr>
</table>
</td>
</tr>
</table>
検出戦略: テーブルの祖先もテーブルかどうかチェックします。
function isNestedTable(table) {
let parent = table.parentElement;
while (parent) {
if (parent.tagName === "TABLE") {
return true;
}
parent = parent.parentElement;
}
return false;
}
function getTopLevelTables() {
const all = document.querySelectorAll("table");
return Array.from(all).filter(t => !isNestedTable(t));
}
ネストテーブルのコンテンツは?
外側のテーブルでは、ネストテーブルをテキストコンテンツにフラット化します:
function extractCellText(cell) {
const clone = cell.cloneNode(true);
// ネストテーブルを削除(テキストはtextContentに含まれている)
clone.querySelectorAll("table").forEach(t => t.remove());
// 不可視要素を削除
clone.querySelectorAll("style, script").forEach(el => el.remove());
return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
問題3:Wikipediaのナビゲーション行
Wikipediaのテーブルは、ナビゲーション行で始まることがよくあります:
| v t e 国の人口一覧 |
| 順位 | 国 | 人口 |
| 1 | 中国 | 14億 |
あの「v t e」行(閲覧/ノート/編集リンク)はデータではなくUIです。これをヘッダー行として扱うパーサーはゴミを生成します。
Wikipediaテーブルの処理に関する実践ガイドは、テーブルをExcelにコピーするのに最適なChrome拡張機能もご覧ください。
検出:
function isWikipediaNavRow(row) {
const firstCell = row[0] || "";
const patterns = [
/^v\s+t\s+e\s/i,
/^\s*v\s*\|\s*t\s*\|\s*e/i,
/^\[v\]\s*\[t\]\s*\[e\]/i
];
return patterns.some(p => p.test(firstCell));
}
function detectHeaderRowIndex(matrix) {
for (let i = 0; i < Math.min(3, matrix.length - 1); i++) {
if (isWikipediaNavRow(matrix[i])) {
return i + 1; // ヘッダーは次の行
}
}
return 0; // デフォルト:最初の行がヘッダー
}
問題4:タイトル行(全列スパン)
一部のテーブルには、全幅にまたがるタイトル行があります:
<table>
<tr><td colspan="4">四半期売上(百万円)</td></tr>
<tr><td>Q1</td><td>Q2</td><td>Q3</td><td>Q4</td></tr>
<tr><td>100</td><td>120</td><td>115</td><td>130</td></tr>
</table>
rowspan展開後、最初の行は ["四半期売上...", "四半期売上...", ...] になります — 同じ値の繰り返し。
検出:
function isTitleRow(row, nextRow) {
if (!row || !nextRow) return false;
const uniqueValues = new Set(row.filter(v => v.trim()));
const nextUniqueValues = new Set(nextRow.filter(v => v.trim()));
return (
uniqueValues.size === 1 &&
nextUniqueValues.size > 2 &&
row[0] && row[0].length > 30
);
}
問題5:グループ化カラムヘッダー(FBRefスタイル)
FBRefのようなスポーツ統計サイトは2階層ヘッダーを使用します:
| | | プレー時間 | パフォーマンス |
| 選手 | 国籍 | 出場 | 先発 | 分 | ゴール | アシスト | xG |
| ハーランド| ノルウェー | 35 | 33 | 2950| 36 | 8 | 32 |
1行目にはグループ名、2行目に実際のカラム名があります。両方とも「ヘッダー」です。
課題: colspan展開後、0行目はこうなります:
["", "", "プレー時間", "プレー時間", "プレー時間", "パフォーマンス", "パフォーマンス", "パフォーマンス"]
検出ヒューリスティクス:
function isGroupHeaderRow(row, nextRow) {
if (!row || !nextRow || row.length !== nextRow.length) return false;
let repeatCount = 0;
for (let i = 1; i < row.length; i++) {
if (row[i] && row[i] === row[i-1]) repeatCount++;
}
const repeatRatio = repeatCount / (row.length - 1);
const uniqueInRow = new Set(row.filter(v => v.trim())).size;
const uniqueInNext = new Set(nextRow.filter(v => v.trim())).size;
return repeatRatio > 0.4 && uniqueInNext > uniqueInRow;
}
グループ + サブヘッダーのマージ:
function mergeGroupAndSubHeaders(groupRow, subHeaderRow) {
return subHeaderRow.map((subHeader, idx) => {
const group = (groupRow[idx] || "").trim();
const sub = (subHeader || "").trim();
if (!group) return sub;
if (!sub) return group;
if (sub.toLowerCase() === group.toLowerCase()) return sub;
return `${group} - ${sub}`;
});
}
// 結果: ["選手", "国籍", "プレー時間 - 出場", "プレー時間 - 先発", ...]
問題6:水平重複テーブル
Wikipediaの人口テーブルには、こんな構造のものがよくあります:
| 順位 | 名前 | 人口 | 順位 | 名前 | 人口 |
| 1 | 東京 | 3700万 | 11 | パリ | 1100万 |
| 2 | デリー | 3200万 | 12 | カイロ | 1000万 |
これは垂直スペースを節約するために2列で表示された1つの論理テーブルです。
検出:
function detectHorizontalDuplication(headers) {
const half = Math.floor(headers.length / 2);
if (half < 2) return null;
const firstHalf = headers.slice(0, half);
const secondHalf = headers.slice(half, half * 2);
const matches = firstHalf.every((h, i) =>
h.toLowerCase() === secondHalf[i]?.toLowerCase()
);
if (matches) {
return { detected: true, repeatCount: 2, baseColumns: half };
}
return null;
}
正規化: 各行を分割して垂直にスタック:
function normalizeHorizontallyDuplicatedTable(matrix, baseColumns) {
const header = matrix[0].slice(0, baseColumns);
const normalizedRows = [header];
for (let i = 1; i < matrix.length; i++) {
const row = matrix[i];
normalizedRows.push(row.slice(0, baseColumns));
const secondHalf = row.slice(baseColumns, baseColumns * 2);
if (secondHalf.some(cell => cell.trim())) {
normalizedRows.push(secondHalf);
}
}
return normalizedRows;
}
統合アルゴリズム
実世界のパースでは、これらすべてのケースを順番にチェックする必要があります:
function parseTable(table) {
// 1. rowspan/colspanを仮想グリッドに展開
let matrix = expandRowspans(table);
// 2. ナビ/タイトル行を検出・スキップ
const headerIndex = detectHeaderRowIndex(matrix);
if (headerIndex > 0) {
matrix = matrix.slice(headerIndex);
}
// 3. グループヘッダーの処理(FBRefスタイル)
const groupedHeaders = detectGroupedColumnHeaders(matrix);
if (groupedHeaders) {
const mergedHeaders = mergeGroupAndSubHeaders(matrix[0], matrix[1]);
matrix = [mergedHeaders, ...matrix.slice(2)];
}
// 4. 水平重複の処理
const duplication = detectHorizontalDuplication(matrix[0]);
if (duplication) {
matrix = normalizeHorizontallyDuplicatedTable(matrix, duplication.baseColumns);
}
return matrix;
}
エッジケースのテスト
上記のすべてのパターンは、実際のバグレポートから生まれました。各パターンのHTMLフィクスチャを含むテストスイートをメンテナンスしています:
// テスト: Wikipediaスタイルのナビ行
const navRowHtml = `
<table>
<tr><td colspan="3">v t e 国一覧</td></tr>
<tr><td>順位</td><td>国</td><td>人口</td></tr>
<tr><td>1</td><td>中国</td><td>14億</td></tr>
</table>
`;
const result = parseTable(parseHtml(navRowHtml));
assert(result[0][0] === "順位"); // ヘッダーが正しく識別
assert(result[1][1] === "中国"); // データが正しく整列
テストスイートにはこれらのパターンの組み合わせをカバーする24のケースがあります。新しいバグレポートが新しいテストケースになります。
試してみよう
テーブル抽出を開発している方には、このガイドがデバッグ時間の節約になれば幸いです。コードを書かずにテーブルをエクスポートしたいだけなら、HTML Table Exporter がこれらすべてのケースを自動的に処理します。
詳しくは gauchogrid.com/ja/html-table-exporter をご覧いただくか、Chrome Web Store で無料でお試しください。
パーサーを壊すテーブルを見つけましたか?URLを共有してください。エッジケースを収集しています。
Top comments (0)