Las tablas HTML parecen simples. Filas y celdas. ¿Qué puede salir mal?
Todo, resulta ser.
Pasé meses construyendo HTML Table Exporter, una herramienta de extracción de tablas, y aprendí que las tablas HTML son engañosamente complejas. Aquí va lo que nadie te dice cuando empiezas a parsearlas.
El Enfoque Ingenuo (Que Se Rompe Inmediatamente)
Tu primer intento probablemente se vea así:
function extractTable(table) {
return Array.from(table.rows).map(row =>
Array.from(row.cells).map(cell => cell.textContent.trim())
);
}
Limpio, simple, incorrecto.
Funciona para tablas básicas. Pero la web real está llena de tablas que van a romper este código de formas creativas.
Problema 1: Rowspan y Colspan
Las celdas HTML pueden abarcar múltiples filas o columnas:
<table>
<tr>
<td rowspan="3">Categoría A</td>
<td>Ítem 1</td>
<td>$10</td>
</tr>
<tr>
<td>Ítem 2</td>
<td>$20</td>
</tr>
<tr>
<td>Ítem 3</td>
<td>$30</td>
</tr>
</table>
El enfoque ingenuo te da:
Fila 0: ["Categoría A", "Ítem 1", "$10"]
Fila 1: ["Ítem 2", "$20"] // ¡Solo 2 celdas!
Fila 2: ["Ítem 3", "$30"] // ¡Solo 2 celdas!
Las filas 1 y 2 les falta una columna. Tu alineación de columnas ahora está rota.
La Solución: Construir una Grilla Virtual
Necesitas rastrear qué celdas están "ocupadas" por spans de filas anteriores:
function extractTableMatrix(table) {
const rows = Array.from(table.rows);
const grid = [];
rows.forEach((rowEl, rowIndex) => {
if (!grid[rowIndex]) grid[rowIndex] = [];
let colIndex = 0;
// Saltar columnas ya ocupadas por rowspans
while (grid[rowIndex][colIndex] !== undefined) {
colIndex++;
}
Array.from(rowEl.cells).forEach((cell) => {
// Seguir avanzando pasando columnas ocupadas
while (grid[rowIndex][colIndex] !== undefined) {
colIndex++;
}
const text = cell.textContent.trim();
const rowSpan = cell.rowSpan || 1;
const colSpan = cell.colSpan || 1;
// Llenar toda la región del span
for (let r = 0; r < rowSpan; r++) {
for (let c = 0; c < colSpan; c++) {
const targetRow = rowIndex + r;
const targetCol = colIndex + c;
if (!grid[targetRow]) grid[targetRow] = [];
if (grid[targetRow][targetCol] === undefined) {
grid[targetRow][targetCol] = text;
}
}
}
colIndex += colSpan;
});
});
return grid;
}
Ahora obtienes:
Fila 0: ["Categoría A", "Ítem 1", "$10"]
Fila 1: ["Categoría A", "Ítem 2", "$20"]
Fila 2: ["Categoría A", "Ítem 3", "$30"]
El valor de la celda que abarca se duplica en cada posición que ocupa.
Problema 2: Tablas Anidadas
Algunos sitios usan tablas dentro de tablas para layout:
<table>
<tr>
<td>
<table>
<tr><td>Contenido anidado</td></tr>
</table>
</td>
<td>Celda normal</td>
</tr>
</table>
Usar cell.textContent captura todo, incluyendo el contenido de la tabla anidada. Usar cell.innerText podría ayudar, pero es inconsistente entre navegadores.
La Solución: Clonar y Remover
function extractCellText(cell) {
if (!cell) return "";
// Clonar para evitar modificar el DOM
const clone = cell.cloneNode(true);
// Remover contenido invisible y anidado
const removeSelectors = "style, script, noscript, template, table";
clone.querySelectorAll(removeSelectors).forEach(el => el.remove());
// Normalizar espacios
return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Problema 3: Los Headers No Siempre Están en la Fila 0
Muchas tablas tienen filas de título, filas de navegación, u otro contenido antes de los headers reales:
<table>
<tr>
<td colspan="3">Reporte de Ventas Trimestrales 2024</td>
</tr>
<tr>
<th>Región</th>
<th>Q1</th>
<th>Q2</th>
</tr>
<tr>
<td>Norte</td>
<td>$1.2M</td>
<td>$1.4M</td>
</tr>
</table>
Si asumes que la fila 0 son los headers, vas a tener "Reporte de Ventas Trimestrales 2024" como nombre de columna.
La Solución: Detectar la Fila de Headers
function detectHeaderRowIndex(matrix) {
for (let i = 0; i < Math.min(matrix.length - 1, 3); i++) {
const row = matrix[i];
const nextRow = matrix[i + 1];
// Contar valores únicos
const uniqueValues = new Set(row.filter(c => c && c.trim()));
const uniqueNext = new Set(nextRow.filter(c => c && c.trim()));
// Fila de título: 1 valor único (abarca todas las columnas),
// la siguiente fila tiene más
const isTitleRow =
uniqueValues.size === 1 &&
uniqueNext.size > 1 &&
row[0]?.length > 30;
if (isTitleRow) {
return i + 1; // Los headers están en la siguiente fila
}
}
return 0;
}
Problema 4: Celdas Vacías y Columnas Inconsistentes
Algunas filas tienen menos celdas que otras. Algunas celdas están completamente vacías. Algunas tienen solo espacio en blanco o .
// Normalizar todas las filas al mismo largo
function normalizeMatrix(grid) {
const maxCols = Math.max(...grid.map(row => row.length));
return grid.map(row => {
const normalized = new Array(maxCols);
for (let i = 0; i < maxCols; i++) {
normalized[i] = row[i] ?? "";
}
return normalized;
});
}
Problema 5: Contenido Invisible
Las tablas pueden contener contenido que está visualmente oculto pero presente en el DOM:
- Bloques
<style>para CSS con scope - Etiquetas
<script> - Elementos con
display: none - Caracteres de ancho cero
const invisibleSelectors = [
"style",
"script",
"noscript",
"template",
"[hidden]",
"[style*='display: none']",
"[style*='display:none']"
].join(", ");
clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());
Problema 6: Codificación de Caracteres
Las tablas de diferentes fuentes usan diferentes codificaciones:
-
(espacio no-rompible) -
—y–(guiones) - Comillas tipográficas vs comillas rectas
- UTF-8 vs ISO-8859-1
Siempre normalizar:
function normalizeText(text) {
return text
.replace(/\u00a0/g, " ") // Espacio no-rompible
.replace(/[\u2018\u2019]/g, "'") // Comillas simples tipográficas
.replace(/[\u201c\u201d]/g, '"') // Comillas dobles tipográficas
.replace(/[\u2013\u2014]/g, "-") // Guiones medio/largo
.trim();
}
El Pipeline Completo
Después de manejar todos estos casos, mi pipeline de extracción se ve así:
function extractTable(tableElement) {
// 1. Construir grilla virtual (maneja rowspan/colspan)
let matrix = extractTableMatrix(tableElement);
// 2. Normalizar a conteo de columnas consistente
matrix = normalizeMatrix(matrix);
// 3. Encontrar la fila de headers real
const headerIndex = detectHeaderRowIndex(matrix);
// 4. Remover filas de título
if (headerIndex > 0) {
matrix = matrix.slice(headerIndex);
}
// 5. Limpiar todo el texto de celdas
matrix = matrix.map(row =>
row.map(cell => normalizeText(extractCellText(cell)))
);
return matrix;
}
Casos de Borde Que Encontré
Después de procesar miles de tablas, estos son algunos casos de borde del mundo real:
| Fuente | Problema | Solución |
|---|---|---|
| Wikipedia | Filas de navegación "v t e" | Detección de patrón + saltar |
| Wikipedia | Columnas duplicadas horizontalmente | Detectar headers repetidos, desapilar |
| FBRef | Headers de columna agrupados | Fusionar grupo + sub-header |
| Sitios financieros | Números como 1.234.567,89
|
Normalización sensible al locale |
| Sitios gubernamentales | Tablas dentro de <form>
|
Ignorar wrapper del form |
Lecciones Aprendidas
Nunca confíes en la estructura del DOM. Construye tu propia representación normalizada.
Prueba con casos de borde temprano. Wikipedia, FBRef y sitios gubernamentales son excelentes stress tests.
Normaliza todo. Espacios, codificación, conteo de columnas—hazlo consistente.
Los headers necesitan detección. No asumas que la fila 0 es el header.
Los spans son el enemigo. El enfoque de construir una grilla los maneja limpiamente.
Si quieres saltarte toda esta complejidad, HTML Table Exporter maneja estos casos de borde automáticamente. Un clic para exportar cualquier tabla a CSV, JSON o Excel.
Para ver cómo se compara con otras herramientas similares, mira nuestra guía de las mejores extensiones de Chrome para exportar tablas.
Conoce más en gauchogrid.com/es/html-table-exporter o pruébala gratis en la Chrome Web Store.
Pero si estás construyendo tu propio parser, espero que esto te ahorre algo de tiempo depurando. El rabbit hole es profundo.
¿Cuál es la estructura de tabla más rara que encontraste? Compártela en los comentarios. Colecciono casos de borde.
Top comments (0)