Construindo um Sistema de Inferência de Tipos para Dados Web Bagunçados

javascript dev.to

Tabelas web são strings. Tudo é string. Mas quando você exporta para JSON ou SQL, você quer:

  • "1.234,56"1234.56 (número)
  • "2024-03-15" → tipo date
  • "Sim"true (booleano)
  • "N/D"null

Construir um sistema confiável de inferência de tipos significa lidar com o caos dos dados do mundo real. Veja como eu abordei isso no HTML Table Exporter.

O Problema: Ambiguidade em Todo Lugar

Considere estes valores:

Valor Poderia ser...
"1,234" Número 1234 (EUA) ou 1.234 (BR/EU)
"01/02/03" 1 Jan 2003 (EUA) ou 1 Fev 2003 (BR) ou 2001-02-03
"1" Inteiro 1 ou booleano true
"N/D" String ou null
"0" Inteiro 0 ou booleano false

Inferência de tipos não é sobre parsing — é sobre adivinhar a intenção a partir de evidências ambíguas.

Arquitetura: Inferência no Nível da Coluna

Inferir tipos célula por célula não é confiável. O valor "1" sozinho pode ser qualquer coisa. Mas uma coluna contendo ["1", "2", "3", "4"] é claramente de inteiros.

Minha abordagem:

  1. Amostrar valores da coluna (até 100)
  2. Inferir o tipo para cada valor não-nulo
  3. Agregar para determinar o tipo da coluna
  4. Aplicar limiar de 90% — se 90%+ dos valores correspondem a um tipo, usar esse tipo
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 ocorrências 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 é um 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 em ordem de prioridade
  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 que 90%? Dados reais têm ruído. Uma coluna com 100 inteiros pode ter um "N/D". Exigir 100% de correspondência é rigoroso demais.

Detecção de Tipo no Nível do Valor

A função inferValueType lida com valores individuais:

function inferValueType(value) {
  if (value == null || value === "") {
    return DATA_TYPES.NULL;
  }

  const str = String(value).trim();
  if (str === "") return DATA_TYPES.NULL;

  // Verificação de booleano
  const lowerStr = str.toLowerCase();
  if (["true", "false", "yes", "no", "sim", "não", "", "si"].includes(lowerStr)) {
    return DATA_TYPES.BOOLEAN;
  }

  // Verificação de data (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;
  }

  // Verificação de inteiro (estrita)
  if (/^-?\d+$/.test(str)) {
    return DATA_TYPES.INTEGER;
  }

  // Verificação de número (com decimais e separadores)
  const cleanedForNumber = str
    .replace(/[$€£¥R\$%\s]/g, "")
    .replace(/,/g, ".");

  if (/^-?\d+(\.\d+)?$/.test(cleanedForNumber)) {
    return DATA_TYPES.NUMBER;
  }

  return DATA_TYPES.STRING;
}
Enter fullscreen mode Exit fullscreen mode

O Desafio da Normalização Numérica

Formatos numéricos europeu/brasileiro vs americano são a ambiguidade mais difícil:

Valor Interpretação EUA Interpretação BR/EU
"1,234" 1234 1.234
"1.234" 1.234 1234
"1,234.56" 1234.56 Inválido
"1.234,56" Inválido 1234.56

Minha heurística para detectar o formato:

function normalizeNumber(value) {
  if (value == null) return value;

  let str = String(value).trim();

  // Remover símbolos de moeda e espaços
  str = str.replace(/[$€£¥R\$\s]/g, "");

  // Tratar porcentagem
  const isPercent = str.endsWith("%");
  if (isPercent) str = str.slice(0, -1);

  // Detectar formato analisando 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) {
    // Inteiro simples: "1234"
    normalized = str;
  } else if (commaCount === 0 && dotCount === 1) {
    // "1.234" (separador de milhar EU/BR) ou "1.23" (decimal)
    // Heurística: 3 dígitos após o ponto = separador de milhar
    const afterDot = str.slice(lastDot + 1);
    if (afterDot.length === 3 && /^\d+$/.test(afterDot)) {
      // Provavelmente separador de milhar EU/BR
      normalized = str.replace(".", "");
    } else {
      // Provavelmente decimal
      normalized = str;
    }
  } else if (commaCount === 1 && dotCount === 0) {
    // "1,234" (separador de milhar EUA) ou "1,23" (decimal EU/BR)
    const afterComma = str.slice(lastComma + 1);
    if (afterComma.length === 3 && /^\d+$/.test(afterComma)) {
      // Provavelmente separador de milhar EUA
      normalized = str.replace(",", "");
    } else {
      // Provavelmente decimal EU/BR
      normalized = str.replace(",", ".");
    }
  } else if (lastDot > lastComma) {
    // "1,234.56" - formato EUA
    normalized = str.replace(/,/g, "");
  } else if (lastComma > lastDot) {
    // "1.234,56" - formato EU/BR
    normalized = str.replace(/\./g, "").replace(",", ".");
  } else {
    // Ambíguo, retornar como está
    return value;
  }

  const num = parseFloat(normalized);
  if (Number.isNaN(num)) return value;

  return isPercent ? num / 100 : num;
}
Enter fullscreen mode Exit fullscreen mode

