Las tablas HTML parecen simples. <table>, <tr>, <td>. ¿Qué puede salir mal?
Después de construir HTML Table Exporter, una herramienta de exportación de tablas que ha procesado miles de tablas del mundo real, te puedo decir: mucho. Este post cubre los casos de borde que rompen parsers ingenuos y cómo manejarlos.
El Caso Engañosamente Simple
Una tabla perfecta se ve así:
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Ingresos</th>
</tr>
</thead>
<tbody>
<tr>
<td>Acme Inc</td>
<td>$1.2M</td>
</tr>
</tbody>
</table>
Parsear esto es trivial:
const rows = table.querySelectorAll('tr');
const data = [...rows].map(row =>
[...row.querySelectorAll('td, th')].map(cell => cell.textContent.trim())
);
¿Listo, no? Ni cerca.
Problema 1: Celdas Combinadas (colspan/rowspan)
Las tablas reales tienen celdas combinadas. Muchas.
<tr>
<td rowspan="3">Q1 2024</td>
<td>Enero</td>
<td>$100k</td>
</tr>
<tr>
<td>Febrero</td>
<td>$120k</td>
</tr>
<tr>
<td>Marzo</td>
<td>$90k</td>
</tr>
Si parseas esto de forma ingenua, obtienes:
Fila 1: ["Q1 2024", "Enero", "$100k"]
Fila 2: ["Febrero", "$120k"] // ¡Falta la primera columna!
Fila 3: ["Marzo", "$90k"] // ¡Falta la primera columna!
La Solución: Construir una Matriz de Posiciones
Necesitas rastrear qué celdas están "ocupadas" por rowspans de filas anteriores:
function parseTableWithMergedCells(table) {
const rows = table.querySelectorAll('tr');
const matrix = [];
const rowspanTracker = []; // Rastrear rowspans activos por columna
rows.forEach((row, rowIndex) => {
matrix[rowIndex] = [];
let colIndex = 0;
// Saltar columnas ocupadas por rowspans anteriores
while (rowspanTracker[colIndex] > 0) {
matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
rowspanTracker[colIndex]--;
colIndex++;
}
row.querySelectorAll('td, th').forEach(cell => {
// Saltar columnas ocupadas
while (rowspanTracker[colIndex] > 0) {
matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
rowspanTracker[colIndex]--;
colIndex++;
}
const colspan = parseInt(cell.getAttribute('colspan')) || 1;
const rowspan = parseInt(cell.getAttribute('rowspan')) || 1;
const value = cell.textContent.trim();
// Llenar colspan
for (let c = 0; c < colspan; c++) {
matrix[rowIndex][colIndex] = value;
// Rastrear rowspan para filas futuras
if (rowspan > 1) {
rowspanTracker[colIndex] = rowspan - 1;
}
colIndex++;
}
});
});
return matrix;
}
Esto está simplificado—la implementación real necesita manejar rowspans anidados dentro de colspans, lo cual se pone feo rápido.
Problema 2: Tablas Que No Son Tablas de Datos
No todo <table> contiene datos. Muchos sitios (sí, todavía en 2024) usan tablas para layout:
<table>
<tr>
<td><nav>Menú acá</nav></td>
<td><main>Contenido acá</main></td>
</tr>
</table>
O para formularios:
<table>
<tr>
<td><label>Email:</label></td>
<td><input type="email"></td>
</tr>
</table>
La Solución: Heurísticas
Uso varias señales para detectar tablas de datos "reales":
function isDataTable(table) {
const rows = table.querySelectorAll('tr');
const cells = table.querySelectorAll('td, th');
// Muy pocas filas o celdas
if (rows.length < 2 || cells.length < 4) return false;
// Contiene elementos de formulario (probablemente layout de form)
if (table.querySelector('input, select, textarea, button')) return false;
// Mayormente links de navegación
const links = table.querySelectorAll('a');
const textContent = table.textContent.length;
const linkText = [...links].reduce((sum, a) => sum + a.textContent.length, 0);
if (linkText / textContent > 0.7) return false;
// Verificar consistencia de columnas
const colCounts = [...rows].map(row =>
row.querySelectorAll('td, th').length
);
const variance = Math.max(...colCounts) - Math.min(...colCounts);
if (variance > 3) return false; // Columnas inconsistentes = probablemente layout
return true;
}
Ninguna de estas es perfecta. Siempre vas a tener casos de borde.
Problema 3: Contenido Oculto
Las celdas suelen contener más que texto visible:
<td>
<span class="value">1.234</span>
<span class="sort-key" style="display:none">1234</span>
</td>
Wikipedia hace esto mucho para tablas ordenables. Si simplemente tomas textContent, obtienes "1.234 1234".
La Solución: Extraer Solo Texto Visible
function getVisibleText(element) {
// Clonar para no modificar el original
const clone = element.cloneNode(true);
// Remover elementos ocultos
clone.querySelectorAll('[style*="display: none"], [style*="display:none"], .hidden, [hidden]').forEach(el => el.remove());
// También verificar computed style para elementos ocultos dinámicamente
// (más costoso, usar con moderación)
return clone.textContent.trim();
}
Problema 4: Números Que No Son Números
"$1,234.56" es un número. También lo es "1.234,56" (formato europeo/latinoamericano). Y "(1,234)" (negativo contable). Y "1,234 M" (con sufijo).
Tu hoja de cálculo necesita números reales para hacer matemáticas.
La Solución: Parsing Sensible al Locale
function parseNumber(value) {
if (!value || typeof value !== 'string') return value;
// Remover símbolos de moneda y espacios
let cleaned = value.replace(/[$€£¥₹\s]/g, '').trim();
// Manejar negativos contables: (1,234) -> -1234
if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
cleaned = '-' + cleaned.slice(1, -1);
}
// Manejar sufijos: 1.5M, 2.3B, 100K
const suffixes = { 'K': 1e3, 'M': 1e6, 'B': 1e9, 'T': 1e12 };
const suffixMatch = cleaned.match(/([0-9.,]+)\s*([KMBT])$/i);
if (suffixMatch) {
cleaned = suffixMatch[1];
var multiplier = suffixes[suffixMatch[2].toUpperCase()];
}
// Detectar formato europeo vs US
// Europeo/LATAM: 1.234,56 (punto para miles, coma para decimales)
// US: 1,234.56 (coma para miles, punto para decimales)
const lastComma = cleaned.lastIndexOf(',');
const lastDot = cleaned.lastIndexOf('.');
if (lastComma > lastDot && lastComma > cleaned.length - 4) {
// Formato europeo/LATAM
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
} else {
// Formato US
cleaned = cleaned.replace(/,/g, '');
}
let num = parseFloat(cleaned);
if (multiplier) num *= multiplier;
return isNaN(num) ? value : num;
}
Esto maneja quizás el 90% de los casos. El otro 10% te va a sorprender.
Problema 5: Infierno de Codificación de Caracteres
Pensarías que UTF-8 resolvió esto. No fue así.
Las tablas reales contienen:
- Espacios no-rompibles (
/\u00A0) que parecen espacios pero no lo son - Caracteres de ancho cero que rompen comparaciones de strings
- Caracteres Windows-1252 que se manglearon al convertir a UTF-8
- Emoji que rompen parsers viejos
- Marcas right-to-left en tablas de idiomas mixtos
La Solución: Normalizar Todo
function normalizeText(text) {
return text
// Normalizar unicode (maneja caracteres compuestos vs descompuestos)
.normalize('NFC')
// Reemplazar espacios no-rompibles con espacios normales
.replace(/\u00A0/g, ' ')
// Remover caracteres de ancho cero
.replace(/[\u200B-\u200D\uFEFF]/g, '')
// Normalizar espacios en blanco
.replace(/\s+/g, ' ')
.trim();
}
Y al exportar a CSV para Excel, anteponer el BOM de UTF-8:
const BOM = '\uFEFF';
const csvContent = BOM + generateCSV(data);
Sin el BOM, Excel puede interpretar tu archivo UTF-8 como Windows-1252 y destrozar los caracteres especiales.
Problema 6: Tablas Anidadas
Sí, tablas dentro de tablas. Generalmente para layout, pero a veces para datos:
<table>
<tr>
<td>Producto A</td>
<td>
<table>
<tr><td>Talle S</td><td>$10</td></tr>
<tr><td>Talle M</td><td>$12</td></tr>
</table>
</td>
</tr>
</table>
La Solución: Decidir Tu Estrategia
Opciones:
- Aplanar: Convertir tabla anidada a texto ("Talle S: $10, Talle M: $12")
- Extraer por separado: Tratar tablas anidadas como exportaciones separadas
- Expandir filas: Crear múltiples filas padre, una por fila anidada
Yo fui con la opción 2 (extraer por separado) con la opción 1 como fallback para casos profundamente anidados. No hay respuesta perfecta—depende del caso de uso.
La Realidad
Después de manejar todos estos casos, mi parser de tablas tiene ~800 líneas de JavaScript. Y todavía no maneja todo perfectamente.
Algunas verdades duras:
- Ningún parser es perfecto. El HTML del mundo real es desordenado.
- Las heurísticas fallan. Siempre vas a necesitar vías de escape para los usuarios.
- El rendimiento importa. Algunas páginas tienen 50+ tablas. El parsing necesita ser rápido.
- Los casos de borde son infinitos. Lanza algo que funcione para el 95% de los casos, después itera.
Herramientas y Recursos
Si estás construyendo algo similar:
- SheetJS (xlsx) — Librería sólida para generar archivos Excel
- Papa Parse — Parsing y generación de CSV rápido
-
Chrome DevTools —
$('table')en consola para inspeccionar tablas rápidamente
O si solo necesitás exportar tablas sin construir nada: hice HTML Table Exporter específicamente porque me cansé de escribir scrapers descartables. Maneja todos los casos de borde mencionados arriba.
Para una comparación detallada de herramientas similares, mira nuestra guía de las mejores extensiones de Chrome para copiar tablas a Excel.
Conoce más en gauchogrid.com/es/html-table-exporter o pruébala gratis en la Chrome Web Store.
¿Qué casos de borde raros de tablas encontraste? Siempre estoy buscando nuevos test cases para romper mi parser.
Top comments (0)