DEV Community

Cover image for ヘッダー行の自動検出:テーブル解析のヒューリスティクス
circobit
circobit

Posted on

ヘッダー行の自動検出:テーブル解析のヒューリスティクス

HTMLテーブルの最初の行はヘッダー行。

ただし、そうでない場合もあります。

Wikipediaテーブルでは実際のヘッダーの前に全列にまたがるタイトル行があることが多い。スポーツ統計サイトでは「出場時間」が「試合数」「先発」「出場分」といった複数のサブ列にまたがるグループヘッダーがある。財務テーブルにはヘッダーのように見えるが実際にはそうではない単位行(「百万ドル」)がある。

行0が常にヘッダーだと仮定すると、実世界のテーブルのかなりの割合でエクスポートが壊れます。

プログラムで実際のヘッダー行を検出する方法を解説します。

問題:3種類の「最初の行」

よくあるパターンを見てみましょう:

パターン1:タイトル行

<table>
  <tr>
    <th colspan="4">世界の国別人口</th>  <!-- タイトルであり、ヘッダーではない -->
  </tr>
  <tr>
    <th>順位</th>
    <th>国名</th>
    <th>人口</th>
    <th>世界比率</th>
  </tr>
  <tr>
    <td>1</td>
    <td>インド</td>
    <td>1,428,627,663</td>
    <td>17.85%</td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

行0はタイトル。行1がヘッダー。行2以降がデータ。

パターン2:グループヘッダー(2階層)

<table>
  <tr>
    <th></th>
    <th></th>
    <th colspan="3">出場時間</th>
    <th colspan="2">パフォーマンス</th>
  </tr>
  <tr>
    <th>選手</th>
    <th>国籍</th>
    <th>試合</th>
    <th>先発</th>
    <th>出場分</th>
    <th>得点</th>
    <th>アシスト</th>
  </tr>
  <tr>
    <td>田中太郎</td>
    <td>JPN</td>
    <td>34</td>
    <td>30</td>
    <td>2700</td>
    <td>12</td>
    <td>8</td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

行0はグループヘッダー。行1が実際の列ヘッダー。行2以降がデータ。

パターン3:Wikipediaのナビゲーション接頭辞

<tr>
  <th colspan="3">v t e 世界遺産</th>
</tr>
Enter fullscreen mode Exit fullscreen mode

「v t e」(表示/トーク/編集)リンクはWikipediaのテンプレートナビゲーション。除去する必要があり、その行自体もヘッダーではなくタイトルかもしれません。

ヒューリスティック1:タイトル行の検出

タイトル行の典型的な特徴:

  • セルが1つ(または非常に少ない)
  • ほとんど/すべての列にまたがる大きなcolspan
  • 列名ではなくタイトルに見えるテキストコンテンツ
function isTitleRow(row, totalColumns) {
  if (!row || row.length === 0) return false;

  // 空でないセルを数える
  const nonEmptyCells = row.filter(cell => cell && cell.trim()).length;

  // タイトル行は通常1-2個の非空セル
  if (nonEmptyCells > 2) return false;

  // 最初のセルがほとんどの列にまたがっているか確認(colspanを示す)
  // 正規化されたマトリクスでは、これは繰り返される値として表示される
  const firstValue = row[0];
  const repeatedCount = row.filter(cell => cell === firstValue).length;

  // 最初の値が列の50%以上で繰り返されている場合、colspanタイトルの可能性が高い
  if (repeatedCount > totalColumns * 0.5) {
    return true;
  }

  return false;
}
Enter fullscreen mode Exit fullscreen mode

ヒューリスティック2:「ヘッダーらしい」行の特徴

ヘッダー行にはデータ行と区別する特徴があります:

function rowLooksLikeHeaders(row) {
  if (!row || row.length === 0) return false;

  let numericCells = 0;
  let textCells = 0;
  let emptyCells = 0;

  for (const cell of row) {
    const value = (cell || "").trim();

    if (!value) {
      emptyCells++;
    } else if (/^-?\d+([.,]\d+)?%?$/.test(value)) {
      // 純粋な数値またはパーセント
      numericCells++;
    } else {
      textCells++;
    }
  }

  const totalNonEmpty = numericCells + textCells;
  if (totalNonEmpty === 0) return false;

  // ヘッダーは主にテキストで、数値ではない
  // 非空セルの70%以上が数値ならデータの可能性が高い
  if (numericCells / totalNonEmpty > 0.7) {
    return false;
  }

  // ヘッダーはほとんど空であるべきではない
  if (emptyCells / row.length > 0.7) {
    return false;
  }

  return true;
}
Enter fullscreen mode Exit fullscreen mode

ヒューリスティック3:「データらしい」行の特徴

逆のチェックで、正しい境界を見つけたことを確認します:

function rowLooksLikeData(row) {
  if (!row || row.length === 0) return false;

  let numericCells = 0;
  let dateCells = 0;
  let totalNonEmpty = 0;

  for (const cell of row) {
    const value = (cell || "").trim();
    if (!value) continue;

    totalNonEmpty++;

    // 数値パターンのチェック
    if (/^-?\d+([.,]\d+)?%?$/.test(value)) {
      numericCells++;
    }

    // 日付パターンのチェック
    if (/^\d{1,4}[-/\.]\d{1,2}[-/\.]\d{1,4}$/.test(value)) {
      dateCells++;
    }
  }

  if (totalNonEmpty === 0) return false;

  // データ行は通常、数値または日付コンテンツを含む
  const dataLikeCells = numericCells + dateCells;
  return dataLikeCells / totalNonEmpty > 0.3;
}
Enter fullscreen mode Exit fullscreen mode

