DEV Community

Cover image for La Complejidad Oculta de las Tablas HTML
circobit
circobit

Posted on

La Complejidad Oculta de las Tablas HTML

Las tablas HTML parecen simples. Filas y celdas. ¿Qué puede salir mal?

Todo, resulta ser.

Pasé meses construyendo HTML Table Exporter, una herramienta de extracción de tablas, y aprendí que las tablas HTML son engañosamente complejas. Aquí va lo que nadie te dice cuando empiezas a parsearlas.

El Enfoque Ingenuo (Que Se Rompe Inmediatamente)

Tu primer intento probablemente se vea 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

Limpio, simple, incorrecto.

Funciona para tablas básicas. Pero la web real está llena de tablas que van a romper este código de formas creativas.

Problema 1: Rowspan y Colspan

Las celdas HTML pueden abarcar múltiples filas o columnas:

<table>
  <tr>
    <td rowspan="3">Categoría A</td>
    <td>Ítem 1</td>
    <td>$10</td>
  </tr>
  <tr>
    <td>Ítem 2</td>
    <td>$20</td>
  </tr>
  <tr>
    <td>Ítem 3</td>
    <td>$30</td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

El enfoque ingenuo te da:

Fila 0: ["Categoría A", "Ítem 1", "$10"]
Fila 1: ["Ítem 2", "$20"]           // ¡Solo 2 celdas!
Fila 2: ["Ítem 3", "$30"]           // ¡Solo 2 celdas!
Enter fullscreen mode Exit fullscreen mode

Las filas 1 y 2 les falta una columna. Tu alineación de columnas ahora está rota.

La Solución: Construir una Grilla Virtual

Necesitas rastrear qué celdas están "ocupadas" por spans de filas anteriores:

function extractTableMatrix(table) {
  const rows = Array.from(table.rows);
  const grid = [];

  rows.forEach((rowEl, rowIndex) => {
    if (!grid[rowIndex]) grid[rowIndex] = [];
    let colIndex = 0;

    // Saltar columnas ya ocupadas por rowspans
    while (grid[rowIndex][colIndex] !== undefined) {
      colIndex++;
    }

    Array.from(rowEl.cells).forEach((cell) => {
      // Seguir avanzando pasando columnas ocupadas
      while (grid[rowIndex][colIndex] !== undefined) {
        colIndex++;
      }

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

      // Llenar toda la región del span
      for (let r = 0; r < rowSpan; r++) {
        for (let c = 0; c < colSpan; c++) {
          const targetRow = rowIndex + r;
          const targetCol = colIndex + c;

          if (!grid[targetRow]) grid[targetRow] = [];
          if (grid[targetRow][targetCol] === undefined) {
            grid[targetRow][targetCol] = text;
          }
        }
      }

      colIndex += colSpan;
    });
  });

  return grid;
}
Enter fullscreen mode Exit fullscreen mode

Ahora obtienes:

Fila 0: ["Categoría A", "Ítem 1", "$10"]
Fila 1: ["Categoría A", "Ítem 2", "$20"]
Fila 2: ["Categoría A", "Ítem 3", "$30"]
Enter fullscreen mode Exit fullscreen mode

El valor de la celda que abarca se duplica en cada posición que ocupa.

Problema 2: Tablas Anidadas

Algunos sitios usan tablas dentro de tablas para layout:

<table>
  <tr>
    <td>
      <table>
        <tr><td>Contenido anidado</td></tr>
      </table>
    </td>
    <td>Celda normal</td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Usar cell.textContent captura todo, incluyendo el contenido de la tabla anidada. Usar cell.innerText podría ayudar, pero es inconsistente entre navegadores.

La Solución: Clonar y Remover

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

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

  // Remover contenido invisible y anidado
  const removeSelectors = "style, script, noscript, template, table";
  clone.querySelectorAll(removeSelectors).forEach(el => el.remove());

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

Problema 3: Los Headers No Siempre Están en la Fila 0

Muchas tablas tienen filas de título, filas de navegación, u otro contenido antes de los headers reales:

<table>
  <tr>
    <td colspan="3">Reporte de Ventas Trimestrales 2024</td>
  </tr>
  <tr>
    <th>Región</th>
    <th>Q1</th>
    <th>Q2</th>
  </tr>
  <tr>
    <td>Norte</td>
    <td>$1.2M</td>
    <td>$1.4M</td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Si asumes que la fila 0 son los headers, vas a tener "Reporte de Ventas Trimestrales 2024" como nombre de columna.

La Solución: Detectar la Fila de Headers

