HTML-tabellen zien er simpel uit. <table>, <tr>, <td>. Wat kan er misgaan?
Na het bouwen van HTML Table Exporter, een tabelexport-tool die duizenden real-world tabellen heeft verwerkt, kan ik je vertellen: heel veel. Dit artikel behandelt de edge cases die naïeve parsers breken en hoe je ze aanpakt.
De Bedrieglijk Eenvoudige Case
Een perfecte tabel ziet er zo uit:
<table>
<thead>
<tr>
<th>Naam</th>
<th>Omzet</th>
</tr>
</thead>
<tbody>
<tr>
<td>Acme Inc</td>
<td>€1,2M</td>
</tr>
</tbody>
</table>
Dit parsen is triviaal:
const rows = table.querySelectorAll('tr');
const data = [...rows].map(row =>
[...row.querySelectorAll('td, th')].map(cell => cell.textContent.trim())
);
Klaar, toch? Nog lang niet.
Probleem 1: Samengevoegde Cellen (colspan/rowspan)
Echte tabellen hebben samengevoegde cellen. Heel veel.
<tr>
<td rowspan="3">Q1 2024</td>
<td>Januari</td>
<td>€100k</td>
</tr>
<tr>
<td>Februari</td>
<td>€120k</td>
</tr>
<tr>
<td>Maart</td>
<td>€90k</td>
</tr>
Als je dit naïef parset, krijg je:
Rij 1: ["Q1 2024", "Januari", "€100k"]
Rij 2: ["Februari", "€120k"] // Eerste kolom ontbreekt!
Rij 3: ["Maart", "€90k"] // Eerste kolom ontbreekt!
De Oplossing: Bouw een Positiematrix
Je moet bijhouden welke cellen "bezet" zijn door rowspans van vorige rijen:
function parseTableWithMergedCells(table) {
const rows = table.querySelectorAll('tr');
const matrix = [];
const rowspanTracker = []; // Actieve rowspans per kolom bijhouden
rows.forEach((row, rowIndex) => {
matrix[rowIndex] = [];
let colIndex = 0;
// Kolommen overslaan die bezet zijn door eerdere rowspans
while (rowspanTracker[colIndex] > 0) {
matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
rowspanTracker[colIndex]--;
colIndex++;
}
row.querySelectorAll('td, th').forEach(cell => {
// Bezette kolommen overslaan
while (rowspanTracker[colIndex] > 0) {
matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
rowspanTracker[colIndex]--;
colIndex++;
}
const colspan = parseInt(cell.getAttribute('colspan')) || 1;
const rowspan = parseInt(cell.getAttribute('rowspan')) || 1;
const value = cell.textContent.trim();
// Colspan vullen
for (let c = 0; c < colspan; c++) {
matrix[rowIndex][colIndex] = value;
// Rowspan bijhouden voor toekomstige rijen
if (rowspan > 1) {
rowspanTracker[colIndex] = rowspan - 1;
}
colIndex++;
}
});
});
return matrix;
}
Dit is vereenvoudigd—de echte implementatie moet ook geneste rowspans binnen colspans verwerken, en dat wordt al snel complex.
Probleem 2: Tabellen Die Geen Datatabellen Zijn
Niet elke <table> bevat data. Veel sites (ja, nog steeds in 2024) gebruiken tabellen voor layout:
<table>
<tr>
<td><nav>Menu hier</nav></td>
<td><main>Content hier</main></td>
</tr>
</table>
Of voor formulieren:
<table>
<tr>
<td><label>E-mail:</label></td>
<td><input type="email"></td>
</tr>
</table>
De Oplossing: Heuristieken
Ik gebruik meerdere signalen om "echte" datatabellen te detecteren:
function isDataTable(table) {
const rows = table.querySelectorAll('tr');
const cells = table.querySelectorAll('td, th');
// Te weinig rijen of cellen
if (rows.length < 2 || cells.length < 4) return false;
// Bevat formulierelementen (waarschijnlijk formulierlayout)
if (table.querySelector('input, select, textarea, button')) return false;
// Voornamelijk navigatielinks
const links = table.querySelectorAll('a');
const textContent = table.textContent.length;
const linkText = [...links].reduce((sum, a) => sum + a.textContent.length, 0);
if (linkText / textContent > 0.7) return false;
// Kolomconsistentie controleren
const colCounts = [...rows].map(row =>
row.querySelectorAll('td, th').length
);
const variance = Math.max(...colCounts) - Math.min(...colCounts);
if (variance > 3) return false; // Inconsistente kolommen = waarschijnlijk layout
return true;
}
Geen van deze is perfect. Er zullen altijd edge cases zijn.
Probleem 3: Verborgen Content
Cellen bevatten vaak meer dan zichtbare tekst:
<td>
<span class="value">1.234</span>
<span class="sort-key" style="display:none">1234</span>
</td>
Wikipedia doet dit veel voor sorteerbare tabellen. Als je gewoon textContent pakt, krijg je "1.234 1234".
De Oplossing: Alleen Zichtbare Tekst Extraheren
function getVisibleText(element) {
// Kloon om het origineel niet aan te passen
const clone = element.cloneNode(true);
// Verborgen elementen verwijderen
clone.querySelectorAll('[style*="display: none"], [style*="display:none"], .hidden, [hidden]').forEach(el => el.remove());
// Ook computed style controleren voor dynamisch verborgen elementen
// (duurder, spaarzaam gebruiken)
return clone.textContent.trim();
}
Probleem 4: Getallen Die Geen Getallen Zijn
"€1.234,56" is een getal. Net als "1,234.56" (Amerikaans formaat). Net als "(1.234)" (boekhoudkundig negatief). Net als "1.234 M" (met suffix).
Je spreadsheet heeft echte getallen nodig om te rekenen.
De Oplossing: Lokalisatiebewust Parsen
function parseNumber(value) {
if (!value || typeof value !== 'string') return value;
// Valutasymbolen en witruimte verwijderen
let cleaned = value.replace(/[$€£¥₹\s]/g, '').trim();
// Boekhoudkundig negatief: (1.234) -> -1234
if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
cleaned = '-' + cleaned.slice(1, -1);
}
// Suffixen: 1,5M, 2,3B, 100K
const suffixes = { 'K': 1e3, 'M': 1e6, 'B': 1e9, 'T': 1e12 };
const suffixMatch = cleaned.match(/([0-9.,]+)\s*([KMBT])$/i);
if (suffixMatch) {
cleaned = suffixMatch[1];
var multiplier = suffixes[suffixMatch[2].toUpperCase()];
}
// Europees vs Amerikaans formaat detecteren
// Europees: 1.234,56 (punt voor duizendtallen, komma voor decimalen)
// Amerikaans: 1,234.56 (komma voor duizendtallen, punt voor decimalen)
const lastComma = cleaned.lastIndexOf(',');
const lastDot = cleaned.lastIndexOf('.');
if (lastComma > lastDot && lastComma > cleaned.length - 4) {
// Europees formaat
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
} else {
// Amerikaans formaat
cleaned = cleaned.replace(/,/g, '');
}
let num = parseFloat(cleaned);
if (multiplier) num *= multiplier;
return isNaN(num) ? value : num;
}
Dit vangt misschien 90% van de gevallen op. De overige 10% zullen je verrassen.
Probleem 5: Tekencodering-Hel
Je zou denken dat UTF-8 dit heeft opgelost. Dat klopt niet.
Echte tabellen bevatten:
- Vaste spaties (
/\u00A0) die eruitzien als spaties maar dat niet zijn - Zero-width tekens die stringvergelijking breken
- Windows-1252 tekens die verminkt zijn naar UTF-8
- Emoji die oudere parsers breken
- Rechts-naar-links markeringen in meertalige tabellen
De Oplossing: Normaliseer Alles
function normalizeText(text) {
return text
// Unicode normaliseren (composed vs decomposed tekens)
.normalize('NFC')
// Vaste spaties vervangen door reguliere spaties
.replace(/\u00A0/g, ' ')
// Zero-width tekens verwijderen
.replace(/[\u200B-\u200D\uFEFF]/g, '')
// Witruimte normaliseren
.replace(/\s+/g, ' ')
.trim();
}
En bij het exporteren naar CSV voor Excel, de UTF-8 BOM toevoegen:
const BOM = '\uFEFF';
const csvContent = BOM + generateCSV(data);
Zonder de BOM kan Excel je UTF-8 bestand interpreteren als Windows-1252 en speciale tekens verminken.
Probleem 6: Geneste Tabellen
Ja, tabellen in tabellen. Meestal voor layout, maar soms voor data:
<table>
<tr>
<td>Product A</td>
<td>
<table>
<tr><td>Maat S</td><td>€10</td></tr>
<tr><td>Maat M</td><td>€12</td></tr>
</table>
</td>
</tr>
</table>
De Oplossing: Kies Je Strategie
Opties:
- Platslaan: Geneste tabel omzetten naar tekst ("Maat S: €10, Maat M: €12")
- Apart extraheren: Geneste tabellen als afzonderlijke exports behandelen
- Rijen uitbreiden: Meerdere bovenliggende rijen aanmaken, één per geneste rij
Ik koos voor optie 2 (apart extraheren) met optie 1 als fallback voor diep geneste gevallen. Er is geen perfect antwoord—het hangt af van de use case.
De Realiteit
Na al deze cases te hebben afgehandeld, is mijn tabelparser ~800 regels JavaScript. En hij vangt nog steeds niet alles perfect op.
Een paar harde waarheden:
- Geen parser is perfect. Real-world HTML is rommelig.
- Heuristieken falen. Je hebt altijd nooduitgangen voor gebruikers nodig.
- Performance telt. Sommige pagina's hebben meer dan 50 tabellen. Parsen moet snel zijn.
- Edge cases zijn oneindig. Ship iets dat werkt voor 95% van de gevallen, en itereer daarna.
Tools en Bronnen
Als je iets vergelijkbaars bouwt:
- SheetJS (xlsx) - Solide bibliotheek voor het genereren van Excel-bestanden
- Papa Parse - Snelle CSV-parsing en -generatie
-
Chrome DevTools -
$('table')in de console om tabellen snel te inspecteren
Of als je gewoon tabellen wilt exporteren zonder iets te bouwen: ik heb HTML Table Exporter gemaakt, specifiek omdat ik het zat was om eenmalige scrapers te schrijven. De extensie handelt alle bovenstaande edge cases af.
Meer informatie op gauchogrid.com/nl/html-table-exporter of probeer het gratis in de Chrome Web Store.
Welke rare tabel-edge-cases ben jij tegengekomen? Ik ben altijd op zoek naar nieuwe testcases om mijn parser te breken.
Top comments (0)