Wikipédia est la source la plus courante de données tabulaires web. C'est aussi un champ de mines de cas limites qui font planter les scrapers naïfs.
J'ai répertorié les cinq patterns qui causent le plus de problèmes en développant HTML Table Exporter, avec du code de détection et des correctifs pour chacun.
Pattern 1 : Lignes de Navigation (« v t e »)
Le Problème :
<table>
<tr>
<td colspan="5">v t e Pays par population</td>
</tr>
<tr>
<td>Rang</td><td>Pays</td><td>Population</td>...
</tr>
...
</table>
La première ligne contient des liens « v t e » (Voir/Discuter/Éditer) vers les pages de modèles Wikipédia. Si votre scraper traite la ligne 0 comme en-têtes, tout casse.
Ce que pd.read_html produit :
v t e Pays par population
0 Rang Pays ...
1 1 Chine ...
Détection :
def is_nav_row(row_values):
"""Détecter le préfixe de navigation Wikipédia."""
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)
Correctif :
import pandas as pd
def read_wikipedia_table(url, table_index=0):
tables = pd.read_html(url)
df = tables[table_index]
# Vérifier si la première ligne est de navigation
if is_nav_row(df.iloc[0].values):
# Utiliser la deuxième ligne comme en-tête
df.columns = df.iloc[1]
df = df.iloc[2:].reset_index(drop=True)
return df
Pattern 2 : Tableaux Dupliqués Horizontalement
Le Problème :
Pour économiser de l'espace vertical, Wikipédia affiche certains tableaux en colonnes multiples :
| Rang | Nom | Pop | Rang | Nom | Pop |
|------|--------|------|------|---------|------|
| 1 | Tokyo | 37M | 11 | Paris | 11M |
| 2 | Delhi | 32M | 12 | Le Caire| 10M |
C'est logiquement UN SEUL tableau avec une structure de colonnes répétée.
Ce que pd.read_html produit :
Rang Nom Pop Rang.1 Nom.1 Pop.1
0 1 Tokyo 37M 11 Paris 11M
1 2 Delhi 32M 12 Le Caire 10M
Pandas voit 6 colonnes. Si vous filtrez par « Nom », vous ratez la moitié des données.
Détection :
def detect_horizontal_duplication(columns):
"""Vérifier si les colonnes se répètent (Rang, Nom, Pop, Rang, Nom, Pop)."""
cols = list(columns)
n = len(cols)
# Essayer de diviser par 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
Correctif :
def normalize_duplicated_table(df, base_columns):
"""Empiler les tableaux dupliqués horizontalement verticalement."""
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]
# Supprimer les lignes où toutes les valeurs sont NaN
chunk = chunk.dropna(how='all')
frames.append(chunk)
return pd.concat(frames, ignore_index=True)
# Utilisation
df = pd.read_html(url)[0]
chunk_size = detect_horizontal_duplication(df.columns)
if chunk_size:
df = normalize_duplicated_table(df, chunk_size)
Pattern 3 : Lignes de Titre (Couvrant Toutes les Colonnes)
Le Problème :
<table>
<tr>
<td colspan="4">Liste des plus hauts bâtiments du monde</td>
</tr>
<tr>
<td>Rang</td><td>Bâtiment</td><td>Ville</td><td>Hauteur</td>
</tr>
...
</table>
La première ligne est un titre, pas des données. Après l'expansion du colspan, elle devient :
['Liste des plus hauts...', 'Liste des plus hauts...', 'Liste des plus hauts...', 'Liste des plus hauts...']
Détection :
def is_title_row(row_values, next_row_values):
"""Détecter les lignes de titre pleine largeur."""
if not row_values or not next_row_values:
return False
# Toutes les valeurs sont identiques (colspan étendu)
unique_values = set(str(v).strip() for v in row_values if str(v).strip())
# Ligne de titre : 1 valeur unique, la ligne suivante a plusieurs valeurs uniques
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
Correctif :
def skip_title_rows(df):
"""Supprimer les lignes de titre du haut d'un 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:
df.columns = df.iloc[skip_count]
df = df.iloc[skip_count + 1:].reset_index(drop=True)
return df
Pattern 4 : En-têtes Groupés (Deux Niveaux)
Le Problème :
| | | Statistiques | Statistiques |
| Rang | Pays | PIB (nominal) | PIB (PPA) |
|--------|---------|-------------------|------------------|
| 1 | USA | 25,5 billions | 25,5 billions |
La ligne 0 contient les en-têtes de catégorie. La ligne 1 contient les vrais en-têtes de colonnes. Les deux sont sémantiquement des « en-têtes ».
Détection :
def has_grouped_headers(df):
"""Détecter les en-têtes groupés à deux niveaux."""
if len(df) < 3:
return False
row0 = df.iloc[0].values
row1 = df.iloc[1].values
# Compter les valeurs consécutives répétées dans row0
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)
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
Correctif :
def merge_grouped_headers(df):
"""Fusionner les en-têtes à deux niveaux en un seul."""
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)
# Utilisation
if has_grouped_headers(df):
df = merge_grouped_headers(df)
Pattern 5 : Tableaux d'Infobox Imbriqués
Le Problème :
Les infoboxes de Wikipédia contiennent des tableaux dans les cellules :
<table class="infobox">
<tr>
<td>Population</td>
<td>
<table> <!-- Imbriqué ! -->
<tr><td>Urbaine</td><td>8,3M</td></tr>
<tr><td>Métro</td><td>20,1M</td></tr>
</table>
</td>
</tr>
</table>
Détection et Filtrage :
from bs4 import BeautifulSoup
import requests
def get_top_level_tables(url):
"""Obtenir uniquement les tableaux de premier niveau, pas les imbriqués."""
response = requests.get(url)
soup = BeautifulSoup(response.text, 'html.parser')
all_tables = soup.find_all('table')
top_level = []
for table in all_tables:
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
Lecteur Complet de Tableaux Wikipédia
En combinant tous les correctifs :
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):
"""Obtenir tous les tableaux de données de la page."""
self._fetch()
tables = self.soup.find_all('table')
results = []
for table in tables:
if table.find_parent('table'):
continue
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):
"""Appliquer toutes les étapes de nettoyage."""
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:]
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
# Utilisation
reader = WikipediaTableReader("https://fr.wikipedia.org/wiki/Liste_de_pays_par_population")
tables = reader.get_tables()
Quand Utiliser une Extension à la Place
Si vous faites de l'extraction ponctuelle (pas un pipeline), une extension de navigateur gère automatiquement tous ces patterns.
HTML Table Exporter détecte ces patterns et normalise la sortie. Un clic vs. déboguer des cas limites.
Pour une comparaison des différentes extensions disponibles, consultez notre guide sur les meilleures extensions Chrome pour exporter des tableaux.
Pour des pipelines automatisés, utilisez le code ci-dessus. Pour des exports occasionnels, utilisez le bon outil pour le travail.
En savoir plus sur gauchogrid.com/fr/html-table-exporter ou essayez-le gratuitement sur le Chrome Web Store.
Vous avez trouvé un tableau Wikipédia qui casse ce code ? Partagez l'URL — je l'ajouterai à ma suite de tests.
Top comments (0)