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)
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;
});
}
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>
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));
}
¿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();
}
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 |
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
}
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>
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
);
}
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 |
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"]
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;
}
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", ...]
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 |
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;
}
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;
}
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;
}
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
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)