DEV Community

Cover image for Construindo um Sistema de Inferência de Tipos para Dados Web Bagunçados
circobit
circobit

Posted on

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

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.

Top comments (0)