function detectHeaderRowIndex(matrix) {
  for (let i = 0; i < Math.min(matrix.length - 1, 3); i++) {
    const row = matrix[i];
    const nextRow = matrix[i + 1];

    // Contar valores únicos
    const uniqueValues = new Set(row.filter(c => c && c.trim()));
    const uniqueNext = new Set(nextRow.filter(c => c && c.trim()));

    // Fila de título: 1 valor único (abarca todas las columnas),
    // la siguiente fila tiene más
    const isTitleRow = 
      uniqueValues.size === 1 && 
      uniqueNext.size > 1 &&
      row[0]?.length > 30;

    if (isTitleRow) {
      return i + 1; // Los headers están en la siguiente fila
    }
  }

  return 0;
}
Enter fullscreen mode Exit fullscreen mode

Problema 4: Celdas Vacías y Columnas Inconsistentes

Algunas filas tienen menos celdas que otras. Algunas celdas están completamente vacías. Algunas tienen solo espacio en blanco o &nbsp;.

// Normalizar todas las filas al mismo largo
function normalizeMatrix(grid) {
  const maxCols = Math.max(...grid.map(row => row.length));

  return grid.map(row => {
    const normalized = new Array(maxCols);
    for (let i = 0; i < maxCols; i++) {
      normalized[i] = row[i] ?? "";
    }
    return normalized;
  });
}
Enter fullscreen mode Exit fullscreen mode

Problema 5: Contenido Invisible

Las tablas pueden contener contenido que está visualmente oculto pero presente en el DOM:

  • Bloques <style> para CSS con scope
  • Etiquetas <script>
  • Elementos con display: none
  • Caracteres de ancho cero
const invisibleSelectors = [
  "style",
  "script", 
  "noscript",
  "template",
  "[hidden]",
  "[style*='display: none']",
  "[style*='display:none']"
].join(", ");

clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());
Enter fullscreen mode Exit fullscreen mode

Problema 6: Codificación de Caracteres

Las tablas de diferentes fuentes usan diferentes codificaciones:

  • &nbsp; (espacio no-rompible)
  • &mdash; y &ndash; (guiones)
  • Comillas tipográficas vs comillas rectas
  • UTF-8 vs ISO-8859-1

Siempre normalizar:

function normalizeText(text) {
  return text
    .replace(/\u00a0/g, " ")           // Espacio no-rompible
    .replace(/[\u2018\u2019]/g, "'")   // Comillas simples tipográficas
    .replace(/[\u201c\u201d]/g, '"')   // Comillas dobles tipográficas
    .replace(/[\u2013\u2014]/g, "-")   // Guiones medio/largo
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

El Pipeline Completo

Después de manejar todos estos casos, mi pipeline de extracción se ve así:

function extractTable(tableElement) {
  // 1. Construir grilla virtual (maneja rowspan/colspan)
  let matrix = extractTableMatrix(tableElement);

  // 2. Normalizar a conteo de columnas consistente
  matrix = normalizeMatrix(matrix);

  // 3. Encontrar la fila de headers real
  const headerIndex = detectHeaderRowIndex(matrix);

  // 4. Remover filas de título
  if (headerIndex > 0) {
    matrix = matrix.slice(headerIndex);
  }

  // 5. Limpiar todo el texto de celdas
  matrix = matrix.map(row => 
    row.map(cell => normalizeText(extractCellText(cell)))
  );

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

Casos de Borde Que Encontré

Después de procesar miles de tablas, estos son algunos casos de borde del mundo real:

Fuente Problema Solución
Wikipedia Filas de navegación "v t e" Detección de patrón + saltar
Wikipedia Columnas duplicadas horizontalmente Detectar headers repetidos, desapilar
FBRef Headers de columna agrupados Fusionar grupo + sub-header
Sitios financieros Números como 1.234.567,89 Normalización sensible al locale
Sitios gubernamentales Tablas dentro de <form> Ignorar wrapper del form

Lecciones Aprendidas

  1. Nunca confíes en la estructura del DOM. Construye tu propia representación normalizada.

  2. Prueba con casos de borde temprano. Wikipedia, FBRef y sitios gubernamentales son excelentes stress tests.

  3. Normaliza todo. Espacios, codificación, conteo de columnas—hazlo consistente.

  4. Los headers necesitan detección. No asumas que la fila 0 es el header.

  5. Los spans son el enemigo. El enfoque de construir una grilla los maneja limpiamente.


Si quieres saltarte toda esta complejidad, HTML Table Exporter maneja estos casos de borde automáticamente. Un clic para exportar cualquier tabla a CSV, JSON o Excel.

Para ver cómo se compara con otras herramientas similares, mira nuestra guía de las mejores extensiones de Chrome para exportar tablas.

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

Pero si estás construyendo tu propio parser, espero que esto te ahorre algo de tiempo depurando. El rabbit hole es profundo.


¿Cuál es la estructura de tabla más rara que encontraste? Compártela en los comentarios. Colecciono casos de borde.

Top comments (0)