DEV Community

Cover image for Automatisch Headerrijen Detecteren: De Heuristieken Achter Tabelparsing
circobit
circobit

Posted on

Automatisch Headerrijen Detecteren: De Heuristieken Achter Tabelparsing

De eerste rij van een HTML-tabel is de headerrij.

Behalve wanneer dat niet zo is.

Wikipedia-tabellen hebben vaak een titelrij die alle kolommen overspant vóór de eigenlijke headers. Sportstatistiekensites hebben gegroepeerde headers waar "Speeltijd" meerdere subkolommen overspant zoals "WS", "Basis", "Min". Financiële tabellen hebben eenheidrijen ("in miljoenen EUR") die op headers lijken maar dat niet zijn.

Als je aanneemt dat rij 0 altijd de header is, zullen je exports kapot zijn voor een aanzienlijk deel van de real-world tabellen.

Zo detecteer je de eigenlijke headerrij programmatisch.

Het Probleem: Drie Soorten "Eerste Rijen"

Bekijk deze veelvoorkomende patronen:

Patroon 1: Titelrij

<table>
  <tr>
    <th colspan="4">Wereldbevolking per Land</th>  <!-- Titel, geen header -->
  </tr>
  <tr>
    <th>Rang</th>
    <th>Land</th>
    <th>Bevolking</th>
    <th>% van Wereld</th>
  </tr>
  <tr>
    <td>1</td>
    <td>India</td>
    <td>1.428.627.663</td>
    <td>17,85%</td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Rij 0 is een titel. Rij 1 is de header. Rij 2+ is data.

Patroon 2: Gegroepeerde Headers (Twee Niveaus)

<table>
  <tr>
    <th></th>
    <th></th>
    <th colspan="3">Speeltijd</th>
    <th colspan="2">Prestatie</th>
  </tr>
  <tr>
    <th>Speler</th>
    <th>Land</th>
    <th>WS</th>
    <th>Basis</th>
    <th>Min</th>
    <th>Dls</th>
    <th>Ass</th>
  </tr>
  <tr>
    <td>Jan de Vries</td>
    <td>NED</td>
    <td>34</td>
    <td>30</td>
    <td>2700</td>
    <td>12</td>
    <td>8</td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Rij 0 is groepheaders. Rij 1 is de eigenlijke kolomheaders. Rij 2+ is data.

Patroon 3: Wikipedia-Navigatieprefix

<tr>
  <th colspan="3">v t e Werelderfgoedlijst</th>
</tr>
Enter fullscreen mode Exit fullscreen mode

De "v t e" (bekijk/overleg/bewerk) links zijn Wikipedia's sjabloonnavigatie. Ze moeten verwijderd worden, en de rij kan nog steeds een titel zijn in plaats van een header.

Heuristiek 1: Titelrijen Detecteren

Een titelrij heeft doorgaans:

  • Een enkele cel (of heel weinig cellen)
  • Grote colspan die de meeste/alle kolommen overspant
  • Tekstinhoud die op een titel lijkt, niet op kolomnamen
function isTitleRow(row, totalColumns) {
  if (!row || row.length === 0) return false;

  // Tel niet-lege cellen
  const nonEmptyCells = row.filter(cell => cell && cell.trim()).length;

  // Titelrijen hebben meestal 1-2 niet-lege cellen
  if (nonEmptyCells > 2) return false;

  // Controleer of de eerste cel de meeste kolommen overspant (duidt op colspan)
  // In een genormaliseerde matrix toont dit zich als herhaalde waarden
  const firstValue = row[0];
  const repeatedCount = row.filter(cell => cell === firstValue).length;

  // Als de eerste waarde >50% van de kolommen herhaalt, is het waarschijnlijk een colspan-titel
  if (repeatedCount > totalColumns * 0.5) {
    return true;
  }

  return false;
}
Enter fullscreen mode Exit fullscreen mode

Heuristiek 2: Wat Maakt dat een Rij "Op Headers Lijkt"

