DEV Community

Cover image for Ein Typinferenz-System für chaotische Webdaten entwickeln
circobit
circobit

Posted on

Ein Typinferenz-System für chaotische Webdaten entwickeln

Webtabellen bestehen aus Strings. Alles ist ein String. Aber beim Export nach JSON oder SQL möchte man:

  • "1.234,56"1234.56 (Zahl)
  • "2024-03-15" → Datumstyp
  • "Ja"true (Boolean)
  • "N/A"null

Ein zuverlässiges Typinferenz-System zu entwickeln bedeutet, das Chaos realer Daten zu bewältigen. So habe ich es für HTML Table Exporter umgesetzt.

Das Problem: Mehrdeutigkeit überall

Betrachten wir diese Werte:

Wert Könnte sein...
"1.234" Zahl 1234 (DE) oder 1.234 (US)
"01/02/03" 1. Feb 2003 (DE) oder Jan 2, 2003 (US) oder 2001-02-03
"1" Integer 1 oder Boolean true
"N/A" String oder null
"0" Integer 0 oder Boolean false

Typinferenz bedeutet nicht einfach Parsen — es geht darum, die Absicht aus mehrdeutigen Hinweisen zu erraten.

Architektur: Spaltenbasierte Inferenz

Zellweise Typinferenz ist unzuverlässig. Der Wert "1" allein könnte alles sein. Aber eine Spalte mit ["1", "2", "3", "4"] ist eindeutig Integer.

Mein Ansatz:

  1. Werte aus der Spalte samplen (bis zu 100)
  2. Typ für jeden Nicht-Null-Wert inferieren
  3. Aggregieren, um den Spaltentyp zu bestimmen
  4. 90%-Schwellenwert anwenden — wenn 90%+ der Werte einem Typ entsprechen, wird dieser verwendet
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);

  // Vorkommen jedes Typs zählen
  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 ist eine Teilmenge von 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;

  // Typen in Prioritätsreihenfolge prüfen
  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

Warum 90%? Reale Daten enthalten Rauschen. Eine Spalte mit 100 Integern könnte ein einzelnes "N/A" enthalten. 100% Übereinstimmung zu verlangen ist zu streng.

Typerkennung auf Wertebene

Die Funktion inferValueType verarbeitet einzelne Werte:

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

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

  // Boolean-Prüfung
  const lowerStr = str.toLowerCase();
  if (["true", "false", "yes", "no", "ja", "nein", "", "si"].includes(lowerStr)) {
    return DATA_TYPES.BOOLEAN;
  }

  // Datums-Prüfung (ISO-Format bevorzugt)
  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;
  }

  // Integer-Prüfung (streng)
  if (/^-?\d+$/.test(str)) {
    return DATA_TYPES.INTEGER;
  }

  // Zahlen-Prüfung (mit Dezimalstellen und Trennzeichen)
  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

Die Herausforderung der Zahlennormalisierung

Europäische vs. US-Zahlenformate sind die schwierigste Mehrdeutigkeit:

Wert US-Interpretation EU-Interpretation
"1,234" 1234 1.234
"1.234" 1.234 1234
"1,234.56" 1234.56 Ungültig
"1.234,56" Ungültig 1234.56

Meine Heuristik zur Formaterkennung:

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

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

  // Währungssymbole und Leerzeichen entfernen
  str = str.replace(/[$€£¥\s]/g, "");

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

  // Format anhand der Trennzeichen erkennen
  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) {
    // Einfache Ganzzahl: "1234"
    normalized = str;
  } else if (commaCount === 0 && dotCount === 1) {
    // Entweder "1.234" (EU-Tausender) oder "1.23" (Dezimal)
    // Heuristik: 3 Ziffern nach Punkt = Tausendertrennzeichen
    const afterDot = str.slice(lastDot + 1);
    if (afterDot.length === 3 && /^\d+$/.test(afterDot)) {
      // Wahrscheinlich EU-Tausendertrennzeichen
      normalized = str.replace(".", "");
    } else {
      // Wahrscheinlich Dezimalzahl
      normalized = str;
    }
  } else if (commaCount === 1 && dotCount === 0) {
    // Entweder "1,234" (US-Tausender) oder "1,23" (EU-Dezimal)
    const afterComma = str.slice(lastComma + 1);
    if (afterComma.length === 3 && /^\d+$/.test(afterComma)) {
      // Wahrscheinlich US-Tausendertrennzeichen
      normalized = str.replace(",", "");
    } else {
      // Wahrscheinlich EU-Dezimalzahl
      normalized = str.replace(",", ".");
    }
  } else if (lastDot > lastComma) {
    // "1,234.56" - US-Format
    normalized = str.replace(/,/g, "");
  } else if (lastComma > lastDot) {
    // "1.234,56" - EU-Format
    normalized = str.replace(/\./g, "").replace(",", ".");
  } else {
    // Mehrdeutig, unverändert zurückgeben
    return value;
  }

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

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

