DEV Community

Cover image for ネストテーブルとrowspanの処理方法(HTMLテーブルパースの難所)
circobit
circobit

Posted on

ネストテーブルとrowspanの処理方法(HTMLテーブルパースの難所)

HTMLテーブルのパースは、実世界のデータに遭遇するまでは簡単に思えます。Wikipediaのテーブルにはナビゲーション行があり、金融サイトは複雑なrowspanを使い、スポーツ統計サイトはヘッダーを2階層にネストしています。

数千の異なるサイトで使用されているテーブル抽出ツール HTML Table Exporter を開発する中で、ほとんどのパーサーを壊すエッジケースをカタログ化してきました。それぞれの対処法を紹介します。

問題1:rowspanの展開

rowspan="3" のセルは、現在の行と次の2行に垂直方向のスペースを占有します。row.cells を素朴に反復処理すると、列がずれます。

壊れた出力:

| 国    | 2020 | 2021 | 2022 |    <- ヘッダー
| 日本  | 100  | 200  | 300  |    <- 期待通り
| 150   | 250  | 350  |          <- "日本" が欠落(rowspan継続)
Enter fullscreen mode Exit fullscreen mode

修正: 仮想グリッドで占有位置を追跡します。

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

重要な洞察: 仮想グリッドが信頼の源です。DOMセルはグリッドを埋めるための命令に過ぎません。

問題2:ネストテーブル

Wikipediaのインフォボックスには、テーブルセル内にテーブルが含まれることがよくあります。再帰的なアプローチではゴミが抽出されます:

<table>
  <tr>
    <td></td>
    <td>
      <table>  <!-- ネスト! -->
        <tr><td>人口</td><td>1.26億</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

検出戦略: テーブルの祖先もテーブルかどうかチェックします。

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

ネストテーブルのコンテンツは?

外側のテーブルでは、ネストテーブルをテキストコンテンツにフラット化します:

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

問題3:Wikipediaのナビゲーション行

Wikipediaのテーブルは、ナビゲーション行で始まることがよくあります:

| v t e  国の人口一覧 |
| 順位 | 国   | 人口    |
| 1    | 中国 | 14億   |
Enter fullscreen mode Exit fullscreen mode

あの「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;  // デフォルト:最初の行がヘッダー
}
Enter fullscreen mode Exit fullscreen mode

問題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>
Enter fullscreen mode Exit fullscreen mode

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

問題5:グループ化カラムヘッダー(FBRefスタイル)

FBRefのようなスポーツ統計サイトは2階層ヘッダーを使用します:

|        |        | プレー時間      | パフォーマンス |
| 選手   | 国籍   | 出場 | 先発 | 分 | ゴール | アシスト | xG |
| ハーランド| ノルウェー | 35 | 33 | 2950| 36  | 8   | 32 |
Enter fullscreen mode Exit fullscreen mode

1行目にはグループ名、2行目に実際のカラム名があります。両方とも「ヘッダー」です。

課題: colspan展開後、0行目はこうなります:

["", "", "プレー時間", "プレー時間", "プレー時間", "パフォーマンス", "パフォーマンス", "パフォーマンス"]
Enter fullscreen mode Exit fullscreen mode

検出ヒューリスティクス:

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

グループ + サブヘッダーのマージ:

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}`;
  });
}

// 結果: ["選手", "国籍", "プレー時間 - 出場", "プレー時間 - 先発", ...]
Enter fullscreen mode Exit fullscreen mode

問題6:水平重複テーブル

Wikipediaの人口テーブルには、こんな構造のものがよくあります:

| 順位 | 名前   | 人口  | 順位 | 名前    | 人口  |
| 1    | 東京   | 3700万 | 11   | パリ   | 1100万 |
| 2    | デリー | 3200万 | 12   | カイロ | 1000万 |
Enter fullscreen mode Exit fullscreen mode

これは垂直スペースを節約するために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;
}
Enter fullscreen mode Exit fullscreen mode

正規化: 各行を分割して垂直にスタック:

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

統合アルゴリズム

実世界のパースでは、これらすべてのケースを順番にチェックする必要があります:

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

エッジケースのテスト

上記のすべてのパターンは、実際のバグレポートから生まれました。各パターンの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] === "中国");   // データが正しく整列
Enter fullscreen mode Exit fullscreen mode

テストスイートにはこれらのパターンの組み合わせをカバーする24のケースがあります。新しいバグレポートが新しいテストケースになります。

試してみよう

テーブル抽出を開発している方には、このガイドがデバッグ時間の節約になれば幸いです。コードを書かずにテーブルをエクスポートしたいだけなら、HTML Table Exporter がこれらすべてのケースを自動的に処理します。

詳しくは gauchogrid.com/ja/html-table-exporter をご覧いただくか、Chrome Web Store で無料でお試しください。


パーサーを壊すテーブルを見つけましたか?URLを共有してください。エッジケースを収集しています。

Top comments (0)