Headerrijen hebben kenmerken die ze onderscheiden van datarijen:

function rowLooksLikeHeaders(row) {
  if (!row || row.length === 0) return false;

  // Als gedomineerd door pure getallen, zijn het geen headers
  let numericCells = 0;
  let textCells = 0;
  let emptyCells = 0;

  for (const cell of row) {
    const value = (cell || "").trim();

    if (!value) {
      emptyCells++;
    } else if (/^-?\d+([.,]\d+)?%?$/.test(value)) {
      // Puur getal of percentage
      numericCells++;
    } else {
      textCells++;
    }
  }

  const totalNonEmpty = numericCells + textCells;
  if (totalNonEmpty === 0) return false;

  // Headers zijn voornamelijk tekst, geen getallen
  // Als >70% van niet-lege cellen numeriek is, is het waarschijnlijk data
  if (numericCells / totalNonEmpty > 0.7) {
    return false;
  }

  // Headers mogen niet grotendeels leeg zijn
  if (emptyCells / row.length > 0.7) {
    return false;
  }

  return true;
}
Enter fullscreen mode Exit fullscreen mode

Heuristiek 3: Wat Maakt dat een Rij "Op Data Lijkt"

De omgekeerde controle helpt bevestigen dat we de juiste grens hebben gevonden:

function rowLooksLikeData(row) {
  if (!row || row.length === 0) return false;

  let numericCells = 0;
  let dateCells = 0;
  let totalNonEmpty = 0;

  for (const cell of row) {
    const value = (cell || "").trim();
    if (!value) continue;

    totalNonEmpty++;

    // Controleer op numerieke patronen
    if (/^-?\d+([.,]\d+)?%?$/.test(value)) {
      numericCells++;
    }

    // Controleer op datumpatronen
    if (/^\d{1,4}[-/\.]\d{1,2}[-/\.]\d{1,4}$/.test(value)) {
      dateCells++;
    }
  }

  if (totalNonEmpty === 0) return false;

  // Datarijen bevatten doorgaans numerieke of datuminhoud
  const dataLikeCells = numericCells + dateCells;
  return dataLikeCells / totalNonEmpty > 0.3;
}
Enter fullscreen mode Exit fullscreen mode

Heuristiek 4: Gegroepeerde Kolomheaders Detecteren

FBREF-stijl tabellen hebben een groepheaderrij gevolgd door een subheaderrij. De groeprij heeft:

  • Lege cellen aan het begin (kolommen zonder groepen)
  • Herhaalde waarden door colspan-expansie
  • Meerdere unieke niet-lege waarden (niet slechts één zoals een titel)
function detectGroupHeaderRow(row, nextRow) {
  if (!row || !nextRow || row.length < 4) return false;

  // Groepheaderrijen MOETEN lege cellen aan het begin hebben
  // Dit onderscheidt ze van horizontaal gedupliceerde tabellen
  const firstCellEmpty = !(row[0] || "").trim();
  if (!firstCellEmpty) return false;

  // Tel unieke niet-lege waarden
  const uniqueValues = new Set(
    row.filter(v => v && v.trim()).map(v => v.trim().toLowerCase())
  );

  // Een titelrij heeft precies ÉÉN unieke waarde
  // Een groepheaderrij moet MEERDERE unieke waarden hebben
  if (uniqueValues.size <= 1) return false;

  // Tel opeenvolgende herhaalde waarden (duidt op colspan-expansie)
  let consecutiveRepeats = 0;
  for (let i = 1; i < row.length; i++) {
    const curr = (row[i] || "").trim();
    const prev = (row[i - 1] || "").trim();
    if (curr === prev) consecutiveRepeats++;
  }

  const repeatRatio = consecutiveRepeats / (row.length - 1);

  // Hoge herhalingsratio (>30%) suggereert colspan-expansie
  // Volgende rij moet meer unieke waarden hebben (de eigenlijke subheaders)
  const nextUniqueValues = new Set(
    nextRow.filter(v => v && v.trim()).map(v => v.trim().toLowerCase())
  );

  return repeatRatio > 0.3 && nextUniqueValues.size > uniqueValues.size;
}
Enter fullscreen mode Exit fullscreen mode

