DEV Community

Cover image for テーブル抽出でのrowspanとcolspanの処理方法
circobit
circobit

Posted on

テーブル抽出でのrowspanとcolspanの処理方法

結合セルを持つテーブルはどこにでもあります。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>
Enter fullscreen mode Exit fullscreen mode

視覚的な表現:

| A | B |
|   | C |
Enter fullscreen mode Exit fullscreen mode

しかし素朴にセルを行ごとに抽出すると:

// 間違ったアプローチ
table.querySelectorAll('tr').forEach(row => {
  const cells = row.querySelectorAll('td');
  console.log(cells.map(c => c.textContent));
});
// 出力: ["A", "B"], ["C"]
// 期待: ["A", "B"], ["A", "C"]
Enter fullscreen mode Exit fullscreen mode

行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;
}
Enter fullscreen mode Exit fullscreen mode

これで出力は視覚的なテーブルと一致します:

const matrix = extractTableMatrix(table);
// [["A", "B"], ["A", "C"]]
Enter fullscreen mode Exit fullscreen mode

なぜこれが重要か:実例

Wikipediaの国別テーブル

Wikipediaは結合セルを多用しています。地域別の国一覧テーブルは:

<tr>
  <td rowspan="5">ヨーロッパ</td>
  <td>フランス</td>
</tr>
<tr>
  <td>ドイツ</td>
</tr>
<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

適切に処理しないと、2~5行目の「ヨーロッパ」の関連付けが失われます。

スポーツ統計サイト

FBREFのようなサイトはrowspanとcolspanの両方を使用します。選手統計テーブルには:

  • colspanによるグループヘッダー(出場時間 → 試合数 | 先発 | 出場時間)
  • rowspanによる選手詳細をまたぐサマリー行

素朴なスクレイパーは意味不明なデータをエクスポートします。マトリクスベースのアプローチは構造を保持します。

財務レポート

四半期レポートでは:

<td colspan="4">2024年</td>
<!-- その後: Q1, Q2, Q3, Q4 -->
Enter fullscreen mode Exit fullscreen mode

年度ヘッダーを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;
});
Enter fullscreen mode Exit fullscreen mode

ネストされたテーブル

セル内のテーブルはノイズを生み出します。ほとんどの場合、ネストされたテーブルのテキストコンテンツが欲しいのであって、再帰的な抽出ではありません。

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();
}
Enter fullscreen mode Exit fullscreen mode

Colspan + Rowspanの組み合わせ

アルゴリズムはこれを自然に処理します——グリッド内のより大きな矩形を埋めるだけです。

<td rowspan="2" colspan="3">大きなセル</td>
Enter fullscreen mode Exit fullscreen mode

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;
  });
}
Enter fullscreen mode Exit fullscreen mode

まとめ

アプローチ span対応 本番利用可能
素朴なイテレーション
spanを考慮したマトリクス
Pandas read_html 部分的 場合による
ブラウザ拡張機能

重要なインサイト:テーブルはネストされたHTML要素としてではなく、グリッドとして考えること。まずグリッドを構築し、それからエクスポートする。

Wikipediaの特に複雑なテーブルのエクスポートについては、WikipediaテーブルをExcelにエクスポートする方法のガイドをご覧ください。


コードを書かずにテーブルを抽出したい方は、gauchogrid.com/ja/html-table-exporterで詳細を確認するか、Chrome ウェブストアで無料でお試しください。

Top comments (0)