DEV Community

Cover image for Hoe Ik Geneste Tabellen en Rowspans Verwerk (De Moeilijke Onderdelen van HTML-tabelparsing)
circobit
circobit

Posted on

Hoe Ik Geneste Tabellen en Rowspans Verwerk (De Moeilijke Onderdelen van HTML-tabelparsing)

Het parsen van HTML-tabellen lijkt eenvoudig — tot je echte data tegenkomt. Wikipedia-tabellen bevatten navigatierijen. Financiële sites gebruiken complexe rowspans. Sportstatistiekensites hebben headers die twee niveaus diep genest zijn.

Na het bouwen van HTML Table Exporter, een tabelextractietool die op duizenden verschillende sites wordt gebruikt, heb ik de randgevallen gecatalogiseerd die de meeste parsers breken. Hier lees je hoe je elk geval kunt aanpakken.

Probleem 1: Rowspan-uitbreiding

Een cel met rowspan="3" neemt verticale ruimte in beslag in de huidige rij en de volgende twee rijen. Als je naïef door row.cells itereert, raken je kolommen niet meer uitgelijnd.

De kapotte output:

| Land    | 2020 | 2021 | 2022 |    <- Header
| VS      | 100  | 200  | 300  |    <- Verwacht
| 150     | 250  | 350  |           <- "VS" ontbreekt (rowspan gaat door)
Enter fullscreen mode Exit fullscreen mode

De oplossing: Houd bezette posities bij in een virtueel grid.

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 => {
      // Zoek de volgende vrije kolom
      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;

      // Markeer alle cellen die dit element overspant
      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;
    });
  });

  // Normaliseer rijlengtes
  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

Kernpunt: Het virtuele grid is de bron van waarheid. De DOM-cellen zijn slechts instructies om het te vullen.

Probleem 2: Geneste Tabellen

Wikipedia-infoboxen bevatten vaak tabellen binnen tabelcellen. Een recursieve aanpak levert rommel op:

<table>
  <tr>
    <td>Land</td>
    <td>
      <table>  <!-- Genest! -->
        <tr><td>Bevolking</td><td>330M</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Detectiestrategie: Controleer of een voorouder van de tabel ook een tabel is.

function isNestedTable(table) {
  let parent = table.parentElement;

  while (parent) {
    if (parent.tagName === "TABLE") {
      return true;
    }
    parent = parent.parentElement;
  }

  return false;
}

// Bij het scannen van een pagina
function getTopLevelTables() {
  const all = document.querySelectorAll("table");
  return Array.from(all).filter(t => !isNestedTable(t));
}
Enter fullscreen mode Exit fullscreen mode

Maar hoe zit het met de inhoud van de geneste tabel?

Voor de buitenste tabel maak ik geneste tabellen plat tot hun tekstinhoud:

function extractCellText(cell) {
  const clone = cell.cloneNode(true);

  // Verwijder geneste tabellen (hun tekst zit al in textContent)
  clone.querySelectorAll("table").forEach(t => t.remove());

  // Verwijder onzichtbare elementen
  clone.querySelectorAll("style, script").forEach(el => el.remove());

  return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
Enter fullscreen mode Exit fullscreen mode

Probleem 3: Wikipedia-navigatierijen

Wikipedia-tabellen beginnen vaak met een navigatierij:

| v t e  Lijst van landen op bevolking |
| Rang | Land    | Bevolking |
| 1    | China   | 1,4 mld   |
Enter fullscreen mode Exit fullscreen mode

Die "v t e"-rij (Bekijken/Overleg/Bewerken) is geen data — het is UI. Een parser die dit als headerrij behandelt, produceert rommel.

Voor een praktische handleiding over het omgaan met Wikipedia-tabellen, zie De 5 Beste Chrome-extensies voor het Exporteren van Tabellen.

Detectie:

function isWikipediaNavRow(row) {
  const firstCell = row[0] || "";

  // Veelvoorkomende patronen voor navigatierijen
  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;  // Header is de volgende rij
    }
  }
  return 0;  // Standaard: eerste rij is header
}
Enter fullscreen mode Exit fullscreen mode

Probleem 4: Titelrijen (Alle Kolommen Overspannend)

Sommige tabellen hebben een titelrij die de volledige breedte overspant:

<table>
  <tr><td colspan="4">Kwartaalomzet (€ miljoenen)</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

Na rowspan-uitbreiding wordt de eerste rij ["Kwartaalomzet...", "Kwartaalomzet...", ...] — dezelfde waarde herhaald.

Detectie:

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()));

  // Kenmerken van een titelrij:
  // 1. Slechts één unieke waarde (herhaald via colspan)
  // 2. Volgende rij heeft meerdere unieke waarden (werkelijke headers)
  // 3. De enkele waarde is lange tekst (>30 tekens, typisch)

  return (
    uniqueValues.size === 1 &&
    nextUniqueValues.size > 2 &&
    row[0] && row[0].length > 30
  );
}
Enter fullscreen mode Exit fullscreen mode

Probleem 5: Gegroepeerde Kolomheaders (FBREF-stijl)

