DEV Community

Cover image for Die versteckte Komplexität von HTML-Tabellen
circobit
circobit

Posted on

Die versteckte Komplexität von HTML-Tabellen

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())
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Jetzt bekommt man:

Zeile 0: ["Kategorie A", "Artikel 1", "10 €"]
Zeile 1: ["Kategorie A", "Artikel 2", "20 €"]
Zeile 2: ["Kategorie A", "Artikel 3", "30 €"]
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Problem 4: Leere Zellen und inkonsistente Spalten

Manche Zeilen haben weniger Zellen als andere. Manche Zellen sind komplett leer. Manche enthalten nur Whitespace oder &nbsp;.

// 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;
  });
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

Problem 6: Zeichenkodierung

Tabellen aus verschiedenen Quellen verwenden unterschiedliche Kodierungen:

  • &nbsp; (geschütztes Leerzeichen)
  • &mdash; und &ndash; (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();
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Vertraue niemals der DOM-Struktur. Baue deine eigene normalisierte Darstellung.

  2. Teste früh mit Edge Cases. Wikipedia, FBRef und Behördenseiten sind großartige Stresstests.

  3. Normalisiere alles. Whitespace, Encoding, Spaltenzahl – mach es konsistent.

  4. Header brauchen Erkennung. Geh nicht davon aus, dass Zeile 0 der Header ist.

  5. 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)