DEV Community

Cover image for 5 Tableaux Wikipédia qui Plantent la Plupart des Scrapers (Et Comment les Corriger)
circobit
circobit

Posted on

5 Tableaux Wikipédia qui Plantent la Plupart des Scrapers (Et Comment les Corriger)

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>
Enter fullscreen mode Exit fullscreen mode

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    ...
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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  |
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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...']
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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    |
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)