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>
Representación visual:
| A | B |
| | C |
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"]
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;
}
Ahora la salida coincide con la tabla visual:
const matrix = extractTableMatrix(table);
// [["A", "B"], ["A", "C"]]
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>
<!-- ... -->
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 -->
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;
});
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();
}
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>
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;
});
}
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)