DEV Community

Cover image for 5 Wikipedia-tabellen Die de Meeste Scrapers Breken (En Hoe Je Ze Oplost)
circobit
circobit

Posted on

5 Wikipedia-tabellen Die de Meeste Scrapers Breken (En Hoe Je Ze Oplost)

Wikipedia is de meest gebruikte bron voor webtabeldata. Het is ook een mijnenveld van randgevallen die naïeve scrapers breken.

Ik heb de vijf patronen verzameld die de meeste problemen veroorzaken bij het bouwen van HTML Table Exporter, met detectiecode en oplossingen voor elk patroon.

Patroon 1: Navigatierijen ("v t e")

Het Probleem:

<table>
  <tr>
    <td colspan="5">v t e Landen op bevolking</td>
  </tr>
  <tr>
    <td>Rang</td><td>Land</td><td>Bevolking</td>...
  </tr>
  ...
</table>
Enter fullscreen mode Exit fullscreen mode

De eerste rij bevat "v t e" (Bekijken/Overleg/Bewerken) links naar Wikipedia-sjabloonpagina's. Als je scraper rij 0 als headers behandelt, breekt alles.

Wat pd.read_html oplevert:

          v t e Landen op bevolking
0   Rang                        Land    ...
1      1                       China    ...
Enter fullscreen mode Exit fullscreen mode

Detectie:

def is_nav_row(row_values):
    """Detecteer Wikipedia-navigatieprefix."""
    if not row_values:
        return False

    first_cell = str(row_values[0]).strip().lower()
    patterns = [
        r'^v\s+t\s+e\s',        # "v t e "
        r'^v\s*\|\s*t\s*\|\s*e', # "v | t | e"
        r'^\[v\]\s*\[t\]\s*\[e\]' # "[v] [t] [e]"
    ]

    import re
    return any(re.match(p, first_cell) for p in patterns)
Enter fullscreen mode Exit fullscreen mode

Oplossing:

import pandas as pd

def read_wikipedia_table(url, table_index=0):
    tables = pd.read_html(url)
    df = tables[table_index]

    # Controleer of eerste rij navigatie is
    if is_nav_row(df.iloc[0].values):
        # Gebruik tweede rij als header
        df.columns = df.iloc[1]
        df = df.iloc[2:].reset_index(drop=True)

    return df
Enter fullscreen mode Exit fullscreen mode

Patroon 2: Horizontaal Gedupliceerde Tabellen

Het Probleem:

Om verticale ruimte te besparen toont Wikipedia sommige tabellen in meerdere kolommen:

| 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 logisch ÉÉN tabel met herhaalde kolomstructuur.

Wat pd.read_html oplevert:

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

Pandas ziet het als 6 kolommen. Als je filtert op "Naam", mis je de helft van de data.

Detectie:

def detect_horizontal_duplication(columns):
    """Controleer of kolommen herhalen (Rang, Naam, Bev., Rang, Naam, Bev.)."""
    cols = list(columns)
    n = len(cols)

    # Probeer te delen door 2, 3, 4
    for divisor in [2, 3, 4]:
        if n % divisor != 0:
            continue

        chunk_size = n // divisor
        base_pattern = [c.rstrip('.0123456789') for c in cols[:chunk_size]]

        is_duplicate = True
        for i in range(1, divisor):
            chunk = cols[i * chunk_size : (i + 1) * chunk_size]
            normalized = [c.rstrip('.0123456789') for c in chunk]
            if normalized != base_pattern:
                is_duplicate = False
                break

        if is_duplicate:
            return chunk_size

    return None
Enter fullscreen mode Exit fullscreen mode

Oplossing:

def normalize_duplicated_table(df, base_columns):
    """Stapel horizontaal gedupliceerde tabellen verticaal."""
    n_repeats = len(df.columns) // base_columns

    frames = []
    for i in range(n_repeats):
        start = i * base_columns
        end = start + base_columns
        chunk = df.iloc[:, start:end].copy()
        chunk.columns = df.columns[:base_columns]
        # Verwijder rijen waar alle waarden NaN zijn (lege tweede helft)
        chunk = chunk.dropna(how='all')
        frames.append(chunk)

    return pd.concat(frames, ignore_index=True)

# Gebruik
df = pd.read_html(url)[0]
chunk_size = detect_horizontal_duplication(df.columns)
if chunk_size:
    df = normalize_duplicated_table(df, chunk_size)
Enter fullscreen mode Exit fullscreen mode

Patroon 3: Titelrijen (Alle Kolommen Overspannend)

