HTML-Tabellen sehen einfach aus. Zeilen und Zellen. Was soll da schiefgehen?
Alles, wie sich herausstellt.
Ich habe Monate damit verbracht, HTML Table Exporter zu bauen, ein Tool zur Tabellenextraktion, und dabei gelernt, dass HTML-Tabellen trügerisch komplex sind. Hier ist, was dir niemand sagt, wenn du anfängst, sie zu parsen.
Der naive Ansatz (der sofort bricht)
Dein erster Versuch sieht wahrscheinlich so aus:
function extractTable(table) {
return Array.from(table.rows).map(row =>
Array.from(row.cells).map(cell => cell.textContent.trim())
);
}
Sauber, einfach, falsch.
Das funktioniert für einfache Tabellen. Aber das echte Web ist voll von Tabellen, die diesen Code auf kreative Weise zum Scheitern bringen.
Problem 1: Rowspan und Colspan
HTML-Zellen können mehrere Zeilen oder Spalten überspannen:
<table>
<tr>
<td rowspan="3">Kategorie A</td>
<td>Artikel 1</td>
<td>10 €</td>
</tr>
<tr>
<td>Artikel 2</td>
<td>20 €</td>
</tr>
<tr>
<td>Artikel 3</td>
<td>30 €</td>
</tr>
</table>
Der naive Ansatz liefert:
Zeile 0: ["Kategorie A", "Artikel 1", "10 €"]
Zeile 1: ["Artikel 2", "20 €"] // Nur 2 Zellen!
Zeile 2: ["Artikel 3", "30 €"] // Nur 2 Zellen!
Den Zeilen 1 und 2 fehlt eine Spalte. Die Spaltenausrichtung ist jetzt kaputt.
Die Lösung: Ein virtuelles Grid aufbauen
Man muss tracken, welche Zellen durch Spans aus vorherigen Zeilen „belegt" sind:
function extractTableMatrix(table) {
const rows = Array.from(table.rows);
const grid = [];
rows.forEach((rowEl, rowIndex) => {
if (!grid[rowIndex]) grid[rowIndex] = [];
let colIndex = 0;
// Bereits durch Rowspans belegte Spalten überspringen
while (grid[rowIndex][colIndex] !== undefined) {
colIndex++;
}
Array.from(rowEl.cells).forEach((cell) => {
// An belegten Spalten vorbeirücken
while (grid[rowIndex][colIndex] !== undefined) {
colIndex++;
}
const text = cell.textContent.trim();
const rowSpan = cell.rowSpan || 1;
const colSpan = cell.colSpan || 1;
// Die gesamte Span-Region füllen
for (let r = 0; r < rowSpan; r++) {
for (let c = 0; c < colSpan; c++) {
const targetRow = rowIndex + r;
const targetCol = colIndex + c;
if (!grid[targetRow]) grid[targetRow] = [];
if (grid[targetRow][targetCol] === undefined) {
grid[targetRow][targetCol] = text;
}
}
}
colIndex += colSpan;
});
});
return grid;
}
Jetzt bekommt man:
Zeile 0: ["Kategorie A", "Artikel 1", "10 €"]
Zeile 1: ["Kategorie A", "Artikel 2", "20 €"]
Zeile 2: ["Kategorie A", "Artikel 3", "30 €"]
Der Wert der überspannenden Zelle wird in jede Position dupliziert, die sie einnimmt.
Problem 2: Verschachtelte Tabellen
Manche Websites verwenden Tabellen in Tabellen fürs Layout:
<table>
<tr>
<td>
<table>
<tr><td>Verschachtelter Inhalt</td></tr>
</table>
</td>
<td>Normale Zelle</td>
</tr>
</table>
cell.textContent erfasst alles, einschließlich des Inhalts verschachtelter Tabellen. cell.innerText könnte helfen, ist aber browserübergreifend inkonsistent.
Die Lösung: Klonen und Entfernen
function extractCellText(cell) {
if (!cell) return "";
// Klonen, um das DOM nicht zu verändern
const clone = cell.cloneNode(true);
// Unsichtbare und verschachtelte Inhalte entfernen
const removeSelectors = "style, script, noscript, template, table";
clone.querySelectorAll(removeSelectors).forEach(el => el.remove());
// Whitespace normalisieren
return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Problem 3: Header sind nicht immer Zeile 0
Viele Tabellen haben Titelzeilen, Navigationszeilen oder anderen Inhalt vor den eigentlichen Headern:
<table>
<tr>
<td colspan="3">Quartalsbericht Vertrieb 2024</td>
</tr>
<tr>
<th>Region</th>
<th>Q1</th>
<th>Q2</th>
</tr>
<tr>
<td>Nord</td>
<td>1,2 Mio. €</td>
<td>1,4 Mio. €</td>
</tr>
</table>
Wenn du davon ausgehst, dass Zeile 0 der Header ist, bekommst du „Quartalsbericht Vertrieb 2024" als Spaltenname.
Die Lösung: Header-Zeile erkennen
function detectHeaderRowIndex(matrix) {
for (let i = 0; i < Math.min(matrix.length - 1, 3); i++) {
const row = matrix[i];
const nextRow = matrix[i + 1];
// Eindeutige Werte zählen
const uniqueValues = new Set(row.filter(c => c && c.trim()));
const uniqueNext = new Set(nextRow.filter(c => c && c.trim()));
// Titelzeile: 1 eindeutiger Wert (alle Spalten überspannend), nächste Zeile hat mehr
const isTitleRow =
uniqueValues.size === 1 &&
uniqueNext.size > 1 &&
row[0]?.length > 30;
if (isTitleRow) {
return i + 1; // Header sind in der nächsten Zeile
}
}
return 0;
}
Problem 4: Leere Zellen und inkonsistente Spalten
Manche Zeilen haben weniger Zellen als andere. Manche Zellen sind komplett leer. Manche enthalten nur Whitespace oder .
// Alle Zeilen auf die gleiche Länge normalisieren
function normalizeMatrix(grid) {
const maxCols = Math.max(...grid.map(row => row.length));
return grid.map(row => {
const normalized = new Array(maxCols);
for (let i = 0; i < maxCols; i++) {
normalized[i] = row[i] ?? "";
}
return normalized;
});
}
Problem 5: Unsichtbarer Inhalt
Tabellen können Inhalte enthalten, die visuell versteckt, aber im DOM vorhanden sind:
-
<style>-Blöcke für scoped CSS -
<script>-Tags -
display: none-Elemente - Zero-Width-Zeichen
const invisibleSelectors = [
"style",
"script",
"noscript",
"template",
"[hidden]",
"[style*='display: none']",
"[style*='display:none']"
].join(", ");
clone.querySelectorAll(invisibleSelectors).forEach(el => el.remove());
Problem 6: Zeichenkodierung
Tabellen aus verschiedenen Quellen verwenden unterschiedliche Kodierungen:
-
(geschütztes Leerzeichen) -
—und–(Gedankenstriche) - Typografische vs. gerade Anführungszeichen
- UTF-8 vs. ISO-8859-1
Immer normalisieren:
function normalizeText(text) {
return text
.replace(/\u00a0/g, " ") // Geschütztes Leerzeichen
.replace(/[\u2018\u2019]/g, "'") // Typografische einfache Anführungszeichen
.replace(/[\u201c\u201d]/g, '"') // Typografische doppelte Anführungszeichen
.replace(/[\u2013\u2014]/g, "-") // Halb-/Geviertstrich
.trim();
}
Die komplette Pipeline
Nach der Behandlung all dieser Fälle sieht meine Extraktions-Pipeline so aus:
function extractTable(tableElement) {
// 1. Virtuelles Grid aufbauen (handhabt rowspan/colspan)
let matrix = extractTableMatrix(tableElement);
// 2. Auf konsistente Spaltenzahl normalisieren
matrix = normalizeMatrix(matrix);
// 3. Tatsächliche Header-Zeile finden
const headerIndex = detectHeaderRowIndex(matrix);
// 4. Titelzeilen entfernen
if (headerIndex > 0) {
matrix = matrix.slice(headerIndex);
}
// 5. Allen Zellentext bereinigen
matrix = matrix.map(row =>
row.map(cell => normalizeText(extractCellText(cell)))
);
return matrix;
}
Edge Cases aus der Praxis
Nach der Verarbeitung tausender Tabellen hier einige reale Edge Cases:
| Quelle | Problem | Lösung |
|---|---|---|
| Wikipedia | „v t e"-Navigationszeilen | Mustererkennung + überspringen |
| Wikipedia | Horizontal duplizierte Spalten | Wiederholende Header erkennen, entstapeln |
| FBRef | Gruppierte Spaltenheader | Gruppe + Sub-Header zusammenführen |
| Finanzseiten | Zahlen als 1.234.567,89
|
Locale-bewusste Normalisierung |
| Behördenseiten | Tabellen in <form>
|
Formular-Wrapper ignorieren |
Erkenntnisse
Vertraue niemals der DOM-Struktur. Baue deine eigene normalisierte Darstellung.
Teste früh mit Edge Cases. Wikipedia, FBRef und Behördenseiten sind großartige Stresstests.
Normalisiere alles. Whitespace, Encoding, Spaltenzahl – mach es konsistent.
Header brauchen Erkennung. Geh nicht davon aus, dass Zeile 0 der Header ist.
Spans sind der Feind. Der Grid-Aufbau-Ansatz handhabt sie sauber.
Wenn du dir die ganze Komplexität sparen willst, handhabt HTML Table Exporter diese Edge Cases automatisch. Ein Klick zum Exportieren jeder Tabelle als CSV, JSON oder Excel.
Für weitere Tipps und Anleitungen, besuche unseren Blog mit Tutorials zur Tabellenextraktion.
Mehr erfahren auf gauchogrid.com/de/html-table-exporter oder kostenlos im Chrome Web Store ausprobieren.
Aber wenn du deinen eigenen Parser baust, hoffe ich, dass dir das einige Stunden Debugging spart. Das Rabbit Hole ist tief.
Was ist die merkwürdigste Tabellenstruktur, die dir begegnet ist? Schreib es in die Kommentare. Ich sammle Edge Cases wie Sammelkarten.
Top comments (0)