DEV Community

Cover image for JavaScriptでWebテーブルエクスポートを自動化する実践ガイド
circobit
circobit

Posted on

JavaScriptでWebテーブルエクスポートを自動化する実践ガイド

Webサイトでテーブルを見つけました。そのデータをスプレッドシートに取り込みたい。コピー→ペースト→Excelでクリーンアップという方法は一度なら使えます。でも、このデータが毎週必要だったら?50ページ分必要だったら?

このガイドでは、HTMLテーブルをJavaScriptでプログラム的に抽出し、素朴なアプローチを壊すエッジケースに対処し、ツールが実際に受け入れるフォーマットにエクスポートする方法を解説します。

素朴なアプローチ(そしてなぜ失敗するか)

最もシンプルな抽出はこんな感じです:

function extractTable(table) {
  return Array.from(table.rows).map(row => 
    Array.from(row.cells).map(cell => cell.textContent.trim())
  );
}
Enter fullscreen mode Exit fullscreen mode

これはシンプルなテーブルなら動きます。以下に遭遇すると即座に壊れます:

  • rowspan/colspan — 複数行・複数列にまたがるセル
  • ネストテーブル — テーブルセル内のテーブル
  • 隠しコンテンツ<style><script>display:none の要素
  • 特殊文字 — セル内の改行、タブ、引用符

それぞれ修正していきましょう。

rowspanとcolspanの処理

セルに rowspan="2" がある場合、現在の行と次の行のスペースを占有します。素朴な抽出では、期待より少ないセルが取得され、列がずれます。

解決策:占有済みの位置を追跡する仮想グリッドを構築します。

function extractTableMatrix(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 => {
      // 前のrowspanで占有済みの列をスキップ
      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++) {
          const targetCol = colIndex + c;
          if (grid[targetRow][targetCol] === undefined) {
            grid[targetRow][targetCol] = text;
          }
        }
      }

      colIndex += colSpan;
    });
  });

  return grid;
}
Enter fullscreen mode Exit fullscreen mode

これにより、以下のテーブル:

<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"],
  ["A", "C"]  // "A" が両方の行に表示される
]
Enter fullscreen mode Exit fullscreen mode

クリーンなテキスト抽出

textContent はすべてを取得します — 一部のページがテーブルセルに挿入する <style> タグのCSSルールや <script> タグのJavaScriptも含めて。

クリーンな抽出にはフィルタリングが必要です:

function extractCellText(cell) {
  if (!cell) return "";

  // DOMを変更しないようにクローン
  const clone = cell.cloneNode(true);

  // 不可視要素を削除
  const invisibleSelectors = "style, script, noscript, template, link";
  clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());

  // 空白を正規化
  return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Enter fullscreen mode Exit fullscreen mode

ネストテーブルの検出

テーブルのセル内に別のテーブルが含まれている場合、通常は外側のテーブルのデータが欲しいのであって、再帰的な混乱ではありません。

検出は簡単です:

function isNestedTable(table, allTables) {
  let parent = table.parentElement;

  while (parent) {
    if (parent.tagName === "TABLE") {
      return true;  // このテーブルは別のテーブルの中にある
    }
    parent = parent.parentElement;
  }

  return false;
}

// ページをスキャンする際のフィルタリング
const allTables = document.querySelectorAll("table");
const topLevelTables = Array.from(allTables)
  .filter(t => !isNestedTable(t, allTables));
Enter fullscreen mode Exit fullscreen mode

CSVへの変換

CSVは以下の処理が必要になるまではシンプルに見えます:

  • 値の中のカンマ
  • 値の中の引用符
  • 値の中の改行

RFC 4180準拠のアプローチ:

