DEV Community

Cover image for De Tabla HTML a Sentencias SQL INSERT en Un Clic
circobit
circobit

Posted on

De Tabla HTML a Sentencias SQL INSERT en Un Clic

Encontraste datos en un sitio web. Los necesitas en tu base de datos. El flujo habitual implica: exportar a CSV, importar en una herramienta SQL, lidiar con tipos incompatibles, arreglar problemas de encoding.

¿Y si pudieras ir directamente de tabla HTML a SQL válido?

Esta guía muestra cómo generar sentencias CREATE TABLE e INSERT INTO desde cualquier tabla web, manejando inferencia de tipos, sanitización de identificadores y escaping correcto. Este es el enfoque que uso en HTML Table Exporter.

El Resultado Final

Desde una tabla como esta:

| Nombre Producto | Precio | En Stock |
|-----------------|--------|----------|
| Widget Pro      | 29.99  | Sí       |
| Gadget Basic    | 14.50  | No       |
Enter fullscreen mode Exit fullscreen mode

Generar:

-- Exportado desde HTML Table Exporter PRO

CREATE TABLE productos (
  nombre_producto TEXT,
  precio REAL,
  en_stock TEXT
);

INSERT INTO productos (nombre_producto, precio, en_stock) VALUES
  ('Widget Pro', 29.99, 'Sí'),
  ('Gadget Basic', 14.50, 'No');
Enter fullscreen mode Exit fullscreen mode

Construyamos esto paso a paso.

Paso 1: Extraer la Matriz de la Tabla

Primero, extraer los datos crudos de la tabla 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

Salida: Un array 2D donde rows[0] son encabezados y rows[1+] son datos.

Paso 2: Sanitizar Nombres de Columnas

Los identificadores SQL tienen reglas estrictas. Encabezados como "Nombre Producto" o "Precio ($)" necesitan sanitización:

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

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

  // Normalizar unicode (eliminar acentos)
  id = id.normalize("NFD").replace(/[\u0300-\u036f]/g, "");

  // Convertir a snake_case en minúsculas
  id = id
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "");

  // Asegurar que no empiece con número
  if (/^\d/.test(id)) {
    id = `col_${id}`;
  }

  // Fallback si queda vacío después de sanitizar
  if (!id) {
    return `col_${fallbackIndex + 1}`;
  }

  return id;
}

// Ejemplos:
// "Nombre Producto" → "nombre_producto"
// "Precio ($)"      → "precio"
// "2024 Ingresos"   → "col_2024_ingresos"
// ""                 → "col_1"
Enter fullscreen mode Exit fullscreen mode

Paso 3: Manejar Nombres de Columnas Duplicados

Las tablas a veces tienen encabezados duplicados. SQL requiere nombres de columna únicos:

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;
  });
}

// ["Nombre", "Nombre", "Valor"] → ["nombre", "nombre_1", "valor"]
Enter fullscreen mode Exit fullscreen mode

Paso 4: Inferir Tipos SQL

Analizar datos para determinar los tipos SQL apropiados:

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++) {
    // Muestrear hasta 50 valores por columna
    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;
    }

    // Verificar si todos los valores coinciden con un tipo
    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

Prioridad de tipos: INTEGER > REAL > TEXT

Si el 100% de los valores no vacíos son enteros, usar INTEGER. Si son todos numéricos (incluyendo decimales), usar REAL. Si no, TEXT.

Paso 5: Escapar Valores Correctamente

La inyección SQL no es solo una preocupación de seguridad—corrompe tus datos. Escaping correcto:

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

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

  // Tipos numéricos
  if (type === "INTEGER" || type === "REAL") {
    // Normalizar separador decimal
    const normalized = v.replace(",", ".");
    const num = Number(normalized);

    if (!Number.isNaN(num) && Number.isFinite(num)) {
      return normalized;
    }
    // Caer al caso TEXT si no es un número válido
  }

  // TEXT: escapar comillas simples duplicándolas
  const escaped = v.replace(/'/g, "''");
  return `'${escaped}'`;
}

// Ejemplos:
// sqlEscapeValue("Hola", "TEXT")          → "'Hola'"
// sqlEscapeValue("Es bueno", "TEXT")      → "'Es bueno'"
// 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

Paso 6: Generar el SQL