Heuristiek 5: Wikipedia-Navigatieprefixen Opschonen

Wikipedia-sjablonen prefixen content vaak met "v t e" (links naar bekijken/overleg/bewerken):

function cleanWikipediaNavPrefix(text) {
  if (!text) return text;

  // Patroon 1: "v t e " aan het begin (spatie-gescheiden)
  // Patroon 2: "v | t | e " (pipe-gescheiden)
  // Patroon 3: "[v] [t] [e] " (haakje-gescheiden)

  return text
    .replace(/^\s*v\s+t\s+e\s+/i, "")
    .replace(/^\s*v\s*\|\s*t\s*\|\s*e\s+/i, "")
    .replace(/^\s*\[v\]\s*\[t\]\s*\[e\]\s+/i, "")
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

Alles Samenbrengen: Het Detectie-algoritme

function detectHeaderRowIndex(matrix) {
  if (!matrix || matrix.length < 2) return 0;

  const totalColumns = matrix[0]?.length || 0;

  for (let i = 0; i < Math.min(matrix.length - 1, 5); i++) {
    const currentRow = matrix[i];
    const nextRow = matrix[i + 1];

    // Sla titelrijen over
    if (isTitleRow(currentRow, totalColumns)) {
      continue;
    }

    // Controleer op gegroepeerde headers (twee niveaus)
    if (detectGroupHeaderRow(currentRow, nextRow)) {
      // De subheaderrij (i+1) is de eigenlijke header
      return i + 1;
    }

    // Controleer of deze rij op headers lijkt en de volgende op data
    if (rowLooksLikeHeaders(currentRow) && rowLooksLikeData(nextRow)) {
      return i;
    }
  }

  // Fallback: neem aan dat rij 0 de header is
  return 0;
}
Enter fullscreen mode Exit fullscreen mode

Real-world Testen

Deze heuristieken zijn ontwikkeld door te testen tegen:

  • Wikipedia-landen/bevolkingstabellen (titelrijen + "v t e" prefixen)
  • FBREF-spelersstatistieken (gegroepeerde headers)
  • Financiële tabellen met eenheidrijen
  • Overheidsdatatabel met meerdere headerniveaus

Geen heuristiek is perfect. Het doel is om de gangbare patronen correct af te handelen en netjes te falen bij ongebruikelijke tabellen.

Wanneer Detectie Faalt

Voor tabellen die niet in de gangbare patronen passen, bied een handmatige override:

function extractTable(matrix, options = {}) {
  const headerRowIndex = options.headerRowIndex ?? detectHeaderRowIndex(matrix);

  const headerRow = matrix[headerRowIndex];
  const dataRows = matrix.slice(headerRowIndex + 1);

  return { headerRow, dataRows };
}
Enter fullscreen mode Exit fullscreen mode

Gebruikers die hun data kennen, kunnen de headerrij expliciet opgeven.

Samenvatting

Patroon Detectiemethode
Titelrij Enkele cel met grote colspan
Standaard header Rij met voornamelijk tekst, gevolgd door rij met getallen
Gegroepeerde headers Lege eerste cellen + herhaalde waarden + meer unieke waarden in volgende rij
Wikipedia-nav "v t e" prefix-patroon

Het kernidee: headers en data hebben verschillende kenmerken. Headers zijn tekstrijk met beschrijvende labels. Data is getallenrijk met feitelijke waarden. De grens ertussen is meestal detecteerbaar.

Lees voor meer over de specifieke uitdagingen van Wikipedia-tabellen onze gids over HTML-tabelscraping in Chrome.


Wil je automatische headerdetectie zonder code te schrijven? Meer informatie op gauchogrid.com/nl/html-table-exporter of probeer het gratis in de Chrome Web Store.

Top comments (0)