DEV Community

Cover image for Cómo Manejo Tablas Anidadas y Rowspans (Las Partes Difíciles del Parsing de Tablas HTML)
circobit
circobit

Posted on

Cómo Manejo Tablas Anidadas y Rowspans (Las Partes Difíciles del Parsing de Tablas HTML)

Parsear tablas HTML parece sencillo hasta que encuentras datos del mundo real. Las tablas de Wikipedia tienen filas de navegación. Los sitios financieros usan rowspans complejos. Los sitios de estadísticas deportivas anidan headers de dos niveles.

Después de construir HTML Table Exporter, una herramienta de extracción de tablas usada en miles de sitios diferentes, catalogué los casos borde que rompen la mayoría de los parsers. Así es como manejo cada uno.

Problema 1: Expansión de Rowspan

Una celda con rowspan="3" ocupa espacio vertical en la fila actual y en las dos siguientes. Si iteras por row.cells de forma ingenua, tus columnas se desalinean.

La salida rota:

| País    | 2020 | 2021 | 2022 |    <- Header
| EEUU    | 100  | 200  | 300  |    <- Esperado
| 150     | 250  | 350  |           <- Falta "EEUU" (rowspan continuaba)
Enter fullscreen mode Exit fullscreen mode

La solución: Rastrear posiciones ocupadas en una grilla virtual.

function expandRowspans(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 => {
      // Encontrar la próxima columna no ocupada
      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;

      // Marcar todas las celdas que este elemento abarca
      for (let r = 0; r < rowSpan; r++) {
        const targetRow = rowIndex + r;
        if (!grid[targetRow]) grid[targetRow] = [];

        for (let c = 0; c < colSpan; c++) {
          grid[targetRow][colIndex + c] = text;
        }
      }

      colIndex += colSpan;
    });
  });

  // Normalizar longitudes de filas
  const maxCols = Math.max(...grid.map(r => r.length));
  return grid.map(row => {
    const normalized = new Array(maxCols).fill("");
    row.forEach((val, i) => normalized[i] = val ?? "");
    return normalized;
  });
}
Enter fullscreen mode Exit fullscreen mode

Insight clave: La grilla virtual es la fuente de verdad. Las celdas del DOM son solo instrucciones para poblarla.

Problema 2: Tablas Anidadas

Los infoboxes de Wikipedia a menudo contienen tablas dentro de celdas de tabla. Un enfoque recursivo extrae basura:

<table>
  <tr>
    <td>País</td>
    <td>
      <table>  <!-- ¡Anidada! -->
        <tr><td>Población</td><td>330M</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Estrategia de detección: Verificar si un ancestro de la tabla también es una tabla.

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

  while (parent) {
    if (parent.tagName === "TABLE") {
      return true;
    }
    parent = parent.parentElement;
  }

  return false;
}

// Al escanear una página
function getTopLevelTables() {
  const all = document.querySelectorAll("table");
  return Array.from(all).filter(t => !isNestedTable(t));
}
Enter fullscreen mode Exit fullscreen mode

¿Pero qué pasa con el contenido de la tabla anidada?

Para la tabla exterior, aplano las tablas anidadas a su contenido de texto:

function extractCellText(cell) {
  const clone = cell.cloneNode(true);

  // Eliminar tablas anidadas (su texto ya está incluido vía textContent)
  clone.querySelectorAll("table").forEach(t => t.remove());

  // Eliminar elementos invisibles
  clone.querySelectorAll("style, script").forEach(el => el.remove());

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

Problema 3: Filas de Navegación de Wikipedia

Las tablas de Wikipedia suelen comenzar con una fila de navegación:

| v t e  Lista de países por población |
| Rango | País    | Población |
| 1     | China   | 1.4B      |
Enter fullscreen mode Exit fullscreen mode

Esa fila "v t e" (Ver/Discusión/Editar) no es datos — es UI. Un parser que la trata como fila de encabezado produce basura.

Para una guía práctica sobre el manejo de tablas de Wikipedia, mira La Mejor Extensión de Chrome para Copiar Tablas a Excel.

Detección:

function isWikipediaNavRow(row) {
  const firstCell = row[0] || "";

  // Patrones comunes para filas de navegación
  const patterns = [
    /^v\s+t\s+e\s/i,           // "v t e "
    /^\s*v\s*\|\s*t\s*\|\s*e/i, // "v | t | e"
    /^\[v\]\s*\[t\]\s*\[e\]/i   // "[v] [t] [e]"
  ];

  return patterns.some(p => p.test(firstCell));
}

function detectHeaderRowIndex(matrix) {
  for (let i = 0; i < Math.min(3, matrix.length - 1); i++) {
    if (isWikipediaNavRow(matrix[i])) {
      return i + 1;  // El header es la siguiente fila
    }
  }
  return 0;  // Por defecto: la primera fila es el header
}
Enter fullscreen mode Exit fullscreen mode

Problema 4: Filas de Título (Abarcando Todas las Columnas)

Algunas tablas tienen una fila de título que abarca todo el ancho:

<table>
  <tr><td colspan="4">Ingresos Trimestrales ($ millones)</td></tr>
  <tr><td>T1</td><td>T2</td><td>T3</td><td>T4</td></tr>
  <tr><td>100</td><td>120</td><td>115</td><td>130</td></tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Después de la expansión de rowspan, la primera fila se convierte en ["Ingresos Trimestrales...", "Ingresos Trimestrales...", ...] — el mismo valor repetido.

Detección:

function isTitleRow(row, nextRow) {
  if (!row || !nextRow) return false;

  const uniqueValues = new Set(row.filter(v => v.trim()));
  const nextUniqueValues = new Set(nextRow.filter(v => v.trim()));

  // Características de una fila de título:
  // 1. Solo un valor único (repetido vía colspan)
  // 2. La siguiente fila tiene múltiples valores únicos (headers reales)
  // 3. El valor único es texto largo (>30 caracteres típicamente)

  return (
    uniqueValues.size === 1 &&
    nextUniqueValues.size > 2 &&
    row[0] && row[0].length > 30
  );
}
Enter fullscreen mode Exit fullscreen mode

Problema 5: Headers de Columnas Agrupados (Estilo FBREF)

Sitios de estadísticas deportivas como FBREF usan headers de dos niveles:

|         |        | Tiempo de Juego   | Rendimiento    |
| Jugador | Nación | PJ | Titular | Min | Goles | Asist | xG |
| Haaland | Noruega| 35 | 33      | 2950| 36    | 8     | 32 |
Enter fullscreen mode Exit fullscreen mode

La primera fila contiene nombres de grupo. La segunda contiene nombres reales de columnas. Ambas son "headers".

El desafío: Después de la expansión de colspan, la fila 0 se ve así:

["", "", "Tiempo de Juego", "Tiempo de Juego", "Tiempo de Juego", "Rendimiento", "Rendimiento", "Rendimiento"]
Enter fullscreen mode Exit fullscreen mode

Heurísticas de detección:

function isGroupHeaderRow(row, nextRow) {
  if (!row || !nextRow || row.length !== nextRow.length) return false;

  // Contar cuántas celdas tienen el mismo valor que su vecina
  let repeatCount = 0;
  for (let i = 1; i < row.length; i++) {
    if (row[i] && row[i] === row[i-1]) repeatCount++;
  }

  const repeatRatio = repeatCount / (row.length - 1);

  // Las filas de header agrupado típicamente tienen 40%+ de valores repetidos
  // Y la siguiente fila tiene más valores únicos
  const uniqueInRow = new Set(row.filter(v => v.trim())).size;
  const uniqueInNext = new Set(nextRow.filter(v => v.trim())).size;

  return repeatRatio > 0.4 && uniqueInNext > uniqueInRow;
}
Enter fullscreen mode Exit fullscreen mode

Combinando grupo + sub-headers:

function mergeGroupAndSubHeaders(groupRow, subHeaderRow) {
  return subHeaderRow.map((subHeader, idx) => {
    const group = (groupRow[idx] || "").trim();
    const sub = (subHeader || "").trim();

    if (!group) return sub;
    if (!sub) return group;
    if (sub.toLowerCase() === group.toLowerCase()) return sub;

    return `${group} - ${sub}`;
  });
}

// Resultado: ["Jugador", "Nación", "Tiempo de Juego - PJ", "Tiempo de Juego - Titular", ...]
Enter fullscreen mode Exit fullscreen mode

Problema 6: Tablas Duplicadas Horizontalmente

Las tablas de población de Wikipedia suelen tener esta estructura:

| Rango | Nombre | Pob   | Rango | Nombre  | Pob   |
| 1     | Tokio  | 37M   | 11    | París   | 11M   |
| 2     | Delhi  | 32M   | 12    | Cairo   | 10M   |
Enter fullscreen mode Exit fullscreen mode

Esta es UNA tabla lógica mostrada en dos columnas para ahorrar espacio vertical.

Detección:

function detectHorizontalDuplication(headers) {
  const half = Math.floor(headers.length / 2);
  if (half < 2) return null;

  const firstHalf = headers.slice(0, half);
  const secondHalf = headers.slice(half, half * 2);

  // Verificar si la segunda mitad coincide con la primera
  const matches = firstHalf.every((h, i) => 
    h.toLowerCase() === secondHalf[i]?.toLowerCase()
  );

  if (matches) {
    return { detected: true, repeatCount: 2, baseColumns: half };
  }

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Normalización: Dividir cada fila y apilar verticalmente:

function normalizeHorizontallyDuplicatedTable(matrix, baseColumns) {
  const header = matrix[0].slice(0, baseColumns);
  const normalizedRows = [header];

  for (let i = 1; i < matrix.length; i++) {
    const row = matrix[i];
    // Primera mitad
    normalizedRows.push(row.slice(0, baseColumns));
    // Segunda mitad (si no está vacía)
    const secondHalf = row.slice(baseColumns, baseColumns * 2);
    if (secondHalf.some(cell => cell.trim())) {
      normalizedRows.push(secondHalf);
    }
  }

  return normalizedRows;
}
Enter fullscreen mode Exit fullscreen mode

El Algoritmo Combinado

El parsing del mundo real requiere verificar todos estos casos en secuencia:

function parseTable(table) {
  // 1. Expandir rowspans/colspans a grilla virtual
  let matrix = expandRowspans(table);

  // 2. Detectar y saltar filas de nav/título
  const headerIndex = detectHeaderRowIndex(matrix);
  if (headerIndex > 0) {
    matrix = matrix.slice(headerIndex);
  }

  // 3. Manejar headers agrupados (estilo FBREF)
  const groupedHeaders = detectGroupedColumnHeaders(matrix);
  if (groupedHeaders) {
    const mergedHeaders = mergeGroupAndSubHeaders(matrix[0], matrix[1]);
    matrix = [mergedHeaders, ...matrix.slice(2)];
  }

  // 4. Manejar duplicación horizontal
  const duplication = detectHorizontalDuplication(matrix[0]);
  if (duplication) {
    matrix = normalizeHorizontallyDuplicatedTable(matrix, duplication.baseColumns);
  }

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

Testeando Estos Casos Borde

Cada patrón de arriba vino de un reporte de bug real. Mantengo un suite de tests con fixtures HTML para cada uno:

// Test: Fila de navegación estilo Wikipedia
const navRowHtml = `
  <table>
    <tr><td colspan="3">v t e Países</td></tr>
    <tr><td>Rango</td><td>País</td><td>Pob</td></tr>
    <tr><td>1</td><td>China</td><td>1.4B</td></tr>
  </table>
`;

const result = parseTable(parseHtml(navRowHtml));
assert(result[0][0] === "Rango");  // Header correctamente identificado
assert(result[1][1] === "China");  // Datos correctamente alineados
Enter fullscreen mode Exit fullscreen mode

El suite de tests tiene 24 casos cubriendo combinaciones de estos patrones. Los nuevos reportes de bugs se convierten en nuevos casos de test.

Pruébalo Tú Mismo

Si estás construyendo extracción de tablas, espero que esto te ahorre tiempo de depuración. Si solo necesitas exportar tablas sin escribir código, HTML Table Exporter maneja todos estos casos automáticamente.

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


¿Encontraste una tabla que rompe tu parser? Comparte la URL; colecciono estos casos borde.

Top comments (0)