Insight chave: A posição e contagem dos separadores resolve a maioria das ambiguidades. O último separador é geralmente o separador decimal.

Detecção de Booleanos (Com Armadilhas)

Booleanos óbvios: "true", "false", "sim", "não"

Mas e quanto a "0" e "1"?

// Problemático: Isso converte alíquotas de imposto em booleanos
// Coluna: [0, 0.05, 0.1, 0.2]  -> [false, 0.05, 0.1, 0.2] 
Enter fullscreen mode Exit fullscreen mode

Minha solução: Não converter valores puramente numéricos em 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; // Pular cabeçalho

    return row.map(cell => {
      if (cell == null) return cell;

      const cellStr = String(cell).toLowerCase().trim();

      // Pular se puramente numérico (previne 0 -> false em colunas 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

Tratamento de Valores Nulos

Dados web usam muitas representações para "sem valor":

  • "" (string vazia)
  • "N/D", "n/d", "N/A", "NA"
  • "-", "--"
  • "null", "NULL"
  • "nenhum", "none", "None"
  • "." (comum em dados governamentais)

Detecção de nulo configurável:

function applyNullValues(rows, nullPatterns) {
  const nullSet = new Set(nullPatterns.map(v => v.toLowerCase().trim()));

  return rows.map((row, rowIndex) => {
    if (rowIndex === 0) return row; // Pular cabeçalho

    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", "N/D", "-", "--", "null", "none", "nenhum", "."]);
Enter fullscreen mode Exit fullscreen mode

O Pipeline

Pipeline completo de inferência de tipos e limpeza:

function cleanTable(tableInfo, config) {
  let rows = cloneRows(tableInfo.rows);

  // 1. Remover espaços de todas as strings
  if (config.trimStrings) {
    rows = applyTrimStrings(rows);
  }

  // 2. Converter padrões de nulo em null real
  if (config.nullValues?.length) {
    rows = applyNullValues(rows, config.nullValues);
  }

  // 3. Normalizar booleanos (antes dos números, para evitar problemas 0->false)
  if (config.booleans) {
    rows = applyBooleanNormalization(rows, config.booleans);
  }

  // 4. Normalizar números (lida com formatos EU/EUA)
  if (config.normalizeNumbers) {
    rows = applyNumberNormalization(rows);
  }

  // 5. Normalizar datas (opcional, específico por formato)
  if (config.normalizeDates) {
    rows = applyDateNormalization(rows, config.dateFormat);
  }

  return { ...tableInfo, rows };
}
Enter fullscreen mode Exit fullscreen mode

Testando Casos Extremos

Inferência de tipos tem muitos casos extremos. Eu mantenho uma suíte de testes:

// Testes de normalização de números
assertEqual(normalizeNumber("1,234.56"), 1234.56);    // Formato EUA
assertEqual(normalizeNumber("1.234,56"), 1234.56);    // Formato BR/EU
assertEqual(normalizeNumber("€1.234,56"), 1234.56);   // Com moeda
assertEqual(normalizeNumber("45,5%"), 0.455);         // Porcentagem
assertEqual(normalizeNumber("1.234"), 1234);          // Milhar BR/EU
assertEqual(normalizeNumber("1.23"), 1.23);           // Decimal

// Testes de booleano
assertEqual(inferValueType("Sim"), DATA_TYPES.BOOLEAN);
assertEqual(inferValueType("0"), DATA_TYPES.INTEGER);  // NÃO booleano
assertEqual(inferValueType("0.5"), DATA_TYPES.NUMBER); // NÃO booleano
Enter fullscreen mode Exit fullscreen mode

Integração com Exportação

Ao exportar para SQL, use os 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

Lições Aprendidas

  1. Contexto da coluna supera análise da célula. Um único "1" é ambíguo; uma coluna de inteiros não é.

  2. Seja conservador com conversões. É melhor deixar algo como string do que corrompê-lo com uma conversão errada.

  3. Torne configurável. Diferentes domínios têm diferentes valores nulos, representações de booleanos e formatos numéricos.

  4. Teste com dados reais. Testes sintéticos não capturam o caos das tabelas web reais.

Este sistema alimenta a limpeza de dados no HTML Table Exporter. Para conhecer as melhores ferramentas para extrair dados de tabelas web, confira nossa comparação das melhores extensões Chrome para exportar tabelas.

A versão PRO permite que os usuários configurem essas regras por perfil de exportação. Saiba mais em gauchogrid.com/pt-br/html-table-exporter ou experimente na Chrome Web Store.


Construindo inferência de tipos para outro domínio? Adoraria ouvir sobre seus casos extremos.

Source: dev.to

arrow_back Back to Tutorials