Webtabellen zijn strings. Alles is een string. Maar wanneer je exporteert naar JSON of SQL, wil je:
-
"1.234,56"→1234.56(getal) -
"2024-03-15"→ datumtype -
"Yes"→true(boolean) -
"N/A"→null
Een betrouwbaar type-inferentiesysteem bouwen betekent omgaan met de chaos van echte data. Hier lees je hoe ik het heb aangepakt voor HTML Table Exporter.
Het Probleem: Ambiguïteit Overal
Beschouw deze waarden:
| Waarde | Kan zijn... |
|---|---|
"1,234" |
Getal 1234 (VS) of 1.234 (EU) |
"01/02/03" |
1 feb 2003 (VS) of 2 jan 2003 (EU) of 2001-02-03 |
"1" |
Integer 1 of boolean true |
"N/A" |
String of null |
"0" |
Integer 0 of boolean false |
Type-inferentie gaat niet over parsen — het gaat over het raden van intentie uit ambigue aanwijzingen.
Architectuur: Inferentie op Kolomniveau
Types per cel afleiden is onbetrouwbaar. De waarde "1" alleen kan alles zijn. Maar een kolom met ["1", "2", "3", "4"] is duidelijk integers.
Mijn aanpak:
- Neem waarden uit de kolom (maximaal 100)
- Leid het type af voor elke niet-null waarde
- Aggregeer om het kolomtype te bepalen
- Pas een 90%-drempel toe — als 90%+ waarden overeenkomen met een type, gebruik dat
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);
// Tel voorkomens van elk 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 is een subset van 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;
// Controleer types in prioriteitsvolgorde
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 };
}
Waarom 90%? Echte data bevat ruis. Een kolom met 100 integers kan één "N/A" bevatten. 100% overeenkomst eisen is te streng.
Typedetectie op Waardeniveau
De inferValueType-functie handelt individuele waarden af:
function inferValueType(value) {
if (value == null || value === "") {
return DATA_TYPES.NULL;
}
const str = String(value).trim();
if (str === "") return DATA_TYPES.NULL;
// Boolean-controle
const lowerStr = str.toLowerCase();
if (["true", "false", "yes", "no", "ja", "nee"].includes(lowerStr)) {
return DATA_TYPES.BOOLEAN;
}
// Datumcontrole (ISO-formaat heeft voorkeur)
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-controle (strikt)
if (/^-?\d+$/.test(str)) {
return DATA_TYPES.INTEGER;
}
// Getalcontrole (met decimalen en scheidingstekens)
const cleanedForNumber = str
.replace(/[$€£¥%\s]/g, "")
.replace(/,/g, ".");
if (/^-?\d+(\.\d+)?$/.test(cleanedForNumber)) {
return DATA_TYPES.NUMBER;
}
return DATA_TYPES.STRING;
}
De Uitdaging van Getalnormalisatie
Europese vs. Amerikaanse getalnotaties zijn de lastigste ambiguïteit:
| Waarde | VS-interpretatie | EU-interpretatie |
|---|---|---|
"1,234" |
1234 | 1.234 |
"1.234" |
1.234 | 1234 |
"1,234.56" |
1234.56 | Ongeldig |
"1.234,56" |
Ongeldig | 1234.56 |
Mijn heuristiek voor formaatdetectie:
function normalizeNumber(value) {
if (value == null) return value;
let str = String(value).trim();
// Verwijder valutasymbolen en witruimte
str = str.replace(/[$€£¥\s]/g, "");
// Percentage afhandelen
const isPercent = str.endsWith("%");
if (isPercent) str = str.slice(0, -1);
// Detecteer formaat door scheidingstekens te analyseren
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) {
// Gewoon integer: "1234"
normalized = str;
} else if (commaCount === 0 && dotCount === 1) {
// "1.234" (EU duizendtal) of "1.23" (decimaal)
// Heuristiek: 3 cijfers na punt = duizendtalscheidingsteken
const afterDot = str.slice(lastDot + 1);
if (afterDot.length === 3 && /^\d+$/.test(afterDot)) {
normalized = str.replace(".", "");
} else {
normalized = str;
}
} else if (commaCount === 1 && dotCount === 0) {
// "1,234" (VS duizendtal) of "1,23" (EU decimaal)
const afterComma = str.slice(lastComma + 1);
if (afterComma.length === 3 && /^\d+$/.test(afterComma)) {
normalized = str.replace(",", "");
} else {
normalized = str.replace(",", ".");
}
} else if (lastDot > lastComma) {
// "1,234.56" - VS-formaat
normalized = str.replace(/,/g, "");
} else if (lastComma > lastDot) {
// "1.234,56" - EU-formaat
normalized = str.replace(/\./g, "").replace(",", ".");
} else {
// Ambigu, geef ongewijzigd terug
return value;
}
const num = parseFloat(normalized);
if (Number.isNaN(num)) return value;
return isPercent ? num / 100 : num;
}
Kernpunt: De positie en het aantal scheidingstekens lost de meeste ambiguïteit op. Het laatste scheidingsteken is meestal het decimaalscheidingsteken.
Boolean-detectie (Met Valkuilen)
Duidelijke booleans: "true", "false", "yes", "no", "ja", "nee"
Maar hoe zit het met "0" en "1"?
// Problematisch: Dit converteert belastingtarieven naar booleans
// Kolom: [0, 0.05, 0.1, 0.2] -> [false, 0.05, 0.1, 0.2]
Mijn oplossing: Converteer puur numerieke waarden niet naar booleans.
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; // Sla header over
return row.map(cell => {
if (cell == null) return cell;
const cellStr = String(cell).toLowerCase().trim();
// Sla over als puur numeriek (voorkomt 0 -> false in getalkolommen)
if (/^-?\d+(\.\d+)?$/.test(cellStr)) {
return cell;
}
if (trueValues.has(cellStr)) return "true";
if (falseValues.has(cellStr)) return "false";
return cell;
});
});
}
Null-waarde-afhandeling
Webdata gebruikt veel representaties voor "geen waarde":
-
""(lege string) -
"N/A","n/a","NA" -
"-","--" -
"null","NULL" -
"none","None" -
"."(komt voor in overheidsdata)
Configureerbare null-detectie:
function applyNullValues(rows, nullPatterns) {
const nullSet = new Set(nullPatterns.map(v => v.toLowerCase().trim()));
return rows.map((row, rowIndex) => {
if (rowIndex === 0) return row; // Sla header over
return row.map(cell => {
if (cell == null) return null;
const cellStr = String(cell).toLowerCase().trim();
return nullSet.has(cellStr) ? null : cell;
});
});
}
// Gebruik
applyNullValues(rows, ["N/A", "n/a", "-", "--", "null", "none", "."]);
De Pipeline
Volledige type-inferentie- en opschoningspipeline:
function cleanTable(tableInfo, config) {
let rows = cloneRows(tableInfo.rows);
// 1. Trim alle strings
if (config.trimStrings) {
rows = applyTrimStrings(rows);
}
// 2. Converteer null-patronen naar werkelijke null
if (config.nullValues?.length) {
rows = applyNullValues(rows, config.nullValues);
}
// 3. Normaliseer booleans (vóór getallen, om 0->false te voorkomen)
if (config.booleans) {
rows = applyBooleanNormalization(rows, config.booleans);
}
// 4. Normaliseer getallen (handelt EU/VS-formaten af)
if (config.normalizeNumbers) {
rows = applyNumberNormalization(rows);
}
// 5. Normaliseer datums (optioneel, formaat-specifiek)
if (config.normalizeDates) {
rows = applyDateNormalization(rows, config.dateFormat);
}
return { ...tableInfo, rows };
}
Randgevallen Testen
Type-inferentie heeft veel randgevallen. Ik onderhoud een testsuite:
// Getalnormalisatietests
assertEqual(normalizeNumber("1,234.56"), 1234.56); // VS-formaat
assertEqual(normalizeNumber("1.234,56"), 1234.56); // EU-formaat
assertEqual(normalizeNumber("€1.234,56"), 1234.56); // Met valutasymbool
assertEqual(normalizeNumber("45,5%"), 0.455); // Percentage
assertEqual(normalizeNumber("1.234"), 1234); // EU duizendtal
assertEqual(normalizeNumber("1.23"), 1.23); // Decimaal
// Boolean-tests
assertEqual(inferValueType("Yes"), DATA_TYPES.BOOLEAN);
assertEqual(inferValueType("0"), DATA_TYPES.INTEGER); // NIET boolean
assertEqual(inferValueType("0.5"), DATA_TYPES.NUMBER); // NIET boolean
Integratie met Export
Bij export naar SQL gebruik je de afgeleide types:
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";
}
});
}
Lessen Geleerd
Kolomcontext overtreft celanalyse. Een enkele
"1"is ambigu; een kolom integers niet.Wees conservatief met conversie. Het is beter iets als string te laten dan het te beschadigen met een verkeerde conversie.
Maak het configureerbaar. Verschillende domeinen hebben verschillende null-waarden, boolean-representaties en getalnotaties.
Test met echte data. Synthetische tests missen de chaos van werkelijke webtabellen.
Dit systeem drijft de dataopschoning in HTML Table Exporter aan. Bekijk hoe het kopiëren van tabellen naar Excel vaak resulteert in kapotte opmaak die goede type-inferentie kan oplossen.
De PRO-versie laat gebruikers deze regels per exportprofiel configureren. Meer informatie op gauchogrid.com/nl/html-table-exporter of probeer het in de Chrome Web Store.
Bouw je type-inferentie voor een ander domein? Ik hoor graag over jouw randgevallen.
Top comments (0)