DEV Community

Cover image for Construire un Système d'Inférence de Types pour des Données Web Désordonnées
circobit
circobit

Posted on

Construire un Système d'Inférence de Types pour des Données Web Désordonnées

Les tableaux web sont des chaînes de caractères. Tout est une chaîne. Mais quand on exporte en JSON ou SQL, on veut :

  • "1 234,56"1234.56 (nombre)
  • "2024-03-15" → type date
  • "Oui"true (booléen)
  • "N/A"null

Construire un système fiable d'inférence de types, c'est gérer le chaos des données du monde réel. Voici comment je l'ai abordé pour HTML Table Exporter.

Le Problème : l'Ambiguïté Partout

Considérez ces valeurs :

Valeur Pourrait être...
"1,234" Nombre 1234 (US) ou 1.234 (EU)
"01/02/03" 1er fév. 2003 (EU) ou 2 jan. 2003 (US) ou 2001-02-03
"1" Entier 1 ou booléen true
"N/A" Chaîne ou null
"0" Entier 0 ou booléen false

L'inférence de types, ce n'est pas du parsing — c'est deviner l'intention à partir de preuves ambiguës.

Architecture : Inférence au Niveau de la Colonne

Inférer les types cellule par cellule n'est pas fiable. La valeur "1" seule pourrait être n'importe quoi. Mais une colonne contenant ["1", "2", "3", "4"] est clairement composée d'entiers.

Mon approche :

  1. Échantillonner les valeurs de la colonne (jusqu'à 100)
  2. Inférer le type pour chaque valeur non-nulle
  3. Agréger pour déterminer le type de la colonne
  4. Appliquer un seuil de 90 % — si 90 %+ des valeurs correspondent à un type, on l'utilise
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);

  // Compter les occurrences de chaque type
  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 est un sous-ensemble 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;

  // Vérifier les types par ordre de priorité
  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

Pourquoi 90 % ? Les données réelles ont du bruit. Une colonne de 100 entiers pourrait contenir un "N/A". Exiger 100 % de correspondance est trop strict.

Détection de Type au Niveau de la Valeur

La fonction inferValueType gère les valeurs individuelles :

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

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

  // Vérification booléenne
  const lowerStr = str.toLowerCase();
  if (["true", "false", "yes", "no", "oui", "non", "", "si"].includes(lowerStr)) {
    return DATA_TYPES.BOOLEAN;
  }

  // Vérification de date (format ISO préféré)
  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;
  }

  // Vérification d'entier (strict)
  if (/^-?\d+$/.test(str)) {
    return DATA_TYPES.INTEGER;
  }

  // Vérification numérique (avec décimales et séparateurs)
  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

Le Défi de la Normalisation des Nombres

Les formats numériques européens vs américains sont l'ambiguïté la plus difficile :

Valeur Interprétation US Interprétation EU
"1,234" 1234 1.234
"1.234" 1.234 1234
"1,234.56" 1234.56 Invalide
"1.234,56" Invalide 1234.56

Mon heuristique pour détecter le format :

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

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

  // Supprimer les symboles monétaires et espaces
  str = str.replace(/[$€£¥\s]/g, "");

  // Gérer les pourcentages
  const isPercent = str.endsWith("%");
  if (isPercent) str = str.slice(0, -1);

  // Détecter le format en analysant les séparateurs
  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) {
    // Entier simple : "1234"
    normalized = str;
  } else if (commaCount === 0 && dotCount === 1) {
    // Soit "1.234" (millier EU) soit "1.23" (décimal)
    // Heuristique : 3 chiffres après le point = séparateur de milliers
    const afterDot = str.slice(lastDot + 1);
    if (afterDot.length === 3 && /^\d+$/.test(afterDot)) {
      // Probablement séparateur de milliers EU
      normalized = str.replace(".", "");
    } else {
      // Probablement décimal
      normalized = str;
    }
  } else if (commaCount === 1 && dotCount === 0) {
    // Soit "1,234" (millier US) soit "1,23" (décimal EU)
    const afterComma = str.slice(lastComma + 1);
    if (afterComma.length === 3 && /^\d+$/.test(afterComma)) {
      // Probablement séparateur de milliers US
      normalized = str.replace(",", "");
    } else {
      // Probablement décimal EU
      normalized = str.replace(",", ".");
    }
  } else if (lastDot > lastComma) {
    // "1,234.56" - Format US
    normalized = str.replace(/,/g, "");
  } else if (lastComma > lastDot) {
    // "1.234,56" - Format EU
    normalized = str.replace(/\./g, "").replace(",", ".");
  } else {
    // Ambigu, retourner tel quel
    return value;
  }

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

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

