結合セルを持つテーブルはどこにでもあります。Wikipediaで使われ、スポーツ統計サイトで使われ、財務レポートで使われています。
そして、私が見てきたほぼすべてのテーブルスクレイパーを壊します。
問題はこうです:<tr> と <td> 要素を順に処理すると、HTMLにあるものが取得できます——しかしそれは視覚的にレンダリングされたものとは異なります。rowspan="3" を持つセルはHTMLには1回だけ出現しますが、出力では3行を占めます。
素朴なアプローチは静かに失敗します。列のずれ、データの欠落、元のテーブルとはまったく異なるエクスポート結果になります。
修正方法を解説します。
問題:HTMLはグリッドと一致しない
結合セルを持つシンプルなテーブルを考えます:
<table>
<tr>
<td rowspan="2">A</td>
<td>B</td>
</tr>
<tr>
<td>C</td>
</tr>
</table>
視覚的な表現:
| A | B |
| | C |
しかし素朴にセルを行ごとに抽出すると:
// 間違ったアプローチ
table.querySelectorAll('tr').forEach(row => {
const cells = row.querySelectorAll('td');
console.log(cells.map(c => c.textContent));
});
// 出力: ["A", "B"], ["C"]
// 期待: ["A", "B"], ["A", "C"]
行2には、視覚的にスパンしている「A」が欠けています。
解決策:マトリクスを構築する
正しいアプローチは、2次元グリッドを事前に割り当て、span属性に従って埋めることです。
function extractTableMatrix(table) {
const rows = Array.from(table.rows);
const grid = [];
rows.forEach((rowEl, rowIndex) => {
if (!grid[rowIndex]) grid[rowIndex] = [];
const gridRow = grid[rowIndex];
let colIndex = 0;
// 前の行のrowspanで既に占有されている列をスキップ
while (gridRow[colIndex] !== undefined) {
colIndex++;
}
const cells = Array.from(rowEl.cells);
cells.forEach((cell) => {
// 占有されたセルを飛ばす
while (gridRow[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 targetRowIndex = rowIndex + r;
if (!grid[targetRowIndex]) grid[targetRowIndex] = [];
for (let c = 0; c < colSpan; c++) {
const targetColIndex = colIndex + c;
if (grid[targetRowIndex][targetColIndex] === undefined) {
grid[targetRowIndex][targetColIndex] = text;
}
}
}
colIndex += colSpan;
});
});
return grid;
}
これで出力は視覚的なテーブルと一致します:
const matrix = extractTableMatrix(table);
// [["A", "B"], ["A", "C"]]
なぜこれが重要か:実例
Wikipediaの国別テーブル
Wikipediaは結合セルを多用しています。地域別の国一覧テーブルは:
<tr>
<td rowspan="5">ヨーロッパ</td>
<td>フランス</td>
</tr>
<tr>
<td>ドイツ</td>
</tr>
<!-- ... -->
適切に処理しないと、2~5行目の「ヨーロッパ」の関連付けが失われます。
スポーツ統計サイト
FBREFのようなサイトはrowspanとcolspanの両方を使用します。選手統計テーブルには:
- colspanによるグループヘッダー(出場時間 → 試合数 | 先発 | 出場時間)
- rowspanによる選手詳細をまたぐサマリー行
素朴なスクレイパーは意味不明なデータをエクスポートします。マトリクスベースのアプローチは構造を保持します。
財務レポート
四半期レポートでは:
<td colspan="4">2024年</td>
<!-- その後: Q1, Q2, Q3, Q4 -->
年度ヘッダーを4つの四半期列すべてに伝播させる必要があります。
処理すべきエッジケース
中間の空セル
セルが本当に空の場合があります。「まだspanで埋められていない」と混同しないように注意。
// すべての行を同じ列数に正規化
const maxCols = grid.reduce((max, row) =>
Math.max(max, row ? row.length : 0), 0
);
const normalized = grid.map(row => {
const r = row || [];
const out = new Array(maxCols);
for (let i = 0; i < maxCols; i++) {
out[i] = r[i] != null ? r[i] : "";
}
return out;
});
ネストされたテーブル
セル内のテーブルはノイズを生み出します。ほとんどの場合、ネストされたテーブルのテキストコンテンツが欲しいのであって、再帰的な抽出ではありません。
function extractCellText(cell) {
const clone = cell.cloneNode(true);
// ネストされたテーブル、スクリプト、スタイルを削除
clone.querySelectorAll("table, script, style, noscript")
.forEach(el => el.remove());
return clone.textContent.replace(/\s+/g, " ").trim();
}
Colspan + Rowspanの組み合わせ
アルゴリズムはこれを自然に処理します——グリッド内のより大きな矩形を埋めるだけです。
<td rowspan="2" colspan="3">大きなセル</td>
2×3のブロックを同じ値で埋めます。
ツールを使うべき場面
ゼロから書くことは勉強になりますが、時間がかかります。本番環境では以下を検討してください:
- HTML Table Exporter — これらのエッジケースを自動的に処理するブラウザ拡張機能
- Pandas read_html() — 動作するが複雑なネストテーブルには弱い
- Beautiful Soup — 手動でのspan処理が必要
完全なアルゴリズム
すべてのエッジケースに対応した本番バージョン:
function extractCellText(cell) {
if (!cell) return "";
const clone = cell.cloneNode(true);
// 非表示コンテンツを含む要素を削除
const invisibleSelectors = "style, script, noscript, template, link";
clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());
const text = clone.textContent || "";
return text.replace(/\s+/g, " ").trim();
}
function extractTableMatrix(table) {
const rows = Array.from(table.rows);
const grid = [];
rows.forEach((rowEl, rowIndex) => {
if (!grid[rowIndex]) grid[rowIndex] = [];
const gridRow = grid[rowIndex];
let colIndex = 0;
while (gridRow[colIndex] !== undefined) {
colIndex++;
}
const cells = Array.from(rowEl.cells);
cells.forEach((cell) => {
while (gridRow[colIndex] !== undefined) {
colIndex++;
}
const text = extractCellText(cell);
const rowSpan = parseInt(cell.rowSpan, 10) || 1;
const colSpan = parseInt(cell.colSpan, 10) || 1;
for (let r = 0; r < rowSpan; r++) {
const targetRowIndex = rowIndex + r;
if (!grid[targetRowIndex]) grid[targetRowIndex] = [];
const targetRow = grid[targetRowIndex];
for (let c = 0; c < colSpan; c++) {
const targetColIndex = colIndex + c;
if (targetRow[targetColIndex] === undefined) {
targetRow[targetColIndex] = text;
}
}
}
colIndex += colSpan;
});
});
// すべての行を同じ長さに正規化
const maxCols = grid.reduce((max, row) =>
Math.max(max, row ? row.length : 0), 0
);
return grid.map(row => {
const r = row || [];
const out = new Array(maxCols);
for (let i = 0; i < maxCols; i++) {
out[i] = r[i] != null ? r[i] : "";
}
return out;
});
}
まとめ
| アプローチ | span対応 | 本番利用可能 |
|---|---|---|
| 素朴なイテレーション | ❌ | ❌ |
| spanを考慮したマトリクス | ✅ | ✅ |
| Pandas read_html | 部分的 | 場合による |
| ブラウザ拡張機能 | ✅ | ✅ |
重要なインサイト:テーブルはネストされたHTML要素としてではなく、グリッドとして考えること。まずグリッドを構築し、それからエクスポートする。
Wikipediaの特に複雑なテーブルのエクスポートについては、WikipediaテーブルをExcelにエクスポートする方法のガイドをご覧ください。
コードを書かずにテーブルを抽出したい方は、gauchogrid.com/ja/html-table-exporterで詳細を確認するか、Chrome ウェブストアで無料でお試しください。
Top comments (0)