Webサイトでテーブルを見つけました。そのデータをスプレッドシートに取り込みたい。コピー→ペースト→Excelでクリーンアップという方法は一度なら使えます。でも、このデータが毎週必要だったら?50ページ分必要だったら?
このガイドでは、HTMLテーブルをJavaScriptでプログラム的に抽出し、素朴なアプローチを壊すエッジケースに対処し、ツールが実際に受け入れるフォーマットにエクスポートする方法を解説します。
素朴なアプローチ(そしてなぜ失敗するか)
最もシンプルな抽出はこんな感じです:
function extractTable(table) {
return Array.from(table.rows).map(row =>
Array.from(row.cells).map(cell => cell.textContent.trim())
);
}
これはシンプルなテーブルなら動きます。以下に遭遇すると即座に壊れます:
- 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;
}
これにより、以下のテーブル:
<table>
<tr><td rowspan="2">A</td><td>B</td></tr>
<tr><td>C</td></tr>
</table>
が正しく以下のようになります:
[
["A", "B"],
["A", "C"] // "A" が両方の行に表示される
]
クリーンなテキスト抽出
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();
}
ネストテーブルの検出
テーブルのセル内に別のテーブルが含まれている場合、通常は外側のテーブルのデータが欲しいのであって、再帰的な混乱ではありません。
検出は簡単です:
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));
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");
}
これにより、悪夢のようなケースも正しく処理されます:
toCSV([['Say "Hello, World"', "Normal"]])
// '"Say ""Hello, World""",Normal'
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, "");
}
入力:
| 商品名 | 価格 (¥) |
|---------|---------|
| ウィジェット | 2,999 |
出力:
[
{
"商品名": "ウィジェット",
"価格": "2,999"
}
]
ダウンロードのトリガー
ブラウザコンテキストでは、サーバーなしでダウンロードをトリガーできます:
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");
すべてを組み合わせる
任意のページの最初のテーブルをエクスポートする最小限のブックマークレット:
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();
})();
ブラウザ拡張機能を使うべきタイミング
このコードは動きますが、異なるサイト間でメンテナンスするのは面倒です。テーブルを定期的に抽出するなら、ブラウザ拡張機能が以下を処理してくれます:
- ページあたり複数テーブル
- フォーマット選択(CSV、JSON、Excel)
- データクリーニング(数値正規化、Null処理)
- 列の選択と並び替え
まさにこのワークフローのために HTML Table Exporter を作りました。コアアルゴリズムはここで示したものと同様で、使いやすいUIにパッケージングされています。
詳しくは gauchogrid.com/ja/html-table-exporter をご覧いただくか、Chrome Web Store で無料でお試しください。
テーブル抽出のエッジケースについて質問は?コメントでどうぞ。たぶん経験済みです。
Top comments (0)