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:
- Werte aus der Spalte samplen (bis zu 100)
- Typ für jeden Nicht-Null-Wert inferieren
- Aggregieren, um den Spaltentyp zu bestimmen
- 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 };
}
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", "sí", "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;
}
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;
}
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]
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;
});
});
}
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", "."]);
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 };
}
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
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";
}
});
}
Lessons Learned
Spaltenkontext schlägt Einzelzellanalyse. Ein einzelnes
"1"ist mehrdeutig; eine Spalte voller Integer nicht.Konvertierungen konservativ handhaben. Es ist besser, etwas als String zu belassen, als es durch eine falsche Konvertierung zu beschädigen.
Konfigurierbar machen. Verschiedene Domänen haben unterschiedliche Null-Werte, Boolean-Darstellungen und Zahlenformate.
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)