Les tableaux sont l'un des formats de données structurées les plus répandus sur le web : taux de change, statistiques sportives, listes de prix, classements. Dans cet article, nous verrons comment extraire un tableau HTML et le transformer en un jeu de données propre — de la ligne unique pandas.read_html jusqu'au parsing manuel de tableaux complexes avec cellules fusionnées via BeautifulSoup.
Il s'agit d'un prolongement pratique de l'article de synthèse « Le web scraping avec Python », qui couvre les techniques de base de chargement des pages et l'utilisation des bibliothèques.
Sommaire
- Structure d'un tableau HTML
- La méthode rapide : pandas.read_html
- La méthode flexible : BeautifulSoup à la main
- Extraction des en-têtes
- Tableaux complexes : colspan et rowspan
- Le cyrillique dans les tableaux
- Nettoyage et sauvegarde des données
- Tableaux dynamiques (JavaScript)
- Avantages et inconvénients des approches
1. Structure d'un tableau HTML
Avant de scraper, il faut comprendre le balisage :
<table>
<thead>
<tr><th>Ville</th><th>Population</th></tr>
</thead>
<tbody>
<tr><td>Tokyo</td><td>13 100 000</td></tr>
<tr><td>Sydney</td><td>5 600 000</td></tr>
</tbody>
</table>
<table>— le conteneur du tableau ;<thead>/<tbody>— l'en-tête et le corps (pas toujours présents) ;<tr>— une ligne (table row) ;<th>— cellule d'en-tête,<td>— cellule de données.
2. La méthode rapide : pandas.read_html
Si le tableau est « bien formé » (un <table> normal sans subtilités), pandas le parse en une seule ligne. Sous le capot, il utilise lxml ou BeautifulSoup.
import pandas as pd
# read_html renvoie la LISTE de tous les tableaux de la page
tables = pd.read_html("https://example.com/stats")
df = tables[0] # premier tableau
print(df.head())
df.to_csv("data.csv", index=False)
Paramètres utiles :
tables = pd.read_html(
url,
match="Population", # ne garder que les tableaux contenant ce mot
header=0, # quelle ligne sert d'en-tête
thousands=" ", # séparateur de milliers (pour "13 100 000")
decimal=",", # séparateur décimal (format européen)
)
Astuce : si le site bloque les requêtes de pandas, téléchargez le HTML via
requestsavec les bons en-têtes et passez-lui le texte :pd.read_html(response.text).
read_html est idéal pour les tableaux simples. Mais il bute sur les mises en page non standard, les cellules fusionnées et les tableaux construits avec « des div à la place de table ». Il faut alors passer au parsing manuel.
3. La méthode flexible : BeautifulSoup à la main
BeautifulSoup offre un contrôle total. Boucle de base sur les lignes et les cellules :
import requests
from bs4 import BeautifulSoup
resp = requests.get("https://example.com/stats", timeout=10)
resp.encoding = resp.apparent_encoding
soup = BeautifulSoup(resp.content, "lxml")
table = soup.find("table")
rows = []
for tr in table.find_all("tr"):
cells = [td.get_text(strip=True) for td in tr.find_all(["td", "th"])]
if cells: # on ignore les lignes vides
rows.append(cells)
for row in rows:
print(row)
find_all(["td", "th"]) capture à la fois les cellules de données et les cellules d'en-tête. get_text(strip=True) supprime les espaces superflus et les sauts de ligne.
Sélectionner un tableau précis
S'il y a plusieurs tableaux, accrochez-vous à une classe, un id ou au contexte :
table = soup.find("table", class_="prices")
table = soup.select_one("#main-table")
table = soup.find("h2", string="Prix").find_next("table")
4. Extraction des en-têtes
Pour obtenir un dictionnaire/DataFrame exploitable, séparez les en-têtes des données :
table = soup.find("table")
# en-têtes : depuis thead ou la première ligne
headers = [th.get_text(strip=True) for th in table.select("thead th")]
if not headers:
first_row = table.find("tr")
headers = [c.get_text(strip=True) for c in first_row.find_all(["th", "td"])]
# données
data = []
for tr in table.select("tbody tr"):
cells = [td.get_text(strip=True) for td in tr.find_all("td")]
if len(cells) == len(headers):
data.append(dict(zip(headers, cells)))
import pandas as pd
df = pd.DataFrame(data)
dict(zip(headers, cells)) transforme chaque ligne en dictionnaire « en-tête → valeur » — il est ensuite facile d'assembler un DataFrame.
5. Tableaux complexes : colspan et rowspan
Les cellules fusionnées cassent le parsing naïf : le nombre de <td> par ligne ne correspond plus. Il faut « déplier » les fusions.
colspan (fusion horizontale)
def expand_row(tr):
cells = []
for td in tr.find_all(["td", "th"]):
text = td.get_text(strip=True)
span = int(td.get("colspan", 1))
cells.extend([text] * span) # on duplique sur la largeur de la fusion
return cells
rowspan (fusion verticale)
rowspan est plus délicat — la valeur « déborde » sur les lignes suivantes. Il faut tenir un tampon de report :
def parse_table_with_rowspan(table):
result = []
rowspans = {} # {index_colonne: (valeur, lignes_restantes)}
for tr in table.find_all("tr"):
row = []
col = 0
cells = tr.find_all(["td", "th"])
cell_iter = iter(cells)
while col < len(rowspans) or cells:
# on remplit d'abord les cellules qui « débordent » d'en haut
if col in rowspans and rowspans[col][1] > 0:
value, left = rowspans[col]
row.append(value)
rowspans[col] = (value, left - 1)
col += 1
continue
try:
td = next(cell_iter)
except StopIteration:
break
text = td.get_text(strip=True)
rs = int(td.get("rowspan", 1))
if rs > 1:
rowspans[col] = (text, rs - 1)
row.append(text)
col += 1
if row:
result.append(row)
return result
C'est un squelette simplifié — les tableaux réels sont parfois plus capricieux. Mais le principe est clair : maintenir un dictionnaire des rowspan actifs et réinjecter les valeurs dans les lignes suivantes. Souvent, il est plus simple d'essayer d'abord pandas.read_html (il sait déplier de nombreuses fusions) et de ne passer au parsing manuel que si pandas échoue.
6. Encodages et Unicode dans les tableaux
Si les cellules affichent des caractères illisibles, le problème vient de l'encodage de la réponse, pas du tableau. Passez au parseur les octets bruts (resp.content) ou fixez l'encodage (resp.encoding = resp.apparent_encoding). L'analyse complète se trouve dans le guide, section « Encodages ».
Une subtilité à part pour les nombres au format russe : « 13 100 000 » avec l'espace comme séparateur de milliers. Nettoyez avant conversion en nombre :
value = "13 100 000".replace("\xa0", "").replace(" ", "")
number = int(value) # 13100000
\xa0 est l'espace insécable, un invité « invisible » très fréquent dans les tableaux, notamment sur le web russophone.
7. Nettoyage et sauvegarde des données
Après extraction, les données sont presque toujours « sales » : espaces, symboles monétaires, unités de mesure.
import re
def clean_price(text):
# "1 299 ₽" -> 1299
digits = re.sub(r"[^\d]", "", text)
return int(digits) if digits else None
df["price"] = df["price"].apply(clean_price)
Sauvegarde dans différents formats via pandas :
df.to_csv("data.csv", index=False, encoding="utf-8-sig") # -sig pour Excel
df.to_excel("data.xlsx", index=False)
df.to_json("data.json", orient="records", force_ascii=False)
utf-8-sig ajoute un BOM pour qu'Excel affiche correctement les caractères non ASCII. force_ascii=False conserve les caractères accentués tels quels au lieu de \uXXXX. Pour en savoir plus sur le travail avec JSON, voir « Parser du JSON en Python ».
8. Tableaux dynamiques (JavaScript)
Si le tableau est chargé par un script (pagination sans rechargement, AJAX), il sera absent du HTML source. Deux pistes :
- Trouver la source des données. Ouvrez l'onglet Network du navigateur — le tableau est souvent alimenté par une API JSON. Parser une API est plus simple et plus fiable que du HTML ; voir « Parser du JSON ».
- Rendre la page avec un navigateur. Playwright/Selenium attendent le rendu, puis vous parsez le HTML final :
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url)
page.wait_for_selector("table")
html = page.content()
browser.close()
soup = BeautifulSoup(html, "lxml")
# ... la suite comme pour un tableau classique
9. Avantages et inconvénients des approches
| Approche | Avantages | Inconvénients |
|---|---|---|
| pandas.read_html | une seule ligne, parsing automatique, DataFrame direct | bute sur les mises en page non standard et les fusions complexes |
| BeautifulSoup | contrôle total, n'importe quelle mise en page | plus de code, cellules fusionnées à traiter à la main |
| lxml + XPath | vitesse maximale sur les gros volumes | API moins conviviale (voir l'article sur lxml) |
| Playwright/Selenium | fonctionne avec les tableaux JS | lent, dépendance lourde |
Recommandation pratique : commencez par pandas.read_html. En cas d'échec — BeautifulSoup. Tableau en JavaScript — cherchez l'API JSON, et ne rendez la page avec un navigateur qu'en dernier recours. Et si des tableaux identiques sont éparpillés sur des centaines de pages (catalogue paginé, archives de cotations), téléchargez-les en parallèle — cela accélère la collecte de façon spectaculaire, voir « Le scraping asynchrone en Python ».