HTML-Tabellen sehen einfach aus. <table>, <tr>, <td>. Was soll da schiefgehen?
Nachdem ich HTML Table Exporter gebaut habe – ein Tabellenexport-Tool, das Tausende von Real-World-Tabellen verarbeitet hat – kann ich sagen: eine ganze Menge. Dieser Beitrag behandelt die Edge Cases, die naive Parser zum Scheitern bringen, und wie man damit umgeht.
Der trügerisch einfache Fall
Eine perfekte Tabelle sieht so aus:
<table>
<thead>
<tr>
<th>Name</th>
<th>Umsatz</th>
</tr>
</thead>
<tbody>
<tr>
<td>Acme GmbH</td>
<td>1,2 Mio. €</td>
</tr>
</tbody>
</table>
Das Parsen ist trivial:
const rows = table.querySelectorAll('tr');
const data = [...rows].map(row =>
[...row.querySelectorAll('td, th')].map(cell => cell.textContent.trim())
);
Fertig, oder? Nicht mal annähernd.
Problem 1: Verbundene Zellen (colspan/rowspan)
Echte Tabellen haben verbundene Zellen. Sehr viele davon.
<tr>
<td rowspan="3">Q1 2024</td>
<td>Januar</td>
<td>100.000 €</td>
</tr>
<tr>
<td>Februar</td>
<td>120.000 €</td>
</tr>
<tr>
<td>März</td>
<td>90.000 €</td>
</tr>
Beim naiven Parsen erhält man:
Zeile 1: ["Q1 2024", "Januar", "100.000 €"]
Zeile 2: ["Februar", "120.000 €"] // Erste Spalte fehlt!
Zeile 3: ["März", "90.000 €"] // Erste Spalte fehlt!
Die Lösung: Eine Positionsmatrix aufbauen
Man muss nachverfolgen, welche Zellen durch Rowspans aus vorherigen Zeilen „belegt" sind:
function parseTableWithMergedCells(table) {
const rows = table.querySelectorAll('tr');
const matrix = [];
const rowspanTracker = []; // Aktive Rowspans pro Spalte tracken
rows.forEach((row, rowIndex) => {
matrix[rowIndex] = [];
let colIndex = 0;
// Spalten überspringen, die durch vorherige Rowspans belegt sind
while (rowspanTracker[colIndex] > 0) {
matrix[rowIndex][colIndex] = matrix[rowIndex - 1]?.[colIndex] || '';
rowspanTracker[colIndex]--;
colIndex++;
}
row.querySelectorAll('td, th').forEach(cell => {
// Belegte Spalten überspringen
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 füllen
for (let c = 0; c < colspan; c++) {
matrix[rowIndex][colIndex] = value;
// Rowspan für zukünftige Zeilen tracken
if (rowspan > 1) {
rowspanTracker[colIndex] = rowspan - 1;
}
colIndex++;
}
});
});
return matrix;
}
Das ist vereinfacht – die echte Implementierung muss auch verschachtelte Rowspans innerhalb von Colspans handhaben, was schnell unübersichtlich wird.
Problem 2: Tabellen, die keine Datentabellen sind
Nicht jedes <table> enthält Daten. Viele Websites (ja, auch heute noch) verwenden Tabellen fürs Layout:
<table>
<tr>
<td><nav>Menü hier</nav></td>
<td><main>Inhalt hier</main></td>
</tr>
</table>
Oder für Formulare:
<table>
<tr>
<td><label>E-Mail:</label></td>
<td><input type="email"></td>
</tr>
</table>
Die Lösung: Heuristiken
Ich verwende mehrere Signale, um „echte" Datentabellen zu erkennen:
function isDataTable(table) {
const rows = table.querySelectorAll('tr');
const cells = table.querySelectorAll('td, th');
// Zu wenige Zeilen oder Zellen
if (rows.length < 2 || cells.length < 4) return false;
// Enthält Formularelemente (wahrscheinlich ein Formular-Layout)
if (table.querySelector('input, select, textarea, button')) return false;
// Überwiegend Navigationslinks
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;
// Spaltenkonsistenz prüfen
const colCounts = [...rows].map(row =>
row.querySelectorAll('td, th').length
);
const variance = Math.max(...colCounts) - Math.min(...colCounts);
if (variance > 3) return false; // Inkonsistente Spalten = wahrscheinlich Layout
return true;
}
Keine dieser Heuristiken ist perfekt. Edge Cases wird es immer geben.
Problem 3: Versteckte Inhalte
Zellen enthalten oft mehr als den sichtbaren Text:
<td>
<span class="value">1.234</span>
<span class="sort-key" style="display:none">1234</span>
</td>
Wikipedia macht das häufig bei sortierbaren Tabellen. Wenn man einfach textContent nimmt, bekommt man „1.234 1234".
Die Lösung: Nur sichtbaren Text extrahieren
function getVisibleText(element) {
// Klonen, um das Original nicht zu verändern
const clone = element.cloneNode(true);
// Versteckte Elemente entfernen
clone.querySelectorAll('[style*="display: none"], [style*="display:none"], .hidden, [hidden]').forEach(el => el.remove());
// Optional: computed style für dynamisch versteckte Elemente prüfen
// (aufwändiger, sparsam einsetzen)
return clone.textContent.trim();
}
Problem 4: Zahlen, die keine Zahlen sind
„1.234,56 €" ist eine Zahl. „$1,234.56" (US-Format) ebenso. „(1.234)" (buchhalterisch negativ) auch. Und „1.234 Mio." (mit Suffix) auch.
Die Tabellenkalkulation braucht echte Zahlen zum Rechnen.
Die Lösung: Locale-bewusstes Parsen
function parseNumber(value) {
if (!value || typeof value !== 'string') return value;
// Währungssymbole und Whitespace entfernen
let cleaned = value.replace(/[$€£¥₹\s]/g, '').trim();
// Buchhalterisch negativ: (1.234) -> -1234
if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
cleaned = '-' + cleaned.slice(1, -1);
}
// Suffixe: 1,5M, 2,3Mrd, 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()];
}
// Europäisches vs. US-Format erkennen
// Europäisch: 1.234,56 (Punkt für Tausender, Komma für Dezimal)
// US: 1,234.56 (Komma für Tausender, Punkt für Dezimal)
const lastComma = cleaned.lastIndexOf(',');
const lastDot = cleaned.lastIndexOf('.');
if (lastComma > lastDot && lastComma > cleaned.length - 4) {
// Europäisches Format
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
} else {
// US-Format
cleaned = cleaned.replace(/,/g, '');
}
let num = parseFloat(cleaned);
if (multiplier) num *= multiplier;
return isNaN(num) ? value : num;
}
Das deckt vielleicht 90 % der Fälle ab. Die restlichen 10 % werden dich überraschen.
Problem 5: Zeichenkodierungs-Hölle
Man sollte meinen, UTF-8 hätte das gelöst. Hat es nicht.
Echte Tabellen enthalten:
- Geschützte Leerzeichen (
/\u00A0), die wie Leerzeichen aussehen, aber keine sind - Zero-Width-Zeichen, die String-Vergleiche kaputt machen
- Windows-1252-Zeichen, die beim Konvertieren zu UTF-8 verstümmelt wurden
- Emoji, die ältere Parser zum Absturz bringen
- Right-to-Left-Markierungen in mehrsprachigen Tabellen
Die Lösung: Alles normalisieren
function normalizeText(text) {
return text
// Unicode normalisieren (zusammengesetzte vs. zerlegte Zeichen)
.normalize('NFC')
// Geschützte Leerzeichen durch reguläre ersetzen
.replace(/\u00A0/g, ' ')
// Zero-Width-Zeichen entfernen
.replace(/[\u200B-\u200D\uFEFF]/g, '')
// Whitespace normalisieren
.replace(/\s+/g, ' ')
.trim();
}
Und beim Export als CSV für Excel den UTF-8 BOM voranstellen:
const BOM = '\uFEFF';
const csvContent = BOM + generateCSV(data);
Ohne BOM interpretiert Excel deine UTF-8-Datei möglicherweise als Windows-1252 und verstümmelt Sonderzeichen.
Problem 6: Verschachtelte Tabellen
Ja, Tabellen in Tabellen. Meist fürs Layout, aber manchmal auch für Daten:
<table>
<tr>
<td>Produkt A</td>
<td>
<table>
<tr><td>Größe S</td><td>10 €</td></tr>
<tr><td>Größe M</td><td>12 €</td></tr>
</table>
</td>
</tr>
</table>
Die Lösung: Strategie wählen
Optionen:
- Flatten: Verschachtelte Tabelle zu Text konvertieren („Größe S: 10 €, Größe M: 12 €")
- Separat extrahieren: Verschachtelte Tabellen als eigene Exporte behandeln
- Zeilen erweitern: Mehrere Elternzeilen erstellen, eine pro verschachtelter Zeile
Ich habe mich für Option 2 (separat extrahieren) entschieden, mit Option 1 als Fallback für tief verschachtelte Fälle. Es gibt keine perfekte Lösung – es hängt vom Anwendungsfall ab.
Die Realität
Nach der Behandlung all dieser Fälle umfasst mein Tabellen-Parser circa 800 Zeilen JavaScript. Und er handhabt trotzdem nicht alles perfekt.
Ein paar ehrliche Erkenntnisse:
- Kein Parser ist perfekt. Real-World-HTML ist chaotisch.
- Heuristiken versagen. Man braucht immer Notausgänge für Nutzer.
- Performance zählt. Manche Seiten haben 50+ Tabellen. Das Parsen muss schnell sein.
- Edge Cases sind unendlich. Liefere etwas, das für 95 % der Fälle funktioniert, dann iteriere.
Tools und Ressourcen
Wenn du etwas Ähnliches baust:
- SheetJS (xlsx) – Solide Bibliothek zum Erzeugen von Excel-Dateien
- Papa Parse – Schnelles CSV-Parsen und -Erzeugen
-
Chrome DevTools –
$('table')in der Konsole zum schnellen Inspizieren von Tabellen
Für eine Schritt-für-Schritt-Anleitung zum Tabellenexport, siehe unseren Leitfaden zur besten Chrome-Erweiterung zum Kopieren von Tabellen nach Excel.
Oder wenn du einfach nur Tabellen exportieren willst, ohne selbst etwas zu bauen: Ich habe HTML Table Exporter genau deshalb entwickelt, weil ich es leid war, Einmal-Scraper zu schreiben. Es behandelt alle oben genannten Edge Cases.
Mehr erfahren auf gauchogrid.com/de/html-table-exporter oder kostenlos im Chrome Web Store ausprobieren.
Welche merkwürdigen Tabellen-Edge-Cases sind dir begegnet? Ich bin immer auf der Suche nach neuen Testfällen, um meinen Parser zu brechen.
Top comments (0)