DEV Community

Cover image for Verschachtelte Tabellen und Rowspans richtig parsen – Die schwierigen Teile des HTML-Table-Parsings
circobit
circobit

Posted on

Verschachtelte Tabellen und Rowspans richtig parsen – Die schwierigen Teile des HTML-Table-Parsings

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

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

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

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

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

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

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

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

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

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

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

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

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", ...]
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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)