ヒューリスティック4:グループ列ヘッダーの検出

FBREF形式のテーブルには、グループヘッダー行の後にサブヘッダー行が続きます。グループ行の特徴:

  • 先頭に空セル(グループのない列)
  • colspanの展開による繰り返し値
  • 複数のユニークな非空値(タイトルのように1つだけではない)
function detectGroupHeaderRow(row, nextRow) {
  if (!row || !nextRow || row.length < 4) return false;

  // グループヘッダー行は先頭に空セルがなければならない
  const firstCellEmpty = !(row[0] || "").trim();
  if (!firstCellEmpty) return false;

  // ユニークな非空値を数える
  const uniqueValues = new Set(
    row.filter(v => v && v.trim()).map(v => v.trim().toLowerCase())
  );

  // タイトル行はユニーク値が1つだけ
  // グループヘッダー行は複数のユニーク値が必要
  if (uniqueValues.size <= 1) return false;

  // 連続する繰り返し値を数える(colspan展開を示す)
  let consecutiveRepeats = 0;
  for (let i = 1; i < row.length; i++) {
    const curr = (row[i] || "").trim();
    const prev = (row[i - 1] || "").trim();
    if (curr === prev) consecutiveRepeats++;
  }

  const repeatRatio = consecutiveRepeats / (row.length - 1);

  // 高い繰り返し率(>30%)はcolspan展開を示唆
  // 次の行はより多くのユニーク値を持つべき(実際のサブヘッダー)
  const nextUniqueValues = new Set(
    nextRow.filter(v => v && v.trim()).map(v => v.trim().toLowerCase())
  );

  return repeatRatio > 0.3 && nextUniqueValues.size > uniqueValues.size;
}
Enter fullscreen mode Exit fullscreen mode

ヒューリスティック5:Wikipediaナビゲーション接頭辞のクリーニング

Wikipediaのテンプレートはコンテンツの前に「v t e」(テンプレートの表示/トーク/編集へのリンク)を付加することがあります:

function cleanWikipediaNavPrefix(text) {
  if (!text) return text;

  // パターン1:"v t e " が先頭に(スペース区切り)
  // パターン2:"v | t | e "(パイプ区切り)
  // パターン3:"[v] [t] [e] "(ブラケット区切り)

  return text
    .replace(/^\s*v\s+t\s+e\s+/i, "")
    .replace(/^\s*v\s*\|\s*t\s*\|\s*e\s+/i, "")
    .replace(/^\s*\[v\]\s*\[t\]\s*\[e\]\s+/i, "")
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

統合:検出アルゴリズム

function detectHeaderRowIndex(matrix) {
  if (!matrix || matrix.length < 2) return 0;

  const totalColumns = matrix[0]?.length || 0;

  for (let i = 0; i < Math.min(matrix.length - 1, 5); i++) {
    const currentRow = matrix[i];
    const nextRow = matrix[i + 1];

    // タイトル行をスキップ
    if (isTitleRow(currentRow, totalColumns)) {
      continue;
    }

    // グループヘッダー(2階層)をチェック
    if (detectGroupHeaderRow(currentRow, nextRow)) {
      // サブヘッダー行(i+1)が実際のヘッダー
      return i + 1;
    }

    // この行がヘッダーに見え、次の行がデータに見えるかチェック
    if (rowLooksLikeHeaders(currentRow) && rowLooksLikeData(nextRow)) {
      return i;
    }
  }

  // フォールバック:行0をヘッダーと仮定
  return 0;
}
Enter fullscreen mode Exit fullscreen mode

実世界でのテスト

これらのヒューリスティクスは以下のテストを経て開発されました:

  • Wikipediaの国別/人口テーブル(タイトル行 + 「v t e」接頭辞)
  • FBREFの選手統計(グループヘッダー)
  • 単位行を持つ財務テーブル
  • 複数ヘッダーレベルの政府データテーブル

完璧なヒューリスティクスはありません。目標はよくあるパターンを正しく処理し、珍しいテーブルでもグレースフルに失敗することです。

検出が失敗する場合

一般的なパターンに当てはまらないテーブルには、手動オーバーライドを提供します:

function extractTable(matrix, options = {}) {
  const headerRowIndex = options.headerRowIndex ?? detectHeaderRowIndex(matrix);

  const headerRow = matrix[headerRowIndex];
  const dataRows = matrix.slice(headerRowIndex + 1);

  return { headerRow, dataRows };
}
Enter fullscreen mode Exit fullscreen mode

データを知っているユーザーはヘッダー行を明示的に指定できます。

まとめ

パターン 検出方法
タイトル行 大きなcolspanを持つ単一セル
標準ヘッダー 主にテキストの行の後に数値の行
グループヘッダー 先頭の空セル + 繰り返し値 + 次の行により多くのユニーク値
Wikipediaナビ "v t e" 接頭辞パターン

重要なインサイト:ヘッダーとデータには異なる特性があります。ヘッダーは説明的なラベルを持つテキスト中心。データは実際の値を持つ数値中心。その境界は通常検出可能です。

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


コードを書かずにヘッダー自動検出が必要ですか?gauchogrid.com/ja/html-table-exporterで詳細を確認するか、Chrome ウェブストアで無料でお試しください。

Top comments (0)