DEV Community

Cover image for Automatizando Exportaciones de Tablas Web con JavaScript — Guía Práctica
circobit
circobit

Posted on

Automatizando Exportaciones de Tablas Web con JavaScript — Guía Práctica

Encontraste una tabla en un sitio web. Necesitas esos datos en una hoja de cálculo. El camino obvio — copiar, pegar, limpiar en Excel — funciona una vez. Pero ¿qué pasa si necesitas estos datos semanalmente? ¿O de 50 páginas diferentes?

Esta guía te muestra cómo extraer tablas HTML programáticamente con JavaScript, manejar los casos borde que rompen los enfoques ingenuos, y exportar a formatos que tus herramientas realmente aceptan.

El Enfoque Ingenuo (Y Por Qué Falla)

La extracción más simple se ve así:

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

Esto funciona para tablas simples. Se rompe inmediatamente cuando encuentras:

  • Rowspan/colspan — Celdas que abarcan múltiples filas o columnas
  • Tablas anidadas — Tablas dentro de celdas de tabla
  • Contenido oculto — Elementos <style>, <script>, o display:none
  • Caracteres especiales — Saltos de línea, tabs y comillas dentro del contenido de las celdas

Arreglemos cada uno.

Manejando Rowspan y Colspan

Cuando una celda tiene rowspan="2", ocupa espacio en la fila actual Y en la siguiente. Un extractor ingenuo ve menos celdas de las esperadas y desalinea las columnas.

La solución: construir una grilla virtual que rastree las posiciones ocupadas.

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 => {
      // Saltear columnas ya ocupadas por rowspans previos
      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;

      // Llenar el bloque rectangular que esta celda ocupa
      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

Ahora una tabla como esta:

<table>
  <tr><td rowspan="2">A</td><td>B</td></tr>
  <tr><td>C</td></tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Se convierte correctamente en:

[
  ["A", "B"],
  ["A", "C"]  // "A" aparece en ambas filas
]
Enter fullscreen mode Exit fullscreen mode

Extrayendo Texto Limpio

textContent captura todo — incluyendo reglas CSS en etiquetas <style> y JavaScript en etiquetas <script> que algunas páginas inyectan en las celdas de las tablas.

La extracción limpia requiere filtrado:

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

  // Clonar para evitar modificar el DOM
  const clone = cell.cloneNode(true);

  // Eliminar elementos invisibles
  const invisibleSelectors = "style, script, noscript, template, link";
  clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());

  // Normalizar espacios en blanco
  return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Enter fullscreen mode Exit fullscreen mode

Detectando Tablas Anidadas

Cuando una tabla contiene otra tabla dentro de una celda, típicamente quieres los datos de la tabla exterior, no un lío recursivo.

La detección es directa:

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

  while (parent) {
    if (parent.tagName === "TABLE") {
      return true;  // Esta tabla está dentro de otra tabla
    }
    parent = parent.parentElement;
  }

  return false;
}

// Filtrar al escanear una página
const allTables = document.querySelectorAll("table");
const topLevelTables = Array.from(allTables)
  .filter(t => !isNestedTable(t, allTables));
Enter fullscreen mode Exit fullscreen mode

Convirtiendo a CSV

CSV parece simple hasta que necesitas manejar:

  • Comas dentro de los valores
  • Comillas dentro de los valores
  • Saltos de línea dentro de los valores

El enfoque compatible con RFC 4180:

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

      // Entrecomillar si contiene delimitador, comillas o saltos de línea
      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

Esto maneja correctamente el caso pesadilla:

toCSV([['Di "Hola, Mundo"', "Normal"]])
// '"Di ""Hola, Mundo""",Normal'
Enter fullscreen mode Exit fullscreen mode

Para una guía completa sobre exportaciones CSV, mira Las 5 Mejores Extensiones de Chrome para Exportar Tablas.

Convirtiendo a JSON

Para exportar a JSON, la primera fila se convierte en claves:

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

  // Normalizar a snake_case en minúsculas
  return key
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")  // Eliminar acentos
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "");
}
Enter fullscreen mode Exit fullscreen mode

Entrada:

| Nombre del Producto | Precio ($) |
|---------------------|------------|
| Widget              | 29.99      |
Enter fullscreen mode Exit fullscreen mode

Salida:

[
  {
    "nombre_del_producto": "Widget",
    "precio": "29.99"
  }
]
Enter fullscreen mode Exit fullscreen mode

Disparando la Descarga

En un contexto de navegador, puedes disparar una descarga sin un servidor:

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

// Uso
const csv = toCSV(extractTableMatrix(table));
downloadFile(csv, "datos.csv", "text/csv;charset=utf-8");
Enter fullscreen mode Exit fullscreen mode

Integrando Todo

Aquí hay un bookmarklet mínimo que exporta la primera tabla de cualquier página:

javascript:(function(){
  const table = document.querySelector("table");
  if (!table) { alert("No se encontró tabla"); 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 = "tabla.csv";
  link.click();
})();
Enter fullscreen mode Exit fullscreen mode

Cuándo Usar una Extensión de Navegador

Este código funciona, pero mantenerlo entre diferentes sitios es tedioso. Si extraes tablas regularmente, una extensión de navegador maneja:

  • Múltiples tablas por página
  • Selección de formato (CSV, JSON, Excel)
  • Limpieza de datos (normalización de números, manejo de nulos)
  • Selección y reordenamiento de columnas

Construí HTML Table Exporter exactamente para este flujo de trabajo. Los algoritmos centrales son similares a lo mostrado aquí, empaquetados en una UI usable.

Más info en gauchogrid.com/es/html-table-exporter o pruébala gratis en la Chrome Web Store.


¿Preguntas sobre casos borde de extracción de tablas? Deja un comentario; probablemente ya lo enfrenté.

Top comments (0)