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>
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 ...
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)
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
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 |
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
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
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)
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>
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...']
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
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
Patroon 4: Gegroepeerde Headers (Twee Niveaus)
Het Probleem:
| | | Statistieken | Statistieken |
| Rang | Land | BBP (nominaal) | BBP (KKP) |
|--------|---------|-------------------|------------------|
| 1 | VS | 25,5 biljoen | 25,5 biljoen |
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
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)
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>
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
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()
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)