Encontraste una tabla en un sitio web. Necesitas esos datos en una hoja de cálculo. El camino obvio — copiar, pegar, limpiar en Excel — funciona una vez. Pero ¿qué pasa si necesitas estos datos semanalmente? ¿O de 50 páginas diferentes?
Esta guía te muestra cómo extraer tablas HTML programáticamente con JavaScript, manejar los casos borde que rompen los enfoques ingenuos, y exportar a formatos que tus herramientas realmente aceptan.
El Enfoque Ingenuo (Y Por Qué Falla)
La extracción más simple se ve así:
function extractTable(table) {
return Array.from(table.rows).map(row =>
Array.from(row.cells).map(cell => cell.textContent.trim())
);
}
Esto funciona para tablas simples. Se rompe inmediatamente cuando encuentras:
- Rowspan/colspan — Celdas que abarcan múltiples filas o columnas
- Tablas anidadas — Tablas dentro de celdas de tabla
-
Contenido oculto — Elementos
<style>,<script>, odisplay:none - Caracteres especiales — Saltos de línea, tabs y comillas dentro del contenido de las celdas
Arreglemos cada uno.
Manejando Rowspan y Colspan
Cuando una celda tiene rowspan="2", ocupa espacio en la fila actual Y en la siguiente. Un extractor ingenuo ve menos celdas de las esperadas y desalinea las columnas.
La solución: construir una grilla virtual que rastree las posiciones ocupadas.
function extractTableMatrix(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 => {
// Saltear columnas ya ocupadas por rowspans previos
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;
// Llenar el bloque rectangular que esta celda ocupa
for (let r = 0; r < rowSpan; r++) {
const targetRow = rowIndex + r;
if (!grid[targetRow]) grid[targetRow] = [];
for (let c = 0; c < colSpan; c++) {
const targetCol = colIndex + c;
if (grid[targetRow][targetCol] === undefined) {
grid[targetRow][targetCol] = text;
}
}
}
colIndex += colSpan;
});
});
return grid;
}
Ahora una tabla como esta:
<table>
<tr><td rowspan="2">A</td><td>B</td></tr>
<tr><td>C</td></tr>
</table>
Se convierte correctamente en:
[
["A", "B"],
["A", "C"] // "A" aparece en ambas filas
]
Extrayendo Texto Limpio
textContent captura todo — incluyendo reglas CSS en etiquetas <style> y JavaScript en etiquetas <script> que algunas páginas inyectan en las celdas de las tablas.
La extracción limpia requiere filtrado:
function extractCellText(cell) {
if (!cell) return "";
// Clonar para evitar modificar el DOM
const clone = cell.cloneNode(true);
// Eliminar elementos invisibles
const invisibleSelectors = "style, script, noscript, template, link";
clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());
// Normalizar espacios en blanco
return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Detectando Tablas Anidadas
Cuando una tabla contiene otra tabla dentro de una celda, típicamente quieres los datos de la tabla exterior, no un lío recursivo.
La detección es directa:
function isNestedTable(table, allTables) {
let parent = table.parentElement;
while (parent) {
if (parent.tagName === "TABLE") {
return true; // Esta tabla está dentro de otra tabla
}
parent = parent.parentElement;
}
return false;
}
// Filtrar al escanear una página
const allTables = document.querySelectorAll("table");
const topLevelTables = Array.from(allTables)
.filter(t => !isNestedTable(t, allTables));
Convirtiendo a CSV
CSV parece simple hasta que necesitas manejar:
- Comas dentro de los valores
- Comillas dentro de los valores
- Saltos de línea dentro de los valores
El enfoque compatible con RFC 4180:
function toCSV(rows, delimiter = ",") {
return rows.map(row =>
row.map(cell => {
if (cell == null) cell = "";
const str = String(cell);
// Entrecomillar si contiene delimitador, comillas o saltos de línea
const needsQuotes = str.includes(delimiter) || /["\r\n]/.test(str);
const escaped = str.replace(/"/g, '""');
return needsQuotes ? `"${escaped}"` : escaped;
}).join(delimiter)
).join("\r\n");
}
Esto maneja correctamente el caso pesadilla:
toCSV([['Di "Hola, Mundo"', "Normal"]])
// '"Di ""Hola, Mundo""",Normal'
Para una guía completa sobre exportaciones CSV, mira Las 5 Mejores Extensiones de Chrome para Exportar Tablas.
Convirtiendo a JSON
Para exportar a JSON, la primera fila se convierte en claves:
function toJSON(rows) {
if (rows.length < 2) return "[]";
const headers = rows[0].map((h, i) => sanitizeKey(h, i));
const dataRows = rows.slice(1);
const objects = dataRows.map(row => {
const obj = {};
headers.forEach((key, i) => {
obj[key] = row[i] ?? "";
});
return obj;
});
return JSON.stringify(objects, null, 2);
}
function sanitizeKey(header, index) {
let key = (header || "").toString().trim();
if (!key) return `col_${index + 1}`;
// Normalizar a snake_case en minúsculas
return key
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // Eliminar acentos
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
Entrada:
| Nombre del Producto | Precio ($) |
|---------------------|------------|
| Widget | 29.99 |
Salida:
[
{
"nombre_del_producto": "Widget",
"precio": "29.99"
}
]
Disparando la Descarga
En un contexto de navegador, puedes disparar una descarga sin un servidor:
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
// Uso
const csv = toCSV(extractTableMatrix(table));
downloadFile(csv, "datos.csv", "text/csv;charset=utf-8");
Integrando Todo
Aquí hay un bookmarklet mínimo que exporta la primera tabla de cualquier página:
javascript:(function(){
const table = document.querySelector("table");
if (!table) { alert("No se encontró tabla"); return; }
function extractTableMatrix(table) {
const rows = Array.from(table.rows);
const grid = [];
rows.forEach((rowEl, ri) => {
if (!grid[ri]) grid[ri] = [];
let ci = 0;
Array.from(rowEl.cells).forEach(cell => {
while (grid[ri][ci] !== undefined) ci++;
const text = cell.textContent.trim();
const rs = parseInt(cell.rowSpan) || 1;
const cs = parseInt(cell.colSpan) || 1;
for (let r = 0; r < rs; r++) {
if (!grid[ri+r]) grid[ri+r] = [];
for (let c = 0; c < cs; c++) {
if (grid[ri+r][ci+c] === undefined) grid[ri+r][ci+c] = text;
}
}
ci += cs;
});
});
return grid;
}
const data = extractTableMatrix(table);
const csv = data.map(row =>
row.map(c => c.includes(",") ? `"${c}"` : c).join(",")
).join("\n");
const blob = new Blob([csv], {type: "text/csv"});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "tabla.csv";
link.click();
})();
Cuándo Usar una Extensión de Navegador
Este código funciona, pero mantenerlo entre diferentes sitios es tedioso. Si extraes tablas regularmente, una extensión de navegador maneja:
- Múltiples tablas por página
- Selección de formato (CSV, JSON, Excel)
- Limpieza de datos (normalización de números, manejo de nulos)
- Selección y reordenamiento de columnas
Construí HTML Table Exporter exactamente para este flujo de trabajo. Los algoritmos centrales son similares a lo mostrado aquí, empaquetados en una UI usable.
Más info en gauchogrid.com/es/html-table-exporter o pruébala gratis en la Chrome Web Store.
¿Preguntas sobre casos borde de extracción de tablas? Deja un comentario; probablemente ya lo enfrenté.
Top comments (0)