Las tablas web son cadenas de texto. Todo es una cadena. Pero cuando exportas a JSON o SQL, quieres:
-
"1,234.56"→1234.56(número) -
"2024-03-15"→ tipo date -
"Yes"→true(booleano) -
"N/A"→null
Construir un sistema de inferencia de tipos confiable significa manejar el caos de los datos del mundo real. Así lo abordé para HTML Table Exporter.
El Problema: Ambigüedad en Todos Lados
Considerá estos valores:
| Valor | Podría ser... |
|---|---|
"1,234" |
Número 1234 (EEUU) o 1.234 (Europa) |
"01/02/03" |
1 de febrero de 2003 (EEUU) o 2 de enero de 2003 (Europa) o 2001-02-03 |
"1" |
Entero 1 o booleano true |
"N/A" |
String o null |
"0" |
Entero 0 o booleano false |
La inferencia de tipos no es parsing—es adivinar la intención a partir de evidencia ambigua.
Arquitectura: Inferencia a Nivel de Columna
Inferir tipos celda por celda no es confiable. El valor "1" solo podría ser cualquier cosa. Pero una columna con ["1", "2", "3", "4"] es claramente de enteros.
Mi enfoque:
- Tomar muestras de valores de la columna (hasta 100)
- Inferir tipo para cada valor no nulo
- Agregar para determinar el tipo de columna
- Aplicar umbral del 90%—si el 90%+ de valores coinciden con un tipo, usarlo
const DATA_TYPES = {
STRING: "string",
INTEGER: "integer",
NUMBER: "number",
BOOLEAN: "boolean",
DATE: "date",
NULL: "null",
};
function inferColumnType(columnValues, maxSamples = 100) {
const nonNullValues = columnValues.filter(v => v != null && v !== "");
if (nonNullValues.length === 0) {
return { type: DATA_TYPES.STRING, confidence: 0 };
}
const sample = nonNullValues.slice(0, maxSamples);
// Contar ocurrencias de cada tipo
const typeCounts = {
[DATA_TYPES.INTEGER]: 0,
[DATA_TYPES.NUMBER]: 0,
[DATA_TYPES.BOOLEAN]: 0,
[DATA_TYPES.DATE]: 0,
[DATA_TYPES.STRING]: 0,
};
for (const val of sample) {
const type = inferValueType(val);
if (type === DATA_TYPES.INTEGER) {
// INTEGER es un subconjunto de NUMBER
typeCounts[DATA_TYPES.INTEGER]++;
typeCounts[DATA_TYPES.NUMBER]++;
} else if (type !== DATA_TYPES.NULL) {
typeCounts[type]++;
}
}
const total = sample.length;
const threshold = 0.9;
// Verificar tipos en orden de prioridad
if (typeCounts[DATA_TYPES.BOOLEAN] >= total * threshold) {
return { type: DATA_TYPES.BOOLEAN, confidence: typeCounts[DATA_TYPES.BOOLEAN] / total };
}
if (typeCounts[DATA_TYPES.DATE] >= total * threshold) {
return { type: DATA_TYPES.DATE, confidence: typeCounts[DATA_TYPES.DATE] / total };
}
if (typeCounts[DATA_TYPES.INTEGER] >= total * threshold) {
return { type: DATA_TYPES.INTEGER, confidence: typeCounts[DATA_TYPES.INTEGER] / total };
}
if (typeCounts[DATA_TYPES.NUMBER] >= total * threshold) {
return { type: DATA_TYPES.NUMBER, confidence: typeCounts[DATA_TYPES.NUMBER] / total };
}
return { type: DATA_TYPES.STRING, confidence: 1 };
}
¿Por qué 90%? Los datos reales tienen ruido. Una columna de 100 enteros podría tener un "N/A". Exigir 100% de coincidencia es demasiado estricto.
Detección de Tipos a Nivel de Valor
La función inferValueType maneja valores individuales:
function inferValueType(value) {
if (value == null || value === "") {
return DATA_TYPES.NULL;
}
const str = String(value).trim();
if (str === "") return DATA_TYPES.NULL;
// Verificación de booleanos
const lowerStr = str.toLowerCase();
if (["true", "false", "yes", "no", "sí", "si"].includes(lowerStr)) {
return DATA_TYPES.BOOLEAN;
}
// Verificación de fechas (formato ISO preferido)
if (/^\d{4}-\d{2}-\d{2}/.test(str)) {
return DATA_TYPES.DATE;
}
if (/^\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}$/.test(str)) {
return DATA_TYPES.DATE;
}
// Verificación de enteros (estricta)
if (/^-?\d+$/.test(str)) {
return DATA_TYPES.INTEGER;
}
// Verificación de números (con decimales y separadores)
const cleanedForNumber = str
.replace(/[$€£¥%\s]/g, "")
.replace(/,/g, ".");
if (/^-?\d+(\.\d+)?$/.test(cleanedForNumber)) {
return DATA_TYPES.NUMBER;
}
return DATA_TYPES.STRING;
}
El Desafío de la Normalización de Números
Los formatos numéricos europeos vs estadounidenses son la ambigüedad más difícil:
| Valor | Interpretación EEUU | Interpretación europea |
|---|---|---|
"1,234" |
1234 | 1.234 |
"1.234" |
1.234 | 1234 |
"1,234.56" |
1234.56 | Inválido |
"1.234,56" |
Inválido | 1234.56 |
Mi heurística para detectar el formato:
function normalizeNumber(value) {
if (value == null) return value;
let str = String(value).trim();
// Eliminar símbolos de moneda y espacios
str = str.replace(/[$€£¥\s]/g, "");
// Manejar porcentajes
const isPercent = str.endsWith("%");
if (isPercent) str = str.slice(0, -1);
// Detectar formato analizando separadores
const commaCount = (str.match(/,/g) || []).length;
const dotCount = (str.match(/\./g) || []).length;
const lastComma = str.lastIndexOf(",");
const lastDot = str.lastIndexOf(".");
let normalized;
if (commaCount === 0 && dotCount === 0) {
// Entero simple: "1234"
normalized = str;
} else if (commaCount === 0 && dotCount === 1) {
// Puede ser "1.234" (miles europeo) o "1.23" (decimal)
// Heurística: 3 dígitos después del punto = separador de miles
const afterDot = str.slice(lastDot + 1);
if (afterDot.length === 3 && /^\d+$/.test(afterDot)) {
// Probablemente separador de miles europeo
normalized = str.replace(".", "");
} else {
// Probablemente decimal
normalized = str;
}
} else if (commaCount === 1 && dotCount === 0) {
// Puede ser "1,234" (miles EEUU) o "1,23" (decimal europeo)
const afterComma = str.slice(lastComma + 1);
if (afterComma.length === 3 && /^\d+$/.test(afterComma)) {
// Probablemente separador de miles EEUU
normalized = str.replace(",", "");
} else {
// Probablemente decimal europeo
normalized = str.replace(",", ".");
}
} else if (lastDot > lastComma) {
// "1,234.56" - Formato EEUU
normalized = str.replace(/,/g, "");
} else if (lastComma > lastDot) {
// "1.234,56" - Formato europeo
normalized = str.replace(/\./g, "").replace(",", ".");
} else {
// Ambiguo, devolver tal cual
return value;
}
const num = parseFloat(normalized);
if (Number.isNaN(num)) return value;
return isPercent ? num / 100 : num;
}
Insight clave: La posición y cantidad de separadores resuelve la mayor parte de la ambigüedad. El último separador generalmente es el separador decimal.
Detección de Booleanos (Con Trampas)
Booleanos obvios: "true", "false", "yes", "no"
Pero ¿qué pasa con "0" y "1"?
// Problemático: Esto convierte tasas impositivas a booleanos
// Columna: [0, 0.05, 0.1, 0.2] -> [false, 0.05, 0.1, 0.2]
Mi solución: No convertir valores puramente numéricos a booleanos.
function applyBooleanNormalization(rows, booleanConfig) {
const trueValues = new Set(booleanConfig.true.map(v => v.toLowerCase()));
const falseValues = new Set(booleanConfig.false.map(v => v.toLowerCase()));
return rows.map((row, rowIndex) => {
if (rowIndex === 0) return row; // Saltar encabezado
return row.map(cell => {
if (cell == null) return cell;
const cellStr = String(cell).toLowerCase().trim();
// Saltar si es puramente numérico (evita 0 -> false en columnas numéricas)
if (/^-?\d+(\.\d+)?$/.test(cellStr)) {
return cell;
}
if (trueValues.has(cellStr)) return "true";
if (falseValues.has(cellStr)) return "false";
return cell;
});
});
}
Manejo de Valores Nulos
Los datos web usan muchas representaciones para "sin valor":
-
""(cadena vacía) -
"N/A","n/a","NA" -
"-","--" -
"null","NULL" -
"none","None" -
"."(visto en datos gubernamentales)
Detección de nulos configurable:
function applyNullValues(rows, nullPatterns) {
const nullSet = new Set(nullPatterns.map(v => v.toLowerCase().trim()));
return rows.map((row, rowIndex) => {
if (rowIndex === 0) return row; // Saltar encabezado
return row.map(cell => {
if (cell == null) return null;
const cellStr = String(cell).toLowerCase().trim();
return nullSet.has(cellStr) ? null : cell;
});
});
}
// Uso
applyNullValues(rows, ["N/A", "n/a", "-", "--", "null", "none", "."]);
El Pipeline
Pipeline completo de inferencia de tipos y limpieza:
function cleanTable(tableInfo, config) {
let rows = cloneRows(tableInfo.rows);
// 1. Recortar todos los strings
if (config.trimStrings) {
rows = applyTrimStrings(rows);
}
// 2. Convertir patrones nulos a null real
if (config.nullValues?.length) {
rows = applyNullValues(rows, config.nullValues);
}
// 3. Normalizar booleanos (antes de números, para evitar 0->false)
if (config.booleans) {
rows = applyBooleanNormalization(rows, config.booleans);
}
// 4. Normalizar números (maneja formatos EU/US)
if (config.normalizeNumbers) {
rows = applyNumberNormalization(rows);
}
// 5. Normalizar fechas (opcional, específico de formato)
if (config.normalizeDates) {
rows = applyDateNormalization(rows, config.dateFormat);
}
return { ...tableInfo, rows };
}
Tests de Casos Extremos
La inferencia de tipos tiene muchos casos extremos. Mantengo una suite de tests:
// Tests de normalización de números
assertEqual(normalizeNumber("1,234.56"), 1234.56); // Formato EEUU
assertEqual(normalizeNumber("1.234,56"), 1234.56); // Formato europeo
assertEqual(normalizeNumber("€1.234,56"), 1234.56); // Con moneda
assertEqual(normalizeNumber("45,5%"), 0.455); // Porcentaje
assertEqual(normalizeNumber("1.234"), 1234); // Miles europeo
assertEqual(normalizeNumber("1.23"), 1.23); // Decimal
// Tests de booleanos
assertEqual(inferValueType("Yes"), DATA_TYPES.BOOLEAN);
assertEqual(inferValueType("0"), DATA_TYPES.INTEGER); // NO booleano
assertEqual(inferValueType("0.5"), DATA_TYPES.NUMBER); // NO booleano
Integración con la Exportación
Al exportar a SQL, usar los tipos inferidos:
function inferSqlColumnTypes(rows) {
const header = rows[0];
const dataRows = rows.slice(1);
return header.map((_, colIndex) => {
const columnValues = dataRows.map(row => row[colIndex]);
const { type } = inferColumnType(columnValues);
switch (type) {
case DATA_TYPES.INTEGER: return "INTEGER";
case DATA_TYPES.NUMBER: return "REAL";
case DATA_TYPES.BOOLEAN: return "BOOLEAN";
case DATA_TYPES.DATE: return "DATE";
default: return "TEXT";
}
});
}
Lecciones Aprendidas
El contexto de columna le gana al análisis de celda. Un solo
"1"es ambiguo; una columna de enteros no lo es.Ser conservador con la conversión. Es mejor dejar algo como string que corromperlo con una conversión incorrecta.
Hacerlo configurable. Distintos dominios tienen distintos valores nulos, representaciones de booleanos y formatos numéricos.
Testear con datos reales. Los tests sintéticos no capturan el caos de las tablas web reales.
Este sistema potencia la limpieza de datos en HTML Table Exporter. Para un ejemplo práctico de estos desafíos, mira cómo copiar tablas de sitios web a Excel muchas veces resulta en formato roto que una inferencia de tipos adecuada puede corregir.
La versión PRO permite configurar estas reglas por perfil de exportación. Más información en gauchogrid.com/es/html-table-exporter o pruébalo en la Chrome Web Store.
¿Estás construyendo inferencia de tipos para otro dominio? Me encantaría escuchar sobre tus casos extremos.
Top comments (0)