Het Probleem:

<table>
  <tr>
    <td colspan="4">Lijst van hoogste gebouwen ter wereld</td>
  </tr>
  <tr>
    <td>Rang</td><td>Gebouw</td><td>Stad</td><td>Hoogte</td>
  </tr>
  ...
</table>
Enter fullscreen mode Exit fullscreen mode

De eerste rij is een titel, geen data. Na colspan-uitbreiding wordt het:

['Lijst van hoogste...', 'Lijst van hoogste...', 'Lijst van hoogste...', 'Lijst van hoogste...']
Enter fullscreen mode Exit fullscreen mode

Detectie:

def is_title_row(row_values, next_row_values):
    """Detecteer titelrijen die de volle breedte overspannen."""
    if not row_values or not next_row_values:
        return False

    # Alle waarden zijn hetzelfde (colspan uitgevouwen)
    unique_values = set(str(v).strip() for v in row_values if str(v).strip())

    # Titelrij: 1 unieke waarde, volgende rij heeft meerdere unieke waarden
    # En de waarde is lang (> 20 tekens, typisch voor titels)
    if len(unique_values) == 1:
        title = list(unique_values)[0]
        next_unique = len(set(str(v).strip() for v in next_row_values if str(v).strip()))
        return len(title) > 20 and next_unique > 2

    return False
Enter fullscreen mode Exit fullscreen mode

Oplossing:

def skip_title_rows(df):
    """Verwijder titelrijen van de bovenkant van een dataframe."""
    skip_count = 0

    for i in range(min(3, len(df) - 1)):
        current_row = df.iloc[i].values
        next_row = df.iloc[i + 1].values if i + 1 < len(df) else None

        if is_title_row(current_row, next_row):
            skip_count = i + 1
        else:
            break

    if skip_count > 0:
        # Gebruik de rij na titels als header
        df.columns = df.iloc[skip_count]
        df = df.iloc[skip_count + 1:].reset_index(drop=True)

    return df
Enter fullscreen mode Exit fullscreen mode

Patroon 4: Gegroepeerde Headers (Twee Niveaus)

Het Probleem:

|        |         | Statistieken      | Statistieken     |
| Rang   | Land    | BBP (nominaal)    | BBP (KKP)        |
|--------|---------|-------------------|------------------|
| 1      | VS      | 25,5 biljoen      | 25,5 biljoen     |
Enter fullscreen mode Exit fullscreen mode

Rij 0 is categorieheaders. Rij 1 is de werkelijke kolomheaders. Beide zijn semantisch "headers."

Wat pd.read_html oplevert:

Vaak beschadigd of met een MultiIndex die lastig is om mee te werken.

Detectie:

def has_grouped_headers(df):
    """Detecteer gegroepeerde headers met twee niveaus."""
    if len(df) < 3:
        return False

    row0 = df.iloc[0].values
    row1 = df.iloc[1].values

    # Tel herhaalde opeenvolgende waarden in rij0
    repeat_count = 0
    for i in range(1, len(row0)):
        if str(row0[i]).strip() == str(row0[i-1]).strip() and str(row0[i]).strip():
            repeat_count += 1

    repeat_ratio = repeat_count / max(1, len(row0) - 1)

    # Gegroepeerde headers hebben typisch 40%+ herhaalde waarden
    # EN rij1 heeft meer unieke niet-lege waarden dan rij0
    unique0 = len(set(str(v).strip() for v in row0 if str(v).strip()))
    unique1 = len(set(str(v).strip() for v in row1 if str(v).strip()))

    return repeat_ratio > 0.3 and unique1 > unique0
Enter fullscreen mode Exit fullscreen mode

Oplossing:

def merge_grouped_headers(df):
    """Voeg headers met twee niveaus samen tot één niveau."""
    group_row = df.iloc[0].values
    header_row = df.iloc[1].values

    merged = []
    for i, (group, header) in enumerate(zip(group_row, header_row)):
        g = str(group).strip()
        h = str(header).strip()

        if not g or g == h:
            merged.append(h)
        elif not h:
            merged.append(g)
        else:
            merged.append(f"{g} - {h}")

    df.columns = merged
    return df.iloc[2:].reset_index(drop=True)

# Gebruik
if has_grouped_headers(df):
    df = merge_grouped_headers(df)
Enter fullscreen mode Exit fullscreen mode

Patroon 5: Geneste Infobox-tabellen

Het Probleem:

Wikipedia-infoboxen bevatten tabellen binnen tabelcellen:

<table class="infobox">
  <tr>
    <td>Bevolking</td>
    <td>
      <table>  <!-- Genest! -->
        <tr><td>Stedelijk</td><td>8,3M</td></tr>
        <tr><td>Metropool</td><td>20,1M</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Wat pd.read_html oplevert:

Zowel de buitenste als de binnenste tabel worden geretourneerd. Als je zoekt naar "alle tabellen op de pagina," krijg je duplicaten en geneste rommel.

Detectie en Filtering:

from bs4 import BeautifulSoup
import requests

def get_top_level_tables(url):
    """Haal alleen tabellen op het hoogste niveau op, geen geneste."""
    response = requests.get(url)
    soup = BeautifulSoup(response.text, 'html.parser')

    all_tables = soup.find_all('table')

    top_level = []
    for table in all_tables:
        # Controleer of deze tabel in een andere tabel zit
        parent = table.parent
        is_nested = False

        while parent:
            if parent.name == 'table':
                is_nested = True
                break
            parent = parent.parent

        if not is_nested:
            top_level.append(table)

    return top_level

def read_top_level_tables(url):
    """Lees alleen tabellen op het hoogste niveau als DataFrames."""
    import pandas as pd

    tables = get_top_level_tables(url)

    dfs = []
    for table in tables:
        try:
            df = pd.read_html(str(table))[0]
            dfs.append(df)
        except Exception:
            continue

    return dfs
Enter fullscreen mode Exit fullscreen mode

Complete Wikipedia Table Reader

Alle oplossingen gecombineerd:

import pandas as pd
import requests
from bs4 import BeautifulSoup
import re

class WikipediaTableReader:
    def __init__(self, url):
        self.url = url
        self.soup = None

    def _fetch(self):
        if self.soup is None:
            response = requests.get(self.url)
            self.soup = BeautifulSoup(response.text, 'html.parser')

    def _is_nav_row(self, values):
        if not values:
            return False
        first = str(values[0]).strip().lower()
        return bool(re.match(r'^v\s+t\s+e\s', first))

    def _is_title_row(self, values, next_values):
        unique = set(str(v).strip() for v in values if str(v).strip())
        if len(unique) != 1:
            return False
        title = list(unique)[0]
        next_unique = len(set(str(v).strip() for v in next_values if str(v).strip()))
        return len(title) > 20 and next_unique > 2

    def get_tables(self, skip_infobox=True):
        """Haal alle datatabellen op van de pagina."""
        self._fetch()

        tables = self.soup.find_all('table')
        results = []

        for table in tables:
            # Sla geneste tabellen over
            if table.find_parent('table'):
                continue

            # Sla infoboxen over indien gewenst
            if skip_infobox and 'infobox' in table.get('class', []):
                continue

            try:
                df = pd.read_html(str(table))[0]
                df = self._clean_table(df)
                if len(df) > 0 and len(df.columns) > 1:
                    results.append(df)
            except Exception:
                continue

        return results

    def _clean_table(self, df):
        """Pas alle opschoningsstappen toe."""
        # Sla navigatierijen over
        while len(df) > 0 and self._is_nav_row(df.iloc[0].values):
            df.columns = df.iloc[1] if len(df) > 1 else df.columns
            df = df.iloc[2:].reset_index(drop=True) if len(df) > 2 else df.iloc[1:]

        # Sla titelrijen over
        if len(df) > 1:
            while self._is_title_row(df.iloc[0].values, df.iloc[1].values if len(df) > 1 else []):
                df.columns = df.iloc[1]
                df = df.iloc[2:].reset_index(drop=True)

        return df

# Gebruik
reader = WikipediaTableReader("https://nl.wikipedia.org/wiki/Lijst_van_landen_naar_inwonertal")
tables = reader.get_tables()
Enter fullscreen mode Exit fullscreen mode

Wanneer Gebruik Je Een Extensie

Als je ad-hoc extractie doet (geen pipeline bouwt), handelt een browserextensie al deze patronen automatisch af.

HTML Table Exporter detecteert deze patronen en normaliseert de output. Eén klik versus het debuggen van randgevallen.

Bekijk de vergelijking in onze gids over de beste Chrome-extensies voor het exporteren van tabellen.

Voor geautomatiseerde pipelines gebruik je de code hierboven. Voor incidentele exports gebruik je de juiste tool voor de klus.

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


Een Wikipedia-tabel gevonden die deze code breekt? Deel de URL — ik voeg hem toe aan mijn testsuite.

Top comments (0)