DEV Community

Cover image for HTMLテーブルの隠れた複雑さ(パースが思ったより難しい理由)
circobit
circobit

Posted on

HTMLテーブルの隠れた複雑さ(パースが思ったより難しい理由)

HTMLテーブルはシンプルに見えます。<table><tr><td>。何が問題になるのでしょうか?

数千もの実際のテーブルを処理してきたテーブルエクスポートツール HTML Table Exporter を開発した経験から言えることは:問題だらけです。この記事では、素朴なパーサーを壊すエッジケースとその対処法を紹介します。

一見シンプルなケース

完璧なテーブルはこんな感じです:

<table>
  <thead>
    <tr>
      <th>名前</th>
      <th>売上</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>株式会社Acme</td>
      <td>¥1.2億</td>
    </tr>
  </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

これをパースするのは簡単です:

const rows = table.querySelectorAll('tr');
const data = [...rows].map(row => 
  [...row.querySelectorAll('td, th')].map(cell => cell.textContent.trim())
);
Enter fullscreen mode Exit fullscreen mode

これで完了?いいえ、全然です。

問題1:セル結合(colspan/rowspan)

実際のテーブルにはセル結合が頻出します。

<tr>
  <td rowspan="3">2024年 Q1</td>
  <td>1月</td>
  <td>¥100万</td>
</tr>
<tr>
  <td>2月</td>
  <td>¥120万</td>
</tr>
<tr>
  <td>3月</td>
  <td>¥90万</td>
</tr>
Enter fullscreen mode Exit fullscreen mode

素朴にパースすると、こうなります:

1: ["2024年 Q1", "1月", "¥100万"]
2: ["2月", "¥120万"]          // 最初の列が欠落!
3: ["3月", "¥90万"]           // 最初の列が欠落!
Enter fullscreen mode Exit fullscreen mode

解決策:位置マトリクスの構築

前の行のrowspanによって「占有」されているセルを追跡する必要があります:

function parseTableWithMergedCells(table) {
  const rows = table.querySelectorAll('tr');
  const matrix = [];
  const rowspanTracker = []; // 列ごとのアクティブなrowspanを追跡

  rows.forEach((row, rowIndex) => {
    matrix[rowIndex] = [];
    let colIndex = 0;

    // 前のrowspanで占有されている列をスキップ
    while (rowspanTracker[colIndex] > 0) {
      matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
      rowspanTracker[colIndex]--;
      colIndex++;
    }

    row.querySelectorAll('td, th').forEach(cell => {
      while (rowspanTracker[colIndex] > 0) {
        matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
        rowspanTracker[colIndex]--;
        colIndex++;
      }

      const colspan = parseInt(cell.getAttribute('colspan')) || 1;
      const rowspan = parseInt(cell.getAttribute('rowspan')) || 1;
      const value = cell.textContent.trim();

      for (let c = 0; c < colspan; c++) {
        matrix[rowIndex][colIndex] = value;
        if (rowspan > 1) {
          rowspanTracker[colIndex] = rowspan - 1;
        }
        colIndex++;
      }
    });
  });

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

これは簡略化版です。実際の実装では、colspan内にネストされたrowspanも処理する必要があり、かなり複雑になります。

問題2:データテーブルではないテーブル

すべての <table> がデータを含むわけではありません。多くのサイト(はい、2024年になっても)がレイアウトにテーブルを使用しています:

<table>
  <tr>
    <td><nav>メニュー</nav></td>
    <td><main>コンテンツ</main></td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

あるいはフォーム用:

<table>
  <tr>
    <td><label>メール:</label></td>
    <td><input type="email"></td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

解決策:ヒューリスティクス

「本物の」データテーブルを検出するために、いくつかのシグナルを使います:

function isDataTable(table) {
  const rows = table.querySelectorAll('tr');
  const cells = table.querySelectorAll('td, th');

  if (rows.length < 2 || cells.length < 4) return false;
  if (table.querySelector('input, select, textarea, button')) return false;

  const links = table.querySelectorAll('a');
  const textContent = table.textContent.length;
  const linkText = [...links].reduce((sum, a) => sum + a.textContent.length, 0);
  if (linkText / textContent > 0.7) return false;

  const colCounts = [...rows].map(row => 
    row.querySelectorAll('td, th').length
  );
  const variance = Math.max(...colCounts) - Math.min(...colCounts);
  if (variance > 3) return false;

  return true;
}
Enter fullscreen mode Exit fullscreen mode

どれも完璧ではありません。エッジケースは常に存在します。

問題3:隠しコンテンツ

セルには可視テキスト以上の内容が含まれることがあります:

<td>
  <span class="value">1,234</span>
  <span class="sort-key" style="display:none">1234</span>
</td>
Enter fullscreen mode Exit fullscreen mode

Wikipediaではソート可能なテーブルでこれが頻出します。textContent をそのまま取得すると "1,234 1234" になります。

解決策:可視テキストのみ抽出

function getVisibleText(element) {
  const clone = element.cloneNode(true);
  clone.querySelectorAll('[style*="display: none"], [style*="display:none"], .hidden, [hidden]').forEach(el => el.remove());
  return clone.textContent.trim();
}
Enter fullscreen mode Exit fullscreen mode

問題4:数値なのに数値じゃない

"$1,234.56" は数値です。"1.234,56"(ヨーロッパ形式)も数値です。"(1,234)"(会計のマイナス表記)も数値です。"1,234 M"(接尾辞付き)も数値です。

スプレッドシートで計算するには、実際の数値が必要です。

解決策:ロケール対応のパース

function parseNumber(value) {
  if (!value || typeof value !== 'string') return value;

  let cleaned = value.replace(/[$€£¥₹\s]/g, '').trim();

  if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
    cleaned = '-' + cleaned.slice(1, -1);
  }

  const suffixes = { 'K': 1e3, 'M': 1e6, 'B': 1e9, 'T': 1e12 };
  const suffixMatch = cleaned.match(/([0-9.,]+)\s*([KMBT])$/i);
  if (suffixMatch) {
    cleaned = suffixMatch[1];
    var multiplier = suffixes[suffixMatch[2].toUpperCase()];
  }

  const lastComma = cleaned.lastIndexOf(',');
  const lastDot = cleaned.lastIndexOf('.');

  if (lastComma > lastDot && lastComma > cleaned.length - 4) {
    cleaned = cleaned.replace(/\./g, '').replace(',', '.');
  } else {
    cleaned = cleaned.replace(/,/g, '');
  }

  let num = parseFloat(cleaned);
  if (multiplier) num *= multiplier;

  return isNaN(num) ? value : num;
}
Enter fullscreen mode Exit fullscreen mode

