DEV Community

Cover image for La Complejidad Oculta de las Tablas HTML (Por Qué Parsearlas Es Más Difícil de lo Que Piensas)
circobit
circobit

Posted on

La Complejidad Oculta de las Tablas HTML (Por Qué Parsearlas Es Más Difícil de lo Que Piensas)

Las tablas HTML parecen simples. <table>, <tr>, <td>. ¿Qué puede salir mal?

Después de construir HTML Table Exporter, una herramienta de exportación de tablas que ha procesado miles de tablas del mundo real, te puedo decir: mucho. Este post cubre los casos de borde que rompen parsers ingenuos y cómo manejarlos.

El Caso Engañosamente Simple

Una tabla perfecta se ve así:

<table>
  <thead>
    <tr>
      <th>Nombre</th>
      <th>Ingresos</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Acme Inc</td>
      <td>$1.2M</td>
    </tr>
  </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

Parsear esto es trivial:

const rows = table.querySelectorAll('tr');
const data = [...rows].map(row => 
  [...row.querySelectorAll('td, th')].map(cell => cell.textContent.trim())
);
Enter fullscreen mode Exit fullscreen mode

¿Listo, no? Ni cerca.

Problema 1: Celdas Combinadas (colspan/rowspan)

Las tablas reales tienen celdas combinadas. Muchas.

<tr>
  <td rowspan="3">Q1 2024</td>
  <td>Enero</td>
  <td>$100k</td>
</tr>
<tr>
  <td>Febrero</td>
  <td>$120k</td>
</tr>
<tr>
  <td>Marzo</td>
  <td>$90k</td>
</tr>
Enter fullscreen mode Exit fullscreen mode

Si parseas esto de forma ingenua, obtienes:

Fila 1: ["Q1 2024", "Enero", "$100k"]
Fila 2: ["Febrero", "$120k"]          // ¡Falta la primera columna!
Fila 3: ["Marzo", "$90k"]             // ¡Falta la primera columna!
Enter fullscreen mode Exit fullscreen mode

La Solución: Construir una Matriz de Posiciones

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

function parseTableWithMergedCells(table) {
  const rows = table.querySelectorAll('tr');
  const matrix = [];
  const rowspanTracker = []; // Rastrear rowspans activos por columna

  rows.forEach((row, rowIndex) => {
    matrix[rowIndex] = [];
    let colIndex = 0;

    // Saltar columnas ocupadas por rowspans anteriores
    while (rowspanTracker[colIndex] > 0) {
      matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
      rowspanTracker[colIndex]--;
      colIndex++;
    }

    row.querySelectorAll('td, th').forEach(cell => {
      // Saltar columnas ocupadas
      while (rowspanTracker[colIndex] > 0) {
        matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
        rowspanTracker[colIndex]--;
        colIndex++;
      }

      const colspan = parseInt(cell.getAttribute('colspan')) || 1;
      const rowspan = parseInt(cell.getAttribute('rowspan')) || 1;
      const value = cell.textContent.trim();

      // Llenar colspan
      for (let c = 0; c < colspan; c++) {
        matrix[rowIndex][colIndex] = value;

        // Rastrear rowspan para filas futuras
        if (rowspan > 1) {
          rowspanTracker[colIndex] = rowspan - 1;
        }
        colIndex++;
      }
    });
  });

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

Esto está simplificado—la implementación real necesita manejar rowspans anidados dentro de colspans, lo cual se pone feo rápido.

Problema 2: Tablas Que No Son Tablas de Datos

No todo <table> contiene datos. Muchos sitios (sí, todavía en 2024) usan tablas para layout:

<table>
  <tr>
    <td><nav>Menú acá</nav></td>
    <td><main>Contenido acá</main></td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

O para formularios:

<table>
  <tr>
    <td><label>Email:</label></td>
    <td><input type="email"></td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

La Solución: Heurísticas

Uso varias señales para detectar tablas de datos "reales":

function isDataTable(table) {
  const rows = table.querySelectorAll('tr');
  const cells = table.querySelectorAll('td, th');

  // Muy pocas filas o celdas
  if (rows.length < 2 || cells.length < 4) return false;

  // Contiene elementos de formulario (probablemente layout de form)
  if (table.querySelector('input, select, textarea, button')) return false;

  // Mayormente links de navegación
  const links = table.querySelectorAll('a');
  const textContent = table.textContent.length;
  const linkText = [...links].reduce((sum, a) => sum + a.textContent.length, 0);
  if (linkText / textContent > 0.7) return false;

  // Verificar consistencia de columnas
  const colCounts = [...rows].map(row => 
    row.querySelectorAll('td, th').length
  );
  const variance = Math.max(...colCounts) - Math.min(...colCounts);
  if (variance > 3) return false; // Columnas inconsistentes = probablemente layout

  return true;
}
Enter fullscreen mode Exit fullscreen mode

Ninguna de estas es perfecta. Siempre vas a tener casos de borde.

Problema 3: Contenido Oculto

Las celdas suelen contener más que texto visible:

<td>
  <span class="value">1.234</span>
  <span class="sort-key" style="display:none">1234</span>
</td>
Enter fullscreen mode Exit fullscreen mode

Wikipedia hace esto mucho para tablas ordenables. Si simplemente tomas textContent, obtienes "1.234 1234".

La Solución: Extraer Solo Texto Visible

function getVisibleText(element) {
  // Clonar para no modificar el original
  const clone = element.cloneNode(true);

  // Remover elementos ocultos
  clone.querySelectorAll('[style*="display: none"], [style*="display:none"], .hidden, [hidden]').forEach(el => el.remove());

  // También verificar computed style para elementos ocultos dinámicamente
  // (más costoso, usar con moderación)

  return clone.textContent.trim();
}
Enter fullscreen mode Exit fullscreen mode

Problema 4: Números Que No Son Números

"$1,234.56" es un número. También lo es "1.234,56" (formato europeo/latinoamericano). Y "(1,234)" (negativo contable). Y "1,234 M" (con sufijo).

Tu hoja de cálculo necesita números reales para hacer matemáticas.

La Solución: Parsing Sensible al Locale

function parseNumber(value) {
  if (!value || typeof value !== 'string') return value;

  // Remover símbolos de moneda y espacios
  let cleaned = value.replace(/[$€£¥₹\s]/g, '').trim();

  // Manejar negativos contables: (1,234) -> -1234
  if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
    cleaned = '-' + cleaned.slice(1, -1);
  }

  // Manejar sufijos: 1.5M, 2.3B, 100K
  const suffixes = { 'K': 1e3, 'M': 1e6, 'B': 1e9, 'T': 1e12 };
  const suffixMatch = cleaned.match(/([0-9.,]+)\s*([KMBT])$/i);
  if (suffixMatch) {
    cleaned = suffixMatch[1];
    var multiplier = suffixes[suffixMatch[2].toUpperCase()];
  }

  // Detectar formato europeo vs US
  // Europeo/LATAM: 1.234,56 (punto para miles, coma para decimales)
  // US: 1,234.56 (coma para miles, punto para decimales)
  const lastComma = cleaned.lastIndexOf(',');
  const lastDot = cleaned.lastIndexOf('.');

  if (lastComma > lastDot && lastComma > cleaned.length - 4) {
    // Formato europeo/LATAM
    cleaned = cleaned.replace(/\./g, '').replace(',', '.');
  } else {
    // Formato US
    cleaned = cleaned.replace(/,/g, '');
  }

  let num = parseFloat(cleaned);
  if (multiplier) num *= multiplier;

  return isNaN(num) ? value : num;
}
Enter fullscreen mode Exit fullscreen mode

