HTML-Tabellen zu parsen scheint unkompliziert – bis man auf echte Daten trifft. Wikipedia-Tabellen haben Navigationszeilen. Finanz-Websites nutzen komplexe Rowspans. Sport-Statistik-Seiten verschachteln Überschriften zwei Ebenen tief.
Beim Entwickeln von HTML Table Exporter, einem Tabellen-Extraktions-Tool, das auf Tausenden verschiedener Websites eingesetzt wird, habe ich die Sonderfälle katalogisiert, die die meisten Parser sprengen. Hier zeige ich, wie man jeden davon handhabt.
Problem 1: Rowspan-Expansion
Eine Zelle mit rowspan="3" belegt vertikalen Platz in der aktuellen Zeile und den nächsten zwei Zeilen. Wenn man naiv durch row.cells iteriert, verschieben sich die Spalten.
Die fehlerhafte Ausgabe:
| Land | 2020 | 2021 | 2022 | <- Überschrift
| USA | 100 | 200 | 300 | <- Erwartet
| 150 | 250 | 350 | <- Fehlendes "USA" (Rowspan-Fortsetzung)
Die Lösung: Belegte Positionen in einem virtuellen Raster nachverfolgen.
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 => {
// Nächste unbelegte Spalte finden
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;
// Alle Zellen markieren, die dieses Element überspannt
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;
});
});
// Zeilenlängen normalisieren
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;
});
}
Entscheidende Erkenntnis: Das virtuelle Raster ist die Wahrheitsquelle. Die DOM-Zellen sind nur Anweisungen zu dessen Befüllung.
Problem 2: Verschachtelte Tabellen
Wikipedia-Infoboxen enthalten oft Tabellen innerhalb von Tabellenzellen. Ein rekursiver Ansatz extrahiert Müll:
<table>
<tr>
<td>Land</td>
<td>
<table> <!-- Verschachtelt! -->
<tr><td>Bevölkerung</td><td>330M</td></tr>
</table>
</td>
</tr>
</table>
Erkennungsstrategie: Prüfen, ob der Vorfahre einer Tabelle ebenfalls eine Tabelle ist.
function isNestedTable(table) {
let parent = table.parentElement;
while (parent) {
if (parent.tagName === "TABLE") {
return true;
}
parent = parent.parentElement;
}
return false;
}
// Beim Scannen einer Seite
function getTopLevelTables() {
const all = document.querySelectorAll("table");
return Array.from(all).filter(t => !isNestedTable(t));
}
Aber was ist mit dem Inhalt der verschachtelten Tabelle?
Für die äußere Tabelle reduziere ich verschachtelte Tabellen auf ihren Textinhalt:
function extractCellText(cell) {
const clone = cell.cloneNode(true);
// Verschachtelte Tabellen entfernen (ihr Text ist bereits über textContent enthalten)
clone.querySelectorAll("table").forEach(t => t.remove());
// Unsichtbare Elemente entfernen
clone.querySelectorAll("style, script").forEach(el => el.remove());
return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Problem 3: Wikipedia-Navigationszeilen
Wikipedia-Tabellen beginnen oft mit einer Navigationszeile:
| v t e Liste der Länder nach Bevölkerung |
| Rang | Land | Bevölkerung |
| 1 | China | 1,4 Mrd. |
Diese „v t e"-Zeile (Anzeigen/Diskussion/Bearbeiten-Links) sind keine Daten – das ist UI. Ein Parser, der sie als Kopfzeile behandelt, produziert Müll.
Eine praktische Anleitung zum Umgang mit komplexen Tabellen finden Sie unter HTML-Tabellen-Scraper: Die 5 besten Chrome-Erweiterungen.
Erkennung:
function isWikipediaNavRow(row) {
const firstCell = row[0] || "";
// Häufige Muster für Navigationszeilen
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; // Überschrift ist die nächste Zeile
}
}
return 0; // Standard: Erste Zeile ist Überschrift
}
Problem 4: Titelzeilen (gesamte Breite überspannend)
Manche Tabellen haben eine Titelzeile, die die gesamte Breite umspannt:
<table>
<tr><td colspan="4">Quartalsumsatz (Mio. €)</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>
Nach der Rowspan-Expansion wird die erste Zeile zu ["Quartalsumsatz...", "Quartalsumsatz...", ...] – derselbe Wert wiederholt.
Erkennung:
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()));
// Titelzeilen-Merkmale:
// 1. Nur ein eindeutiger Wert (wiederholt durch Colspan)
// 2. Nächste Zeile hat mehrere eindeutige Werte (eigentliche Überschriften)
// 3. Der einzelne Wert ist langer Text (typischerweise >30 Zeichen)
return (
uniqueValues.size === 1 &&
nextUniqueValues.size > 2 &&
row[0] && row[0].length > 30
);
}
Problem 5: Gruppierte Spaltenüberschriften (FBRef-Stil)
Sport-Statistik-Seiten wie FBRef verwenden zweistufige Überschriften:
| | | Spielzeit | Leistung |
| Spieler | Nation | SP | Startelf | Min | Tore | Ast | xG |
| Haaland | Norw. | 35 | 33 | 2950| 36 | 8 | 32 |
Die erste Zeile enthält Gruppennamen. Die zweite die eigentlichen Spaltennamen. Beide sind „Überschriften."
Die Herausforderung: Nach der Colspan-Expansion sieht Zeile 0 so aus:
["", "", "Spielzeit", "Spielzeit", "Spielzeit", "Leistung", "Leistung", "Leistung"]
Erkennungs-Heuristik:
function isGroupHeaderRow(row, nextRow) {
if (!row || !nextRow || row.length !== nextRow.length) return false;
// Zählen, wie viele Zellen denselben Wert wie ihre Nachbarzelle haben
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);
// Gruppen-Header-Zeilen haben typischerweise 40%+ wiederholte Werte
// UND die nächste Zeile hat mehr eindeutige Werte
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;
}
Zusammenführung von Gruppen- und Unterüberschriften:
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}`;
});
}
// Ergebnis: ["Spieler", "Nation", "Spielzeit - SP", "Spielzeit - Startelf", ...]
Problem 6: Horizontal duplizierte Tabellen
Wikipedia-Bevölkerungstabellen haben oft diese Struktur:
| Rang | Name | Bev. | Rang | Name | Bev. |
| 1 | Tokyo | 37M | 11 | Paris | 11M |
| 2 | Delhi | 32M | 12 | Kairo | 10M |
Das ist EINE logische Tabelle, die in zwei Spalten dargestellt wird, um vertikalen Platz zu sparen.
Erkennung:
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);
// Prüfen, ob die zweite Hälfte mit der ersten übereinstimmt
const matches = firstHalf.every((h, i) =>
h.toLowerCase() === secondHalf[i]?.toLowerCase()
);
if (matches) {
return { detected: true, repeatCount: 2, baseColumns: half };
}
return null;
}
Normalisierung: Jede Zeile aufteilen und vertikal stapeln:
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];
// Erste Hälfte
normalizedRows.push(row.slice(0, baseColumns));
// Zweite Hälfte (wenn nicht leer)
const secondHalf = row.slice(baseColumns, baseColumns * 2);
if (secondHalf.some(cell => cell.trim())) {
normalizedRows.push(secondHalf);
}
}
return normalizedRows;
}
Der kombinierte Algorithmus
Praxistaugliches Parsen erfordert die Prüfung all dieser Fälle in Reihenfolge:
function parseTable(table) {
// 1. Rowspans/Colspans zu virtuellem Raster expandieren
let matrix = expandRowspans(table);
// 2. Nav-/Titelzeilen erkennen und überspringen
const headerIndex = detectHeaderRowIndex(matrix);
if (headerIndex > 0) {
matrix = matrix.slice(headerIndex);
}
// 3. Gruppierte Überschriften behandeln (FBRef-Stil)
const groupedHeaders = detectGroupedColumnHeaders(matrix);
if (groupedHeaders) {
const mergedHeaders = mergeGroupAndSubHeaders(matrix[0], matrix[1]);
matrix = [mergedHeaders, ...matrix.slice(2)];
}
// 4. Horizontale Duplikation behandeln
const duplication = detectHorizontalDuplication(matrix[0]);
if (duplication) {
matrix = normalizeHorizontallyDuplicatedTable(matrix, duplication.baseColumns);
}
return matrix;
}
Diese Sonderfälle testen
Jedes der obigen Muster stammt aus einem echten Bug-Report. Ich pflege eine Test-Suite mit HTML-Fixtures für jeden Fall:
// Test: Wikipedia-Navigationszeile
const navRowHtml = `
<table>
<tr><td colspan="3">v t e Länder</td></tr>
<tr><td>Rang</td><td>Land</td><td>Bev.</td></tr>
<tr><td>1</td><td>China</td><td>1,4 Mrd.</td></tr>
</table>
`;
const result = parseTable(parseHtml(navRowHtml));
assert(result[0][0] === "Rang"); // Überschrift korrekt erkannt
assert(result[1][1] === "China"); // Daten korrekt ausgerichtet
Die Test-Suite hat 24 Fälle, die Kombinationen dieser Muster abdecken. Neue Bug-Reports werden zu neuen Testfällen.
Selbst ausprobieren
Wenn Sie Tabellenextraktion bauen, hoffe ich, dass Ihnen das Debugging-Zeit spart. Wenn Sie einfach nur Tabellen exportieren möchten, ohne Code zu schreiben, behandelt HTML Table Exporter all diese Fälle automatisch.
Mehr erfahren auf gauchogrid.com/de/html-table-exporter oder kostenlos im Chrome Web Store testen.
Eine Tabelle gefunden, die Ihren Parser sprengt? Teilen Sie die URL – ich sammle diese Sonderfälle.
Top comments (0)