これで約90%のケースに対応できます。残りの10%はきっと驚かされるでしょう。

問題5:文字エンコーディング地獄

UTF-8で解決したと思いましたか?そうはいきません。

実際のテーブルには以下が含まれます:

  • ノーブレークスペース(&nbsp; / \u00A0)—見た目はスペースだが、スペースではない
  • ゼロ幅文字 — 文字列比較を壊す
  • Windows-1252文字がUTF-8に化けたもの
  • 古いパーサーを壊す絵文字
  • 多言語テーブルの右から左への制御文字

解決策:すべてを正規化

function normalizeText(text) {
  return text
    .normalize('NFC')
    .replace(/\u00A0/g, ' ')
    .replace(/[\u200B-\u200D\uFEFF]/g, '')
    .replace(/\s+/g, ' ')
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

Excel用にCSVをエクスポートする際は、UTF-8 BOMを先頭に付加します:

const BOM = '\uFEFF';
const csvContent = BOM + generateCSV(data);
Enter fullscreen mode Exit fullscreen mode

BOMがないと、ExcelはUTF-8ファイルをWindows-1252と解釈し、特殊文字を破壊する可能性があります。

問題6:ネストされたテーブル

はい、テーブルの中にテーブル。通常はレイアウト用ですが、データ用の場合もあります:

<table>
  <tr>
    <td>商品A</td>
    <td>
      <table>
        <tr><td>Sサイズ</td><td>¥1,000</td></tr>
        <tr><td>Mサイズ</td><td>¥1,200</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

解決策:戦略を決める

選択肢:

  1. フラット化:ネストテーブルをテキストに変換("Sサイズ: ¥1,000, Mサイズ: ¥1,200")
  2. 個別抽出:ネストテーブルを別のエクスポートとして扱う
  3. 行の展開:ネストされた行ごとに親行を複数作成

私は選択肢2(個別抽出)を採用し、深くネストされたケースには選択肢1をフォールバックとしました。完璧な答えはありません。ユースケース次第です。

現実

これらすべてのケースに対処した結果、テーブルパーサーは約800行のJavaScriptになりました。それでもすべてを完璧に処理できるわけではありません。

いくつかの厳しい現実:

  • 完璧なパーサーは存在しない。 実世界のHTMLは乱雑です。
  • ヒューリスティクスは失敗する。 ユーザー向けのエスケープハッチが常に必要です。
  • パフォーマンスは重要。 50以上のテーブルがあるページもあります。パースは高速でなければなりません。
  • エッジケースは無限。 95%のケースで動くものをリリースし、その後改善を繰り返しましょう。

ツールとリソース

同様のものを開発している方へ:

  • SheetJS (xlsx) — Excelファイル生成のための堅実なライブラリ
  • Papa Parse — 高速なCSVパースと生成
  • Chrome DevTools — コンソールで $('table') を使ってテーブルを素早くインスペクト

Chrome拡張機能トップ5の比較は、HTMLテーブルスクレイパー比較ガイドもご参照ください。

あるいは、何も構築せずにテーブルをエクスポートしたいだけなら:使い捨てスクレイパーを書くのに疲れたので HTML Table Exporter を作りました。上記のエッジケースすべてに対応しています。

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


あなたが遭遇した奇妙なテーブルのエッジケースは何ですか? パーサーを壊す新しいテストケースを常に探しています。

Top comments (0)