DEV Community

Cover image for Construyendo un Sistema de Inferencia de Tipos para Datos Web Desordenados
circobit
circobit

Posted on

Construyendo un Sistema de Inferencia de Tipos para Datos Web Desordenados

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:

  1. Tomar muestras de valores de la columna (hasta 100)
  2. Inferir tipo para cada valor no nulo
  3. Agregar para determinar el tipo de columna
  4. 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 };
}
Enter fullscreen mode Exit fullscreen mode

¿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", "", "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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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] 
Enter fullscreen mode Exit fullscreen mode

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;
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

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", "."]);
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Lecciones Aprendidas

  1. El contexto de columna le gana al análisis de celda. Un solo "1" es ambiguo; una columna de enteros no lo es.

  2. Ser conservador con la conversión. Es mejor dejar algo como string que corromperlo con una conversión incorrecta.

  3. Hacerlo configurable. Distintos dominios tienen distintos valores nulos, representaciones de booleanos y formatos numéricos.

  4. 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)