DEV Community

Cover image for HTMLテーブルからSQL INSERT文をワンクリックで生成する方法
circobit
circobit

Posted on

HTMLテーブルからSQL INSERT文をワンクリックで生成する方法

Webサイトでデータを見つけた。データベースに入れたい。通常のワークフロー:CSVにエクスポート → SQLツールにインポート → 型の不一致に対処 → エンコーディング問題を修正。

HTMLテーブルから直接、有効なSQLを生成できたらどうでしょう?

このガイドでは、型推論、識別子のサニタイズ、適切なエスケープ処理を含め、任意のWebテーブルから CREATE TABLEINSERT INTO 文を生成する方法を説明します。これはHTML Table Exporterで使用しているアプローチです。

最終結果

このようなテーブルから:

| Product Name    | Price  | In Stock |
|-----------------|--------|----------|
| Widget Pro      | 29.99  | Yes      |
| Gadget Basic    | 14.50  | No       |
Enter fullscreen mode Exit fullscreen mode

以下を生成:

-- Exported from HTML Table Exporter PRO

CREATE TABLE products (
  product_name TEXT,
  price REAL,
  in_stock TEXT
);

INSERT INTO products (product_name, price, in_stock) VALUES
  ('Widget Pro', 29.99, 'Yes'),
  ('Gadget Basic', 14.50, 'No');
Enter fullscreen mode Exit fullscreen mode

ステップバイステップで構築していきましょう。

ステップ1: テーブルマトリクスの抽出

まず、HTMLテーブルから生データを抽出します:

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 => {
      while (grid[rowIndex][colIndex] !== undefined) colIndex++;

      const text = cell.textContent.trim();
      const rowSpan = parseInt(cell.rowSpan) || 1;
      const colSpan = parseInt(cell.colSpan) || 1;

      for (let r = 0; r < rowSpan; r++) {
        if (!grid[rowIndex + r]) grid[rowIndex + r] = [];
        for (let c = 0; c < colSpan; c++) {
          grid[rowIndex + r][colIndex + c] = text;
        }
      }
      colIndex += colSpan;
    });
  });

  return grid;
}
Enter fullscreen mode Exit fullscreen mode

ステップ2: カラム名のサニタイズ

SQL識別子には厳格なルールがあります。"Product Name" や "Price ($)" のようなヘッダーにはサニタイズが必要です:

function sanitizeSqlIdentifier(header, fallbackIndex) {
  let id = (header || "").toString().trim();

  if (!id) {
    return `col_${fallbackIndex + 1}`;
  }

  // Unicodeを正規化(アクセント除去)
  id = id.normalize("NFD").replace(/[\u0300-\u036f]/g, "");

  // 小文字のsnake_caseに変換
  id = id
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "");

  // 数字で始まらないことを確認
  if (/^\d/.test(id)) {
    id = `col_${id}`;
  }

  if (!id) {
    return `col_${fallbackIndex + 1}`;
  }

  return id;
}

// 例:
// "Product Name" → "product_name"
// "Price ($)"    → "price"
// "2024 Revenue" → "col_2024_revenue"
// ""             → "col_1"
Enter fullscreen mode Exit fullscreen mode

ステップ3: 重複カラム名の処理

テーブルに重複ヘッダーがある場合があります。SQLは一意のカラム名を要求します:

function makeUniqueColumnNames(headers) {
  const used = new Set();

  return headers.map((header, idx) => {
    let name = sanitizeSqlIdentifier(header, idx);
    let candidate = name;
    let counter = 1;

    while (used.has(candidate)) {
      candidate = `${name}_${counter}`;
      counter++;
    }

    used.add(candidate);
    return candidate;
  });
}

// ["Name", "Name", "Value"] → ["name", "name_1", "value"]
Enter fullscreen mode Exit fullscreen mode

ステップ4: SQL型の推論

データを分析して適切なSQL型を決定:

function inferSqlColumnTypes(rows, headerRowIndex = 0) {
  const headerRow = rows[headerRowIndex] || [];
  const dataRows = rows.slice(headerRowIndex + 1);
  const types = new Array(headerRow.length).fill("TEXT");

  for (let col = 0; col < headerRow.length; col++) {
    const values = [];
    for (let r = 0; r < Math.min(dataRows.length, 50); r++) {
      const cell = dataRows[r][col];
      const v = cell != null ? String(cell).trim() : "";
      if (v !== "") values.push(v);
    }

    if (values.length === 0) {
      types[col] = "TEXT";
      continue;
    }

    let allInt = true;
    let allNumeric = true;

    for (const v of values) {
      if (!/^[-+]?\d+$/.test(v)) allInt = false;
      if (!/^[-+]?\d+([.,]\d+)?$/.test(v)) allNumeric = false;
    }

    if (allInt) types[col] = "INTEGER";
    else if (allNumeric) types[col] = "REAL";
    else types[col] = "TEXT";
  }

  return types;
}
Enter fullscreen mode Exit fullscreen mode

型の優先順位: INTEGER > REAL > TEXT

ステップ5: 値の適切なエスケープ

SQLインジェクションはセキュリティの問題だけでなく、データの破損にもつながります。適切なエスケープ:

function sqlEscapeValue(raw, type) {
  if (raw == null) return "NULL";

  const v = String(raw).trim();
  if (v === "") return "NULL";

  if (type === "INTEGER" || type === "REAL") {
    const normalized = v.replace(",", ".");
    const num = Number(normalized);

    if (!Number.isNaN(num) && Number.isFinite(num)) {
      return normalized;
    }
  }

  // TEXT: シングルクォートを二重にしてエスケープ
  const escaped = v.replace(/'/g, "''");
  return `'${escaped}'`;
}

// 例:
// sqlEscapeValue("Hello", "TEXT")       → "'Hello'"
// sqlEscapeValue("It's good", "TEXT")   → "'It''s good'"
// sqlEscapeValue("29.99", "REAL")       → "29.99"
// sqlEscapeValue("", "TEXT")            → "NULL"
// sqlEscapeValue(null, "TEXT")          → "NULL"
Enter fullscreen mode Exit fullscreen mode

ステップ6: SQLの生成

すべてを組み合わせて:

function tableToSql(tableInfo) {
  const rows = tableInfo.rows || [];
  if (rows.length < 2) return "";

  const headerRowIndex = tableInfo.headerRowIndex || 0;
  const headerRow = rows[headerRowIndex];
  const dataRows = rows.slice(headerRowIndex + 1);

  if (!headerRow || headerRow.length === 0) return "";

  const columnNames = makeUniqueColumnNames(headerRow);
  const types = inferSqlColumnTypes(rows, headerRowIndex);

  const rawTableName = tableInfo.name || tableInfo.slug || "table";
  const tableName = sanitizeSqlIdentifier(rawTableName, 0) || "table_export";

  // CREATE TABLE文
  const createLines = columnNames.map((col, i) => 
    `  ${col} ${types[i] || "TEXT"}`
  );
  const createStmt = `CREATE TABLE ${tableName} (\n${createLines.join(",\n")}\n);`;

  // INSERT文
  const insertHeader = `INSERT INTO ${tableName} (${columnNames.join(", ")}) VALUES`;

  const valueLines = dataRows.map(row => {
    const values = columnNames.map((_, i) => {
      const cell = row[i];
      const type = types[i] || "TEXT";
      return sqlEscapeValue(cell, type);
    });
    return `  (${values.join(", ")})`;
  });

  let sql = `-- Exported from HTML Table Exporter\n\n${createStmt}\n\n`;

  if (valueLines.length > 0) {
    sql += `${insertHeader}\n${valueLines.join(",\n")};\n`;
  }

  return sql;
}
Enter fullscreen mode Exit fullscreen mode

エッジケースの処理

NULL値

Webテーブルは欠損データを様々な方法で表現します:

const NULL_PATTERNS = ["N/A", "n/a", "-", "--", "null", "none", "."];

function sqlEscapeValue(raw, type, nullPatterns = NULL_PATTERNS) {
  if (raw == null) return "NULL";

  const v = String(raw).trim();
  if (v === "" || nullPatterns.includes(v.toLowerCase())) {
    return "NULL";
  }
  // ... 残りの関数
}
Enter fullscreen mode Exit fullscreen mode

大規模データセット

数千行のテーブルにはINSERTをバッチ処理:

function tableToSqlBatched(tableInfo, batchSize = 1000) {
  // ... 同じセットアップ ...

  const batches = [];
  for (let i = 0; i < valueLines.length; i += batchSize) {
    const batch = valueLines.slice(i, i + batchSize);
    batches.push(`${insertHeader}\n${batch.join(",\n")};`);
  }

  return `${createStmt}\n\n${batches.join("\n\n")}`;
}
Enter fullscreen mode Exit fullscreen mode

データベース固有の構文

SQLite、PostgreSQL、MySQLにはわずかな構文の違いがあります:

function tableToSql(tableInfo, dialect = "sqlite") {
  const typeMap = {
    sqlite: { INTEGER: "INTEGER", REAL: "REAL", TEXT: "TEXT" },
    postgresql: { INTEGER: "INTEGER", REAL: "NUMERIC", TEXT: "TEXT" },
    mysql: { INTEGER: "INT", REAL: "DECIMAL(10,2)", TEXT: "VARCHAR(255)" }
  };

  const dialectTypes = typeMap[dialect] || typeMap.sqlite;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

いつ使うべきか

適している場合:

  • SQLite/PostgreSQLへの素早いデータインポート
  • テストデータベースのシード
  • 一回限りのデータ移行
  • 例を通じてSQLを学ぶ

不向きな場合:

  • 大規模ETL(専用ツールを使用)
  • 複雑なスキーマ(外部キー、制約)
  • バイナリデータ

コード不要で試す

自分で構築したくない場合、HTML Table Exporter PROがワンクリックでSQLを生成します。無料版はCSV/JSON/Excelにエクスポートでき、PROではSQL、NDJSON、データクリーニングが追加されます。

エクスポートアプローチの比較については、テーブルエクスポートに最適なChrome拡張機能 トップ5のガイドをご覧ください。

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


Webデータをどのデータベースにインポートしていますか?ユースケースに興味があります。

Top comments (0)