Het parsen van HTML-tabellen lijkt eenvoudig — tot je echte data tegenkomt. Wikipedia-tabellen bevatten navigatierijen. Financiële sites gebruiken complexe rowspans. Sportstatistiekensites hebben headers die twee niveaus diep genest zijn.
Na het bouwen van HTML Table Exporter, een tabelextractietool die op duizenden verschillende sites wordt gebruikt, heb ik de randgevallen gecatalogiseerd die de meeste parsers breken. Hier lees je hoe je elk geval kunt aanpakken.
Probleem 1: Rowspan-uitbreiding
Een cel met rowspan="3" neemt verticale ruimte in beslag in de huidige rij en de volgende twee rijen. Als je naïef door row.cells itereert, raken je kolommen niet meer uitgelijnd.
De kapotte output:
| Land | 2020 | 2021 | 2022 | <- Header
| VS | 100 | 200 | 300 | <- Verwacht
| 150 | 250 | 350 | <- "VS" ontbreekt (rowspan gaat door)
De oplossing: Houd bezette posities bij in een virtueel grid.
function expandRowspans(table) {
const rows = Array.from(table.rows);
const grid = [];
rows.forEach((rowEl, rowIndex) => {
if (!grid[rowIndex]) grid[rowIndex] = [];
let colIndex = 0;
Array.from(rowEl.cells).forEach(cell => {
// Zoek de volgende vrije kolom
while (grid[rowIndex][colIndex] !== undefined) {
colIndex++;
}
const text = cell.textContent.trim();
const rowSpan = parseInt(cell.rowSpan, 10) || 1;
const colSpan = parseInt(cell.colSpan, 10) || 1;
// Markeer alle cellen die dit element overspant
for (let r = 0; r < rowSpan; r++) {
const targetRow = rowIndex + r;
if (!grid[targetRow]) grid[targetRow] = [];
for (let c = 0; c < colSpan; c++) {
grid[targetRow][colIndex + c] = text;
}
}
colIndex += colSpan;
});
});
// Normaliseer rijlengtes
const maxCols = Math.max(...grid.map(r => r.length));
return grid.map(row => {
const normalized = new Array(maxCols).fill("");
row.forEach((val, i) => normalized[i] = val ?? "");
return normalized;
});
}
Kernpunt: Het virtuele grid is de bron van waarheid. De DOM-cellen zijn slechts instructies om het te vullen.
Probleem 2: Geneste Tabellen
Wikipedia-infoboxen bevatten vaak tabellen binnen tabelcellen. Een recursieve aanpak levert rommel op:
<table>
<tr>
<td>Land</td>
<td>
<table> <!-- Genest! -->
<tr><td>Bevolking</td><td>330M</td></tr>
</table>
</td>
</tr>
</table>
Detectiestrategie: Controleer of een voorouder van de tabel ook een tabel is.
function isNestedTable(table) {
let parent = table.parentElement;
while (parent) {
if (parent.tagName === "TABLE") {
return true;
}
parent = parent.parentElement;
}
return false;
}
// Bij het scannen van een pagina
function getTopLevelTables() {
const all = document.querySelectorAll("table");
return Array.from(all).filter(t => !isNestedTable(t));
}
Maar hoe zit het met de inhoud van de geneste tabel?
Voor de buitenste tabel maak ik geneste tabellen plat tot hun tekstinhoud:
function extractCellText(cell) {
const clone = cell.cloneNode(true);
// Verwijder geneste tabellen (hun tekst zit al in textContent)
clone.querySelectorAll("table").forEach(t => t.remove());
// Verwijder onzichtbare elementen
clone.querySelectorAll("style, script").forEach(el => el.remove());
return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Probleem 3: Wikipedia-navigatierijen
Wikipedia-tabellen beginnen vaak met een navigatierij:
| v t e Lijst van landen op bevolking |
| Rang | Land | Bevolking |
| 1 | China | 1,4 mld |
Die "v t e"-rij (Bekijken/Overleg/Bewerken) is geen data — het is UI. Een parser die dit als headerrij behandelt, produceert rommel.
Voor een praktische handleiding over het omgaan met Wikipedia-tabellen, zie De 5 Beste Chrome-extensies voor het Exporteren van Tabellen.
Detectie:
function isWikipediaNavRow(row) {
const firstCell = row[0] || "";
// Veelvoorkomende patronen voor navigatierijen
const patterns = [
/^v\s+t\s+e\s/i, // "v t e "
/^\s*v\s*\|\s*t\s*\|\s*e/i, // "v | t | e"
/^\[v\]\s*\[t\]\s*\[e\]/i // "[v] [t] [e]"
];
return patterns.some(p => p.test(firstCell));
}
function detectHeaderRowIndex(matrix) {
for (let i = 0; i < Math.min(3, matrix.length - 1); i++) {
if (isWikipediaNavRow(matrix[i])) {
return i + 1; // Header is de volgende rij
}
}
return 0; // Standaard: eerste rij is header
}
Probleem 4: Titelrijen (Alle Kolommen Overspannend)
Sommige tabellen hebben een titelrij die de volledige breedte overspant:
<table>
<tr><td colspan="4">Kwartaalomzet (€ miljoenen)</td></tr>
<tr><td>Q1</td><td>Q2</td><td>Q3</td><td>Q4</td></tr>
<tr><td>100</td><td>120</td><td>115</td><td>130</td></tr>
</table>
Na rowspan-uitbreiding wordt de eerste rij ["Kwartaalomzet...", "Kwartaalomzet...", ...] — dezelfde waarde herhaald.
Detectie:
function isTitleRow(row, nextRow) {
if (!row || !nextRow) return false;
const uniqueValues = new Set(row.filter(v => v.trim()));
const nextUniqueValues = new Set(nextRow.filter(v => v.trim()));
// Kenmerken van een titelrij:
// 1. Slechts één unieke waarde (herhaald via colspan)
// 2. Volgende rij heeft meerdere unieke waarden (werkelijke headers)
// 3. De enkele waarde is lange tekst (>30 tekens, typisch)
return (
uniqueValues.size === 1 &&
nextUniqueValues.size > 2 &&
row[0] && row[0].length > 30
);
}
Probleem 5: Gegroepeerde Kolomheaders (FBREF-stijl)
Sportstatistiekensites zoals FBREF gebruiken headers met twee niveaus:
| | | Speeltijd | Prestaties |
| Speler | Land | WS | Basis | Min | Dls | Ast | xG |
| Haaland | Noorw. | 35 | 33 | 2950| 36 | 8 | 32 |
De eerste rij bevat groepsnamen. De tweede rij bevat de werkelijke kolomnamen. Beide zijn "headers."
De uitdaging: Na colspan-uitbreiding ziet rij 0 er zo uit:
["", "", "Speeltijd", "Speeltijd", "Speeltijd", "Prestaties", "Prestaties", "Prestaties"]
Detectieheuristieken:
function isGroupHeaderRow(row, nextRow) {
if (!row || !nextRow || row.length !== nextRow.length) return false;
// Tel hoeveel cellen dezelfde waarde hebben als hun buur
let repeatCount = 0;
for (let i = 1; i < row.length; i++) {
if (row[i] && row[i] === row[i-1]) repeatCount++;
}
const repeatRatio = repeatCount / (row.length - 1);
// Groepsheaderrijen hebben typisch 40%+ herhaalde waarden
// EN de volgende rij heeft meer unieke waarden
const uniqueInRow = new Set(row.filter(v => v.trim())).size;
const uniqueInNext = new Set(nextRow.filter(v => v.trim())).size;
return repeatRatio > 0.4 && uniqueInNext > uniqueInRow;
}
Groeps- en subheaders samenvoegen:
function mergeGroupAndSubHeaders(groupRow, subHeaderRow) {
return subHeaderRow.map((subHeader, idx) => {
const group = (groupRow[idx] || "").trim();
const sub = (subHeader || "").trim();
if (!group) return sub;
if (!sub) return group;
if (sub.toLowerCase() === group.toLowerCase()) return sub;
return `${group} - ${sub}`;
});
}
// Resultaat: ["Speler", "Land", "Speeltijd - WS", "Speeltijd - Basis", ...]
Probleem 6: Horizontaal Gedupliceerde Tabellen
Wikipedia-bevolkingstabellen hebben vaak deze structuur:
| Rang | Naam | Bev. | Rang | Naam | Bev. |
| 1 | Tokyo | 37M | 11 | Parijs | 11M |
| 2 | Delhi | 32M | 12 | Caïro | 10M |
Dit is ÉÉN logische tabel die in twee kolommen wordt weergegeven om verticale ruimte te besparen.
Detectie:
function detectHorizontalDuplication(headers) {
const half = Math.floor(headers.length / 2);
if (half < 2) return null;
const firstHalf = headers.slice(0, half);
const secondHalf = headers.slice(half, half * 2);
// Controleer of de tweede helft overeenkomt met de eerste
const matches = firstHalf.every((h, i) =>
h.toLowerCase() === secondHalf[i]?.toLowerCase()
);
if (matches) {
return { detected: true, repeatCount: 2, baseColumns: half };
}
return null;
}
Normalisatie: Splits elke rij en stapel ze verticaal:
function normalizeHorizontallyDuplicatedTable(matrix, baseColumns) {
const header = matrix[0].slice(0, baseColumns);
const normalizedRows = [header];
for (let i = 1; i < matrix.length; i++) {
const row = matrix[i];
// Eerste helft
normalizedRows.push(row.slice(0, baseColumns));
// Tweede helft (als niet leeg)
const secondHalf = row.slice(baseColumns, baseColumns * 2);
if (secondHalf.some(cell => cell.trim())) {
normalizedRows.push(secondHalf);
}
}
return normalizedRows;
}
Het Gecombineerde Algoritme
Parsen in de echte wereld vereist het controleren van al deze gevallen op volgorde:
function parseTable(table) {
// 1. Rowspans/colspans uitbreiden naar virtueel grid
let matrix = expandRowspans(table);
// 2. Navigatie-/titelrijen detecteren en overslaan
const headerIndex = detectHeaderRowIndex(matrix);
if (headerIndex > 0) {
matrix = matrix.slice(headerIndex);
}
// 3. Gegroepeerde headers verwerken (FBREF-stijl)
const groupedHeaders = detectGroupedColumnHeaders(matrix);
if (groupedHeaders) {
const mergedHeaders = mergeGroupAndSubHeaders(matrix[0], matrix[1]);
matrix = [mergedHeaders, ...matrix.slice(2)];
}
// 4. Horizontale duplicatie verwerken
const duplication = detectHorizontalDuplication(matrix[0]);
if (duplication) {
matrix = normalizeHorizontallyDuplicatedTable(matrix, duplication.baseColumns);
}
return matrix;
}
Deze Randgevallen Testen
Elk patroon hierboven komt uit een echte bugreport. Ik onderhoud een testsuite met HTML-fixtures voor elk geval:
// Test: Wikipedia-stijl navigatierij
const navRowHtml = `
<table>
<tr><td colspan="3">v t e Landen</td></tr>
<tr><td>Rang</td><td>Land</td><td>Bev.</td></tr>
<tr><td>1</td><td>China</td><td>1,4 mld</td></tr>
</table>
`;
const result = parseTable(parseHtml(navRowHtml));
assert(result[0][0] === "Rang"); // Header correct geïdentificeerd
assert(result[1][1] === "China"); // Data correct uitgelijnd
De testsuite heeft 24 gevallen die combinaties van deze patronen afdekken. Nieuwe bugrapporten worden nieuwe testgevallen.
Probeer Het Zelf
Als je tabelextractie bouwt, hoop ik dat dit je debugtijd bespaart. Als je gewoon tabellen wilt exporteren zonder code te schrijven, verwerkt HTML Table Exporter al deze gevallen automatisch.
Meer informatie op gauchogrid.com/nl/html-table-exporter of probeer het gratis in de Chrome Web Store.
Een tabel gevonden die je parser breekt? Deel de URL — ik verzamel deze randgevallen.
Top comments (0)