Esto maneja quizás el 90% de los casos. El otro 10% te va a sorprender.

Problema 5: Infierno de Codificación de Caracteres

Pensarías que UTF-8 resolvió esto. No fue así.

Las tablas reales contienen:

  • Espacios no-rompibles (&nbsp; / \u00A0) que parecen espacios pero no lo son
  • Caracteres de ancho cero que rompen comparaciones de strings
  • Caracteres Windows-1252 que se manglearon al convertir a UTF-8
  • Emoji que rompen parsers viejos
  • Marcas right-to-left en tablas de idiomas mixtos

La Solución: Normalizar Todo

function normalizeText(text) {
  return text
    // Normalizar unicode (maneja caracteres compuestos vs descompuestos)
    .normalize('NFC')
    // Reemplazar espacios no-rompibles con espacios normales
    .replace(/\u00A0/g, ' ')
    // Remover caracteres de ancho cero
    .replace(/[\u200B-\u200D\uFEFF]/g, '')
    // Normalizar espacios en blanco
    .replace(/\s+/g, ' ')
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

Y al exportar a CSV para Excel, anteponer el BOM de UTF-8:

const BOM = '\uFEFF';
const csvContent = BOM + generateCSV(data);
Enter fullscreen mode Exit fullscreen mode

Sin el BOM, Excel puede interpretar tu archivo UTF-8 como Windows-1252 y destrozar los caracteres especiales.

Problema 6: Tablas Anidadas

Sí, tablas dentro de tablas. Generalmente para layout, pero a veces para datos:

<table>
  <tr>
    <td>Producto A</td>
    <td>
      <table>
        <tr><td>Talle S</td><td>$10</td></tr>
        <tr><td>Talle M</td><td>$12</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

La Solución: Decidir Tu Estrategia

Opciones:

  1. Aplanar: Convertir tabla anidada a texto ("Talle S: $10, Talle M: $12")
  2. Extraer por separado: Tratar tablas anidadas como exportaciones separadas
  3. Expandir filas: Crear múltiples filas padre, una por fila anidada

Yo fui con la opción 2 (extraer por separado) con la opción 1 como fallback para casos profundamente anidados. No hay respuesta perfecta—depende del caso de uso.

La Realidad

Después de manejar todos estos casos, mi parser de tablas tiene ~800 líneas de JavaScript. Y todavía no maneja todo perfectamente.

Algunas verdades duras:

  • Ningún parser es perfecto. El HTML del mundo real es desordenado.
  • Las heurísticas fallan. Siempre vas a necesitar vías de escape para los usuarios.
  • El rendimiento importa. Algunas páginas tienen 50+ tablas. El parsing necesita ser rápido.
  • Los casos de borde son infinitos. Lanza algo que funcione para el 95% de los casos, después itera.

Herramientas y Recursos

Si estás construyendo algo similar:

  • SheetJS (xlsx) — Librería sólida para generar archivos Excel
  • Papa Parse — Parsing y generación de CSV rápido
  • Chrome DevTools$('table') en consola para inspeccionar tablas rápidamente

O si solo necesitás exportar tablas sin construir nada: hice HTML Table Exporter específicamente porque me cansé de escribir scrapers descartables. Maneja todos los casos de borde mencionados arriba.

Para una comparación detallada de herramientas similares, mira nuestra guía de las mejores extensiones de Chrome para copiar tablas a Excel.

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


¿Qué casos de borde raros de tablas encontraste? Siempre estoy buscando nuevos test cases para romper mi parser.

Top comments (0)