DEV Community

Cover image for 5 Wikipedia-Tabellen, die die meisten Scraper zum Absturz bringen (und wie man sie repariert)
circobit
circobit

Posted on

5 Wikipedia-Tabellen, die die meisten Scraper zum Absturz bringen (und wie man sie repariert)

Wikipedia ist die häufigste Quelle für Webtabellendaten. Gleichzeitig ist es ein Minenfeld an Edge Cases, die naive Scraper zum Absturz bringen.

Ich habe die fünf Muster gesammelt, die beim Entwickeln des HTML Table Exporter die meisten Probleme verursachen — mit Erkennungscode und Lösungen für jedes Muster.

Muster 1: Navigationszeilen („v t e")

Das Problem:

<table>
  <tr>
    <td colspan="5">v t e Länder nach Einwohnerzahl</td>
  </tr>
  <tr>
    <td>Rang</td><td>Land</td><td>Einwohner</td>...
  </tr>
  ...
</table>
Enter fullscreen mode Exit fullscreen mode

Die erste Zeile enthält „v t e"-Links (View/Talk/Edit) zu Wikipedia-Vorlagenseiten. Wenn der Scraper Zeile 0 als Header interpretiert, geht alles schief.

Was pd.read_html liefert:

          v t e Länder nach Einwohnerzahl
0   Rang                           Land    ...
1      1                          China    ...
Enter fullscreen mode Exit fullscreen mode

Erkennung:

def is_nav_row(row_values):
    """Wikipedia-Navigationspräfix erkennen."""
    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

Lösung:

import pandas as pd

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

    # Prüfen ob erste Zeile Navigation ist
    if is_nav_row(df.iloc[0].values):
        # Zweite Zeile als Header verwenden
        df.columns = df.iloc[1]
        df = df.iloc[2:].reset_index(drop=True)

    return df
Enter fullscreen mode Exit fullscreen mode

Muster 2: Horizontal duplizierte Tabellen

Das Problem:

Um vertikalen Platz zu sparen, zeigt Wikipedia manche Tabellen in mehreren Spalten an:

| Rang | Name   | Einw. | Rang | Name    | Einw. |
|------|--------|-------|------|---------|-------|
| 1    | Tokio  | 37M   | 11   | Paris   | 11M   |
| 2    | Delhi  | 32M   | 12   | Kairo   | 10M   |
Enter fullscreen mode Exit fullscreen mode

Das ist logisch EINE Tabelle mit wiederholter Spaltenstruktur.

Was pd.read_html liefert:

   Rang    Name  Einw.  Rang.1   Name.1  Einw..1
0     1   Tokio    37M      11    Paris      11M
1     2   Delhi    32M      12    Kairo      10M
Enter fullscreen mode Exit fullscreen mode

Pandas sieht 6 Spalten. Wenn man nach „Name" filtert, fehlt die Hälfte der Daten.

Erkennung:

def detect_horizontal_duplication(columns):
    """Prüfen ob Spalten sich wiederholen (Rang, Name, Einw., Rang, Name, Einw.)."""
    cols = list(columns)
    n = len(cols)

    # Durch 2, 3, 4 teilen versuchen
    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

Lösung:

def normalize_duplicated_table(df, base_columns):
    """Horizontal duplizierte Tabellen vertikal stapeln."""
    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]
        # Zeilen entfernen wo alle Werte NaN sind (leere zweite Hälfte)
        chunk = chunk.dropna(how='all')
        frames.append(chunk)

    return pd.concat(frames, ignore_index=True)

# Verwendung
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

Muster 3: Titelzeilen (über alle Spalten)

Das Problem:

<table>
  <tr>
    <td colspan="4">Liste der höchsten Gebäude der Welt</td>
  </tr>
  <tr>
    <td>Rang</td><td>Gebäude</td><td>Stadt</td><td>Höhe</td>
  </tr>
  ...
</table>
Enter fullscreen mode Exit fullscreen mode

Die erste Zeile ist ein Titel, keine Daten. Nach colspan-Expansion wird daraus:

['Liste der höchsten...', 'Liste der höchsten...', 'Liste der höchsten...', 'Liste der höchsten...']
Enter fullscreen mode Exit fullscreen mode

Erkennung:

def is_title_row(row_values, next_row_values):
    """Titelzeilen über die volle Breite erkennen."""
    if not row_values or not next_row_values:
        return False

    # Alle Werte sind gleich (colspan expandiert)
    unique_values = set(str(v).strip() for v in row_values if str(v).strip())

    # Titelzeile: 1 einzigartiger Wert, nächste Zeile hat mehrere
    # Und der Wert ist lang (> 20 Zeichen typisch für Titel)
    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

Lösung:

def skip_title_rows(df):
    """Titelzeilen am Anfang eines DataFrames entfernen."""
    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:
        # Zeile nach Titeln als Header verwenden
        df.columns = df.iloc[skip_count]
        df = df.iloc[skip_count + 1:].reset_index(drop=True)

    return df
Enter fullscreen mode Exit fullscreen mode

Muster 4: Gruppierte Header (Zweistufig)

Das Problem:

|        |         | Statistiken       | Statistiken      |
| Rang   | Land    | BIP (nominal)     | BIP (KKP)        |
|--------|---------|-------------------|------------------|
| 1      | USA     | 25,5 Billionen    | 25,5 Billionen   |
Enter fullscreen mode Exit fullscreen mode

Zeile 0 sind Kategorie-Header. Zeile 1 sind die eigentlichen Spaltenheader. Beide sind semantisch „Header".

Was pd.read_html liefert:

Oft beschädigt oder mit einem MultiIndex, der umständlich zu handhaben ist.

Erkennung:

def has_grouped_headers(df):
    """Zweistufige gruppierte Header erkennen."""
    if len(df) < 3:
        return False

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

    # Aufeinanderfolgende gleiche Werte in Zeile 0 zählen
    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)

    # Gruppierte Header haben typischerweise 40%+ wiederholte Werte
    # UND Zeile 1 hat mehr einzigartige nicht-leere Werte als Zeile 0
    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

Lösung:

def merge_grouped_headers(df):
    """Zweistufige Header zu einer einzelnen Ebene zusammenführen."""
    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)

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

Muster 5: Verschachtelte Infobox-Tabellen

Das Problem:

Wikipedia-Infoboxen enthalten Tabellen innerhalb von Tabellenzellen:

<table class="infobox">
  <tr>
    <td>Einwohner</td>
    <td>
      <table>  <!-- Verschachtelt! -->
        <tr><td>Stadt</td><td>8,3 Mio.</td></tr>
        <tr><td>Metropole</td><td>20,1 Mio.</td></tr>
      </table>
    </td>
  </tr>
</table>
Enter fullscreen mode Exit fullscreen mode

Was pd.read_html liefert:

Sowohl die äußere als auch die innere Tabelle werden zurückgegeben. Bei der Suche nach „allen Tabellen auf der Seite" erhält man Duplikate und verschachtelten Datenmüll.

Erkennung und Filterung:

from bs4 import BeautifulSoup
import requests

def get_top_level_tables(url):
    """Nur Top-Level-Tabellen abrufen, keine verschachtelten."""
    response = requests.get(url)
    soup = BeautifulSoup(response.text, 'html.parser')

    all_tables = soup.find_all('table')

    top_level = []
    for table in all_tables:
        # Prüfen ob diese Tabelle in einer anderen steckt
        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):
    """Nur Top-Level-Tabellen als DataFrames lesen."""
    import pandas as pd

    tables = get_top_level_tables(url)

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

    return dfs
Enter fullscreen mode Exit fullscreen mode

Kompletter Wikipedia-Tabellenleser

Alle Lösungen kombiniert:

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):
        """Alle Datentabellen der Seite abrufen."""
        self._fetch()

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

        for table in tables:
            # Verschachtelte Tabellen überspringen
            if table.find_parent('table'):
                continue

            # Infoboxen überspringen wenn gewünscht
            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):
        """Alle Bereinigungsschritte anwenden."""
        # Navigationszeilen überspringen
        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:]

        # Titelzeilen überspringen
        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

# Verwendung
reader = WikipediaTableReader("https://de.wikipedia.org/wiki/Liste_der_Staaten_der_Erde")
tables = reader.get_tables()
Enter fullscreen mode Exit fullscreen mode

Wann man stattdessen eine Erweiterung verwenden sollte

Wenn man Ad-hoc-Extraktion betreibt (keine Pipeline baut), behandelt eine Browser-Erweiterung all diese Muster automatisch.

HTML Table Exporter erkennt diese Muster und normalisiert die Ausgabe. Ein Klick statt Edge-Case-Debugging.

Einen praktischen Überblick über die besten Erweiterungen dafür finden Sie in unserem Vergleich der besten Chrome-Erweiterungen zum Exportieren von Tabellen.

Für automatisierte Pipelines verwenden Sie den obigen Code. Für gelegentliche Exporte nutzen Sie das richtige Werkzeug für den Job.

Erfahren Sie mehr auf gauchogrid.com/de/html-table-exporter oder probieren Sie es kostenlos im Chrome Web Store aus.


Eine Wikipedia-Tabelle gefunden, die diesen Code zum Absturz bringt? Teilen Sie die URL — ich nehme sie in meine Testsuite auf.

Top comments (0)