DEV Community

Cover image for Cómo Manejo Rowspans y Colspans en la Extracción de Tablas
circobit
circobit

Posted on

Cómo Manejo Rowspans y Colspans en la Extracción de Tablas

Las tablas con celdas combinadas están en todos lados. Wikipedia las usa. Los sitios de estadísticas deportivas las usan. Los reportes financieros las usan.

Y rompen casi todos los scrapers de tablas que vi.

Este es el problema: cuando iteras elementos <tr> y <td>, obtienes lo que está en el HTML — no lo que se renderiza visualmente. Una celda con rowspan="3" aparece una vez en el HTML pero ocupa tres filas en la salida.

El enfoque ingenuo falla silenciosamente. Obtienes columnas desalineadas, datos faltantes y exportaciones que no se parecen en nada a la tabla original.

Aquí te muestro cómo solucionarlo.

El Problema: HTML No Coincide con la Grilla

Considerá esta tabla simple con una celda combinada:

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

Representación visual:

| A | B |
|   | C |
Enter fullscreen mode Exit fullscreen mode

Pero si extraés celdas ingenuamente iterando filas:

// Enfoque incorrecto
table.querySelectorAll('tr').forEach(row => {
  const cells = row.querySelectorAll('td');
  console.log(cells.map(c => c.textContent));
});
// Resultado: ["A", "B"], ["C"]
// Esperado: ["A", "B"], ["A", "C"]
Enter fullscreen mode Exit fullscreen mode

La fila 2 pierde la "A" que visualmente se extiende hacia ella.

La Solución: Construir una Matriz

El enfoque correcto es pre-asignar una grilla 2D y llenarla según los atributos de span.

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

  rows.forEach((rowEl, rowIndex) => {
    if (!grid[rowIndex]) grid[rowIndex] = [];
    const gridRow = grid[rowIndex];

    let colIndex = 0;

    // Saltar columnas ya ocupadas por rowspans de filas anteriores
    while (gridRow[colIndex] !== undefined) {
      colIndex++;
    }

    const cells = Array.from(rowEl.cells);

    cells.forEach((cell) => {
      // Avanzar pasando celdas ocupadas
      while (gridRow[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 que esta celda abarca
      for (let r = 0; r < rowSpan; r++) {
        const targetRowIndex = rowIndex + r;
        if (!grid[targetRowIndex]) grid[targetRowIndex] = [];

        for (let c = 0; c < colSpan; c++) {
          const targetColIndex = colIndex + c;
          if (grid[targetRowIndex][targetColIndex] === undefined) {
            grid[targetRowIndex][targetColIndex] = text;
          }
        }
      }

      colIndex += colSpan;
    });
  });

  return grid;
}
Enter fullscreen mode Exit fullscreen mode

Ahora la salida coincide con la tabla visual:

const matrix = extractTableMatrix(table);
// [["A", "B"], ["A", "C"]]
Enter fullscreen mode Exit fullscreen mode

Por Qué Importa: Ejemplos Reales

Tablas de Países en Wikipedia

Wikipedia usa celdas combinadas extensamente. Una tabla listando países por región podría tener:

<tr>
  <td rowspan="5">Europa</td>
  <td>Francia</td>
</tr>
<tr>
  <td>Alemania</td>
</tr>
<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

Sin manejo adecuado, pierdes la asociación "Europa" para las filas 2-5.

Sitios de Estadísticas Deportivas

Sitios como FBREF usan tanto rowspan COMO colspan. Las tablas de estadísticas de jugadores tienen:

  • Encabezados agrupados con colspan (Tiempo de Juego → PJ | Titular | Min)
  • Filas de resumen con rowspan abarcando detalles de jugadores

Un scraper ingenuo exporta basura. Un enfoque basado en matriz preserva la estructura.

Reportes Financieros

Los reportes trimestrales suelen tener:

<td colspan="4">2024</td>
<!-- Luego: Q1, Q2, Q3, Q4 -->
Enter fullscreen mode Exit fullscreen mode

El encabezado del año necesita propagarse a las cuatro columnas de trimestres.

Casos Extremos a Manejar

Celdas Vacías en el Medio

A veces las celdas están genuinamente vacías. No confundas esto con "aún no llenada por un span".

// Normalizar al mismo número de columnas
const maxCols = grid.reduce((max, row) => 
  Math.max(max, row ? row.length : 0), 0
);

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

Tablas Anidadas

Las tablas dentro de celdas generan ruido. La mayoría de las veces, quieres el contenido de texto de la tabla anidada, no una extracción recursiva.

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

  // Remover tablas anidadas, scripts, estilos
  clone.querySelectorAll("table, script, style, noscript")
    .forEach(el => el.remove());

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

Colspan + Rowspan Combinados

El algoritmo maneja esto naturalmente — solo estás llenando un rectángulo más grande en la grilla.

<td rowspan="2" colspan="3">Celda Grande</td>
Enter fullscreen mode Exit fullscreen mode

Llena un bloque de 2×3 con el mismo valor.

Cuándo Usar una Herramienta

Escribir esto desde cero es educativo pero consume tiempo. Para uso en producción, considerá:

  • HTML Table Exporter — Extensión de navegador que maneja estos casos extremos automáticamente
  • Pandas read_html() — Funciona pero tiene problemas con tablas anidadas complejas
  • Beautiful Soup — Requiere manejo manual de spans

El Algoritmo Completo

Aquí está la versión de producción con todos los casos extremos manejados:

function extractCellText(cell) {
  if (!cell) return "";
  const clone = cell.cloneNode(true);

  // Remover elementos que contienen contenido no visible
  const invisibleSelectors = "style, script, noscript, template, link";
  clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());

  const text = clone.textContent || "";
  return text.replace(/\s+/g, " ").trim();
}

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

  rows.forEach((rowEl, rowIndex) => {
    if (!grid[rowIndex]) grid[rowIndex] = [];
    const gridRow = grid[rowIndex];

    let colIndex = 0;

    while (gridRow[colIndex] !== undefined) {
      colIndex++;
    }

    const cells = Array.from(rowEl.cells);

    cells.forEach((cell) => {
      while (gridRow[colIndex] !== undefined) {
        colIndex++;
      }

      const text = extractCellText(cell);
      const rowSpan = parseInt(cell.rowSpan, 10) || 1;
      const colSpan = parseInt(cell.colSpan, 10) || 1;

      for (let r = 0; r < rowSpan; r++) {
        const targetRowIndex = rowIndex + r;
        if (!grid[targetRowIndex]) grid[targetRowIndex] = [];
        const targetRow = grid[targetRowIndex];

        for (let c = 0; c < colSpan; c++) {
          const targetColIndex = colIndex + c;
          if (targetRow[targetColIndex] === undefined) {
            targetRow[targetColIndex] = text;
          }
        }
      }

      colIndex += colSpan;
    });
  });

  // Normalizar todas las filas al mismo largo
  const maxCols = grid.reduce((max, row) => 
    Math.max(max, row ? row.length : 0), 0
  );

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

Resumen

Enfoque Maneja Spans Listo para Producción
Iteración ingenua
Matriz con spans
Pandas read_html Parcial Depende
Extensión de navegador

La idea clave: pensá en las tablas como grillas, no como elementos HTML anidados. Construí la grilla primero, después exportá.

Para más sobre exportar las tablas particularmente complicadas de Wikipedia, consulta nuestra guía sobre exportar tablas de Wikipedia a Excel.


¿Necesitas extraer tablas sin escribir código? Conoce más en gauchogrid.com/es/html-table-exporter o pruébalo gratis en la Chrome Web Store.

Top comments (0)