function toCSV(rows, delimiter = ",") {
  return rows.map(row =>
    row.map(cell => {
      if (cell == null) cell = "";
      const str = String(cell);

      // デリミタ、引用符、改行を含む場合はクォート
      const needsQuotes = str.includes(delimiter) || /["\r\n]/.test(str);
      const escaped = str.replace(/"/g, '""');

      return needsQuotes ? `"${escaped}"` : escaped;
    }).join(delimiter)
  ).join("\r\n");
}
Enter fullscreen mode Exit fullscreen mode

これにより、悪夢のようなケースも正しく処理されます:

toCSV([['Say "Hello, World"', "Normal"]])
// '"Say ""Hello, World""",Normal'
Enter fullscreen mode Exit fullscreen mode

CSVエクスポートの完全ガイドは、テーブルエクスポートに最適なChrome拡張機能もご参照ください。

JSONへの変換

JSONエクスポートでは、最初の行がキーになります:

function toJSON(rows) {
  if (rows.length < 2) return "[]";

  const headers = rows[0].map((h, i) => sanitizeKey(h, i));
  const dataRows = rows.slice(1);

  const objects = dataRows.map(row => {
    const obj = {};
    headers.forEach((key, i) => {
      obj[key] = row[i] ?? "";
    });
    return obj;
  });

  return JSON.stringify(objects, null, 2);
}

function sanitizeKey(header, index) {
  let key = (header || "").toString().trim();

  if (!key) return `col_${index + 1}`;

  // 小文字のsnake_caseに正規化
  return key
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")  // アクセントを削除
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "");
}
Enter fullscreen mode Exit fullscreen mode

入力:

| 商品名   | 価格 (¥) |
|---------|---------|
| ウィジェット | 2,999   |
Enter fullscreen mode Exit fullscreen mode

出力:

[
  {
    "商品名": "ウィジェット",
    "価格": "2,999"
  }
]
Enter fullscreen mode Exit fullscreen mode

ダウンロードのトリガー

ブラウザコンテキストでは、サーバーなしでダウンロードをトリガーできます:

function downloadFile(content, filename, mimeType) {
  const blob = new Blob([content], { type: mimeType });
  const url = URL.createObjectURL(blob);

  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  link.click();

  URL.revokeObjectURL(url);
}

// 使用例
const csv = toCSV(extractTableMatrix(table));
downloadFile(csv, "data.csv", "text/csv;charset=utf-8");
Enter fullscreen mode Exit fullscreen mode

すべてを組み合わせる

任意のページの最初のテーブルをエクスポートする最小限のブックマークレット:

javascript:(function(){
  const table = document.querySelector("table");
  if (!table) { alert("テーブルが見つかりません"); return; }

  function extractTableMatrix(table) {
    const rows = Array.from(table.rows);
    const grid = [];
    rows.forEach((rowEl, ri) => {
      if (!grid[ri]) grid[ri] = [];
      let ci = 0;
      Array.from(rowEl.cells).forEach(cell => {
        while (grid[ri][ci] !== undefined) ci++;
        const text = cell.textContent.trim();
        const rs = parseInt(cell.rowSpan) || 1;
        const cs = parseInt(cell.colSpan) || 1;
        for (let r = 0; r < rs; r++) {
          if (!grid[ri+r]) grid[ri+r] = [];
          for (let c = 0; c < cs; c++) {
            if (grid[ri+r][ci+c] === undefined) grid[ri+r][ci+c] = text;
          }
        }
        ci += cs;
      });
    });
    return grid;
  }

  const data = extractTableMatrix(table);
  const csv = data.map(row => 
    row.map(c => c.includes(",") ? `"${c}"` : c).join(",")
  ).join("\n");

  const blob = new Blob([csv], {type: "text/csv"});
  const link = document.createElement("a");
  link.href = URL.createObjectURL(blob);
  link.download = "table.csv";
  link.click();
})();
Enter fullscreen mode Exit fullscreen mode

ブラウザ拡張機能を使うべきタイミング

このコードは動きますが、異なるサイト間でメンテナンスするのは面倒です。テーブルを定期的に抽出するなら、ブラウザ拡張機能が以下を処理してくれます:

  • ページあたり複数テーブル
  • フォーマット選択(CSV、JSON、Excel)
  • データクリーニング(数値正規化、Null処理)
  • 列の選択と並び替え

まさにこのワークフローのために HTML Table Exporter を作りました。コアアルゴリズムはここで示したものと同様で、使いやすいUIにパッケージングされています。

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


テーブル抽出のエッジケースについて質問は?コメントでどうぞ。たぶん経験済みです。

Top comments (0)