Zentrale Erkenntnis: Position und Anzahl der Trennzeichen lösen die meisten Mehrdeutigkeiten auf. Das letzte Trennzeichen ist in der Regel das Dezimaltrennzeichen.

Boolean-Erkennung (mit Fallstricken)

Offensichtliche Booleans: "true", "false", "ja", "nein"

Aber was ist mit "0" und "1"?

// Problematisch: Das konvertiert Steuersätze zu Booleans
// Spalte: [0, 0.05, 0.1, 0.2]  -> [false, 0.05, 0.1, 0.2] 
Enter fullscreen mode Exit fullscreen mode

Meine Lösung: Rein numerische Werte nicht in Booleans konvertieren.

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; // Header überspringen

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

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

      // Überspringen wenn rein numerisch (verhindert 0 -> false in Zahlenspalten)
      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

Null-Wert-Behandlung

Webdaten verwenden viele Darstellungen für „kein Wert":

  • "" (leerer String)
  • "N/A", "n/a", "NA"
  • "-", "--"
  • "null", "NULL"
  • "none", "None"
  • "." (kommt in Behördendaten vor)

Konfigurierbare Null-Erkennung:

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

  return rows.map((row, rowIndex) => {
    if (rowIndex === 0) return row; // Header überspringen

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

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

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

Die Pipeline

Vollständige Typinferenz- und Bereinigungspipeline:

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

  // 1. Alle Strings trimmen
  if (config.trimStrings) {
    rows = applyTrimStrings(rows);
  }

  // 2. Null-Muster in echtes null konvertieren
  if (config.nullValues?.length) {
    rows = applyNullValues(rows, config.nullValues);
  }

  // 3. Booleans normalisieren (vor Zahlen, um 0->false zu vermeiden)
  if (config.booleans) {
    rows = applyBooleanNormalization(rows, config.booleans);
  }

  // 4. Zahlen normalisieren (behandelt EU/US-Formate)
  if (config.normalizeNumbers) {
    rows = applyNumberNormalization(rows);
  }

  // 5. Daten normalisieren (optional, formatspezifisch)
  if (config.normalizeDates) {
    rows = applyDateNormalization(rows, config.dateFormat);
  }

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

Edge Cases testen

Typinferenz hat viele Sonderfälle. Ich pflege eine Testsuite:

// Tests zur Zahlennormalisierung
assertEqual(normalizeNumber("1,234.56"), 1234.56);    // US-Format
assertEqual(normalizeNumber("1.234,56"), 1234.56);    // EU-Format
assertEqual(normalizeNumber("€1.234,56"), 1234.56);   // Mit Währung
assertEqual(normalizeNumber("45,5%"), 0.455);         // Prozent
assertEqual(normalizeNumber("1.234"), 1234);          // EU-Tausender
assertEqual(normalizeNumber("1.23"), 1.23);           // Dezimal

// Boolean-Tests
assertEqual(inferValueType("Ja"), DATA_TYPES.BOOLEAN);
assertEqual(inferValueType("0"), DATA_TYPES.INTEGER);  // NICHT Boolean
assertEqual(inferValueType("0.5"), DATA_TYPES.NUMBER); // NICHT Boolean
Enter fullscreen mode Exit fullscreen mode

Integration mit dem Export

Beim Export nach SQL die inferierten Typen verwenden:

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

Lessons Learned

  1. Spaltenkontext schlägt Einzelzellanalyse. Ein einzelnes "1" ist mehrdeutig; eine Spalte voller Integer nicht.

  2. Konvertierungen konservativ handhaben. Es ist besser, etwas als String zu belassen, als es durch eine falsche Konvertierung zu beschädigen.

  3. Konfigurierbar machen. Verschiedene Domänen haben unterschiedliche Null-Werte, Boolean-Darstellungen und Zahlenformate.

  4. Mit echten Daten testen. Synthetische Tests verpassen das Chaos realer Webtabellen.

Dieses System bildet die Grundlage der Datenbereinigung im HTML Table Exporter. Für ein praktisches Beispiel dieser Herausforderungen lesen Sie, wie das Kopieren von Tabellen aus Websites nach Excel oft zu fehlerhafter Formatierung führt, die eine korrekte Typinferenz beheben kann.

Die PRO-Version ermöglicht es Benutzern, diese Regeln pro Exportprofil zu konfigurieren. Erfahren Sie mehr auf gauchogrid.com/de/html-table-exporter oder probieren Sie es im Chrome Web Store aus.


Entwickeln Sie Typinferenz für eine andere Domäne? Ich würde gerne von Ihren Edge Cases hören.

Top comments (0)