Sportstatistiekensites zoals FBREF gebruiken headers met twee niveaus:

|         |        | Speeltijd         | Prestaties     |
| Speler  | Land   | WS | Basis | Min | Dls | Ast | xG |
| Haaland | Noorw. | 35 | 33    | 2950| 36  | 8   | 32 |
Enter fullscreen mode Exit fullscreen mode

De eerste rij bevat groepsnamen. De tweede rij bevat de werkelijke kolomnamen. Beide zijn "headers."

De uitdaging: Na colspan-uitbreiding ziet rij 0 er zo uit:

["", "", "Speeltijd", "Speeltijd", "Speeltijd", "Prestaties", "Prestaties", "Prestaties"]
Enter fullscreen mode Exit fullscreen mode

Detectieheuristieken:

function isGroupHeaderRow(row, nextRow) {
  if (!row || !nextRow || row.length !== nextRow.length) return false;

  // Tel hoeveel cellen dezelfde waarde hebben als hun buur
  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);

  // Groepsheaderrijen hebben typisch 40%+ herhaalde waarden
  // EN de volgende rij heeft meer unieke waarden
  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

Groeps- en subheaders samenvoegen:

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}`;
  });
}

// Resultaat: ["Speler", "Land", "Speeltijd - WS", "Speeltijd - Basis", ...]
Enter fullscreen mode Exit fullscreen mode

Probleem 6: Horizontaal Gedupliceerde Tabellen

Wikipedia-bevolkingstabellen hebben vaak deze structuur:

| Rang | Naam   | Bev.  | Rang | Naam    | Bev.  |
| 1    | Tokyo  | 37M   | 11   | Parijs  | 11M   |
| 2    | Delhi  | 32M   | 12   | Caïro   | 10M   |
Enter fullscreen mode Exit fullscreen mode

Dit is ÉÉN logische tabel die in twee kolommen wordt weergegeven om verticale ruimte te besparen.

Detectie:

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);

  // Controleer of de tweede helft overeenkomt met de eerste
  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

Normalisatie: Splits elke rij en stapel ze verticaal:

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];
    // Eerste helft
    normalizedRows.push(row.slice(0, baseColumns));
    // Tweede helft (als niet leeg)
    const secondHalf = row.slice(baseColumns, baseColumns * 2);
    if (secondHalf.some(cell => cell.trim())) {
      normalizedRows.push(secondHalf);
    }
  }

  return normalizedRows;
}
Enter fullscreen mode Exit fullscreen mode

Het Gecombineerde Algoritme

Parsen in de echte wereld vereist het controleren van al deze gevallen op volgorde:

function parseTable(table) {
  // 1. Rowspans/colspans uitbreiden naar virtueel grid
  let matrix = expandRowspans(table);

  // 2. Navigatie-/titelrijen detecteren en overslaan
  const headerIndex = detectHeaderRowIndex(matrix);
  if (headerIndex > 0) {
    matrix = matrix.slice(headerIndex);
  }

  // 3. Gegroepeerde headers verwerken (FBREF-stijl)
  const groupedHeaders = detectGroupedColumnHeaders(matrix);
  if (groupedHeaders) {
    const mergedHeaders = mergeGroupAndSubHeaders(matrix[0], matrix[1]);
    matrix = [mergedHeaders, ...matrix.slice(2)];
  }

  // 4. Horizontale duplicatie verwerken
  const duplication = detectHorizontalDuplication(matrix[0]);
  if (duplication) {
    matrix = normalizeHorizontallyDuplicatedTable(matrix, duplication.baseColumns);
  }

  return matrix;
}
Enter fullscreen mode Exit fullscreen mode

Deze Randgevallen Testen

Elk patroon hierboven komt uit een echte bugreport. Ik onderhoud een testsuite met HTML-fixtures voor elk geval:

// Test: Wikipedia-stijl navigatierij
const navRowHtml = `
  <table>
    <tr><td colspan="3">v t e Landen</td></tr>
    <tr><td>Rang</td><td>Land</td><td>Bev.</td></tr>
    <tr><td>1</td><td>China</td><td>1,4 mld</td></tr>
  </table>
`;

const result = parseTable(parseHtml(navRowHtml));
assert(result[0][0] === "Rang");  // Header correct geïdentificeerd
assert(result[1][1] === "China"); // Data correct uitgelijnd
Enter fullscreen mode Exit fullscreen mode

De testsuite heeft 24 gevallen die combinaties van deze patronen afdekken. Nieuwe bugrapporten worden nieuwe testgevallen.

Probeer Het Zelf

Als je tabelextractie bouwt, hoop ik dat dit je debugtijd bespaart. Als je gewoon tabellen wilt exporteren zonder code te schrijven, verwerkt HTML Table Exporter al deze gevallen automatisch.

Meer informatie op gauchogrid.com/nl/html-table-exporter of probeer het gratis in de Chrome Web Store.


Een tabel gevonden die je parser breekt? Deel de URL — ik verzamel deze randgevallen.

Top comments (0)