Juntando todo:

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 "";

  // Generar nombres de columnas
  const columnNames = makeUniqueColumnNames(headerRow);

  // Inferir tipos
  const types = inferSqlColumnTypes(rows, headerRowIndex);

  // Generar nombre de tabla
  const rawTableName = tableInfo.name || tableInfo.slug || "table";
  const tableName = sanitizeSqlIdentifier(rawTableName, 0) || "table_export";

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

  // Sentencias 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(", ")})`;
  });

  // Combinar
  let sql = `-- Exportado desde 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

Ejemplo Completo

Tabla de entrada:

const tableInfo = {
  name: "Ventas Q1",
  rows: [
    ["Producto", "Unidades Vendidas", "Ingresos ($)", "Rentable"],
    ["Widget A", "1,234", "45,678.90", ""],
    ["Widget B", "567", "12,345.67", "No"],
    ["Gadget X", "890", "23,456.78", ""]
  ]
};
Enter fullscreen mode Exit fullscreen mode

Salida:

-- Exportado desde HTML Table Exporter

CREATE TABLE ventas_q1 (
  producto TEXT,
  unidades_vendidas INTEGER,
  ingresos REAL,
  rentable TEXT
);

INSERT INTO ventas_q1 (producto, unidades_vendidas, ingresos, rentable) VALUES
  ('Widget A', 1234, 45678.90, 'Sí'),
  ('Widget B', 567, 12345.67, 'No'),
  ('Gadget X', 890, 23456.78, 'Sí');
Enter fullscreen mode Exit fullscreen mode

Nota:

  • Nombres de columnas sanitizados (Ingresos ($)ingresos)
  • Números detectados y sin comillas
  • Comas en números manejadas
  • Nombre de tabla desde metadatos

Manejo de Casos Extremos

Valores NULL

Muchas tablas web representan datos faltantes de forma diferente:

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";
  }

  // ... resto de la función
}
Enter fullscreen mode Exit fullscreen mode

Datasets Grandes

Para tablas con miles de filas, dividir los INSERTs en lotes:

function tableToSqlBatched(tableInfo, batchSize = 1000) {
  // ... misma configuración ...

  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

Sintaxis Específica por Base de Datos

SQLite, PostgreSQL y MySQL tienen diferencias sutiles de sintaxis:

function tableToSql(tableInfo, dialect = "sqlite") {
  // ... misma lógica de columnas/tipos ...

  // Mapeo de tipos específico por dialecto
  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;

  const createLines = columnNames.map((col, i) => 
    `  ${col} ${dialectTypes[types[i]] || dialectTypes.TEXT}`
  );

  // ... resto de la función ...
}
Enter fullscreen mode Exit fullscreen mode

Bookmarklet de Navegador

Solución rápida para uso ocasional:

javascript:(function(){
  const table = document.querySelector("table");
  if (!table) { alert("No se encontró tabla"); return; }

  const rows = Array.from(table.rows).map(r => 
    Array.from(r.cells).map(c => c.textContent.trim())
  );

  const headers = rows[0].map((h, i) => 
    (h || `col_${i+1}`).toLowerCase().replace(/[^a-z0-9]+/g, "_")
  );

  const values = rows.slice(1).map(row => 
    "(" + row.map(v => `'${v.replace(/'/g, "''")}'`).join(", ") + ")"
  ).join(",\n");

  const sql = `INSERT INTO table_data (${headers.join(", ")}) VALUES\n${values};`;

  navigator.clipboard.writeText(sql);
  alert("¡SQL copiado al portapapeles!");
})();
Enter fullscreen mode Exit fullscreen mode

Cuándo Usar Esto

Bueno para:

  • Importaciones rápidas de datos a SQLite/PostgreSQL
  • Poblar bases de datos de prueba
  • Migraciones de datos únicas
  • Aprender SQL con ejemplos

No ideal para:

  • ETL a gran escala (usar herramientas dedicadas)
  • Esquemas complejos (foreign keys, constraints)
  • Datos binarios

Para pipelines de datos en producción, las herramientas ETL con validación son mejores. Para "necesito esta tabla en mi base de datos ahora", la generación directa de SQL es rápida.

Pruébalo Sin Código

Si no quieres construir esto tú mismo, HTML Table Exporter PRO genera SQL con un clic. La versión gratuita exporta a CSV/JSON/Excel; PRO agrega SQL, NDJSON y limpieza de datos.

Para una comparación de diferentes enfoques de exportación, mira nuestra guía sobre scraper de tablas HTML para Chrome.

Más información en gauchogrid.com/es/html-table-exporter o pruébalo en la Chrome Web Store.


¿A qué base de datos estás importando datos web? Tengo curiosidad por los casos de uso.

Top comments (0)