Insight clé : La position et le nombre de séparateurs résolvent la plupart des ambiguïtés. Le dernier séparateur est généralement le séparateur décimal.

Détection de Booléens (Avec Pièges)

Booléens évidents : "true", "false", "oui", "non"

Mais qu'en est-il de "0" et "1" ?

// Problématique : Ceci convertit des taux de taxe en booléens
// Colonne : [0, 0.05, 0.1, 0.2]  -> [false, 0.05, 0.1, 0.2] 
Enter fullscreen mode Exit fullscreen mode

Ma solution : Ne pas convertir les valeurs purement numériques en booléens.

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; // Ignorer l'en-tête

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

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

      // Ignorer si purement numérique (évite 0 -> false dans les colonnes numériques)
      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

Gestion des Valeurs Nulles

Les données web utilisent de nombreuses représentations pour « pas de valeur » :

  • "" (chaîne vide)
  • "N/A", "n/a", "NA"
  • "-", "--"
  • "null", "NULL"
  • "none", "None"
  • "." (vu dans les données gouvernementales)

Détection de null 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; // Ignorer l'en-tête

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

      const cellStr = String(cell).toLowerCase().trim();
      return nullSet.has(cellStr) ? null : cell;
    });
  });
}

// Utilisation
applyNullValues(rows, ["N/A", "n/a", "-", "--", "null", "none", "."]);
Enter fullscreen mode Exit fullscreen mode

Le Pipeline

Pipeline complet d'inférence de types et de nettoyage :

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

  // 1. Trim de toutes les chaînes
  if (config.trimStrings) {
    rows = applyTrimStrings(rows);
  }

  // 2. Convertir les motifs null en null réels
  if (config.nullValues?.length) {
    rows = applyNullValues(rows, config.nullValues);
  }

  // 3. Normaliser les booléens (avant les nombres, pour éviter 0->false)
  if (config.booleans) {
    rows = applyBooleanNormalization(rows, config.booleans);
  }

  // 4. Normaliser les nombres (gère les formats EU/US)
  if (config.normalizeNumbers) {
    rows = applyNumberNormalization(rows);
  }

  // 5. Normaliser les dates (optionnel, spécifique au format)
  if (config.normalizeDates) {
    rows = applyDateNormalization(rows, config.dateFormat);
  }

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

Tester les Cas Limites

L'inférence de types a de nombreux cas limites. Je maintiens une suite de tests :

// Tests de normalisation de nombres
assertEqual(normalizeNumber("1,234.56"), 1234.56);    // Format US
assertEqual(normalizeNumber("1.234,56"), 1234.56);    // Format EU
assertEqual(normalizeNumber("€1.234,56"), 1234.56);   // Avec devise
assertEqual(normalizeNumber("45,5%"), 0.455);         // Pourcentage
assertEqual(normalizeNumber("1.234"), 1234);          // Millier EU
assertEqual(normalizeNumber("1.23"), 1.23);           // Décimal

// Tests booléens
assertEqual(inferValueType("Oui"), DATA_TYPES.BOOLEAN);
assertEqual(inferValueType("0"), DATA_TYPES.INTEGER);  // PAS booléen
assertEqual(inferValueType("0.5"), DATA_TYPES.NUMBER); // PAS booléen
Enter fullscreen mode Exit fullscreen mode

Intégration avec l'Export

Lors de l'export en SQL, utilisez les types inférés :

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

Leçons Apprises

  1. Le contexte de la colonne bat l'analyse cellulaire. Un seul "1" est ambigu ; une colonne d'entiers ne l'est pas.

  2. Être conservateur avec les conversions. Mieux vaut laisser quelque chose en chaîne que de le corrompre avec une mauvaise conversion.

  3. Rendre configurable. Différents domaines ont différentes valeurs nulles, représentations booléennes et formats numériques.

  4. Tester avec des données réelles. Les tests synthétiques ratent le chaos des vrais tableaux web.

Ce système alimente le nettoyage de données dans HTML Table Exporter. Pour un exemple pratique de ces défis, découvrez comment les meilleures extensions Chrome pour exporter des tableaux gèrent ces formats.

La version PRO permet aux utilisateurs de configurer ces règles par profil d'export. En savoir plus sur gauchogrid.com/fr/html-table-exporter ou essayez-le sur le Chrome Web Store.


Vous construisez un système d'inférence de types pour un autre domaine ? J'adorerais entendre parler de vos cas limites.

Top comments (0)