Le tabelle sono uno dei formati più comuni di dati strutturati sul web: tassi di cambio, statistiche sportive, listini prezzi, classifiche. In questo articolo vediamo come estrarre una tabella HTML e trasformarla in un set di dati pulito — dal pandas.read_html in una sola riga fino al parsing manuale di tabelle complesse con celle unite tramite BeautifulSoup.
È una diramazione pratica della guida generale «Web scraping con Python», che copre le tecniche di base per scaricare le pagine e lavorare con le librerie.
Indice
- Struttura di una tabella HTML
- La via rapida: pandas.read_html
- La via flessibile: BeautifulSoup a mano
- Estrazione delle intestazioni
- Tabelle complesse: colspan e rowspan
- Codifiche e caratteri speciali
- Pulizia e salvataggio dei dati
- Tabelle dinamiche (JavaScript)
- Pro e contro degli approcci
1. Struttura di una tabella HTML
Prima di estrarre qualsiasi cosa, bisogna capire il markup:
<table>
<thead>
<tr><th>Città</th><th>Popolazione</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>— il contenitore della tabella;<thead>/<tbody>— l’intestazione e il corpo (non sempre presenti);<tr>— la riga (table row);<th>— cella di intestazione,<td>— cella di dati.
2. La via rapida: pandas.read_html
Se la tabella è «corretta» (un <table> normale, senza trucchi), pandas la analizza in una riga. Sotto il cofano usa lxml o BeautifulSoup.
import pandas as pd
# read_html restituisce una LISTA di tutte le tabelle della pagina
tables = pd.read_html("https://example.com/stats")
df = tables[0] # la prima tabella
print(df.head())
df.to_csv("data.csv", index=False)Parametri utili:
tables = pd.read_html(
url,
match="Popolazione", # prendere solo le tabelle che contengono questa parola
header=0, # quale riga è l'intestazione
thousands=" ", # separatore delle migliaia (per "13 100 000")
decimal=",", # separatore decimale (formato italiano)
)Suggerimento: se il sito blocca le richieste di pandas, scarica l’HTML con
requestsimpostando le intestazioni giuste e passa il testo:pd.read_html(response.text).
read_html è ideale per le tabelle semplici. Ma inciampa sul markup non standard, sulle celle unite e sulle tabelle costruite con «div al posto di table». Allora serve il parsing manuale.
3. La via flessibile: BeautifulSoup a mano
Il controllo totale lo dà BeautifulSoup. Il ciclo di base su righe e celle:
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: # saltiamo le righe vuote
rows.append(cells)
for row in rows:
print(row)find_all(["td", "th"]) cattura sia le celle normali sia quelle di intestazione. get_text(strip=True) elimina gli spazi e gli a capo superflui.
Scegliere una tabella specifica
Se le tabelle sono più d’una, aggancia la classe, l’id o il contesto:
table = soup.find("table", class_="prices")
table = soup.select_one("#main-table")
table = soup.find("h2", string="Prezzi").find_next("table")
4. Estrazione delle intestazioni
Per ottenere un dizionario o un DataFrame sensato, separa le intestazioni dai dati:
table = soup.find("table")
# intestazioni: dal thead o dalla prima riga
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"])]
# dati
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)) trasforma la riga in un dizionario «intestazione → valore»: da lì montare il DataFrame è facile.
5. Tabelle complesse: colspan e rowspan
Le celle unite rompono il parsing semplice: il numero di <td> nelle righe smette di coincidere. Bisogna «espandere» le unioni.
colspan (unione orizzontale)
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) # duplichiamo per la larghezza dell'unione
return cellsrowspan (unione verticale)
Il rowspan è più difficile: il valore «si propaga» alle righe inferiori. Bisogna tenere un buffer dei riporti:
def parse_table_with_rowspan(table):
result = []
rowspans = {} # {indice_colonna: (valore, righe_rimanenti)}
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:
# prima riempiamo le celle che «si propagano» dall'alto
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È uno schema semplificato: le tabelle reali sanno essere più capricciose. Ma il principio è chiaro: tenere un dizionario dei rowspan attivi e inserire i valori nelle righe successive. Spesso conviene provare prima pandas.read_html (sa espandere molte unioni) e passare al parsing manuale solo se pandas non ce l’ha fatta.
6. Codifiche e caratteri speciali nelle tabelle
Se nelle celle compaiono caratteri illeggibili al posto delle lettere accentate, il problema è nella codifica della risposta, non nella tabella. Passa al parser i byte (resp.content) o imposta la codifica (resp.encoding = resp.apparent_encoding). L’analisi completa è nella guida generale, sezione sulle codifiche.
Una nota a parte per i numeri con il separatore delle migliaia: «13 100 000» con lo spazio. Puliscili prima della conversione in numero:
value = "13 100 000".replace("\xa0", "").replace(" ", "")
number = int(value) # 13100000\xa0 è lo spazio unificatore (non-breaking space), un frequente «ospite invisibile» nelle tabelle web. E se il sito separa le migliaia con i punti («13.100.000»), toglili allo stesso modo prima della conversione.
7. Pulizia e salvataggio dei dati
Dopo l’estrazione i dati sono quasi sempre «sporchi»: spazi, simboli di valuta, unità di misura.
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)Salvataggio nei vari formati con pandas:
df.to_csv("data.csv", index=False, encoding="utf-8-sig") # -sig per Excel
df.to_excel("data.xlsx", index=False)
df.to_json("data.json", orient="records", force_ascii=False)utf-8-sig aggiunge il BOM perché Excel mostri correttamente le lettere accentate. force_ascii=False conserva i caratteri accentati così come sono, invece di trasformarli in sequenze \uXXXX. Per approfondire il lavoro con JSON vedi «Parsing di JSON».
8. Tabelle dinamiche (JavaScript)
Se la tabella viene caricata da uno script (paginazione senza ricaricare la pagina, AJAX), nell’HTML originale non ci sarà. Due strade:
- Trovare la fonte dei dati. Apri la scheda Network del browser: spesso la tabella si alimenta da un’API JSON. Estrarre i dati dall’API è più semplice e affidabile che dall’HTML; vedi «Parsing di JSON».
- Renderizzare con un browser. Playwright/Selenium aspettano il rendering, e poi analizzi l’HTML già pronto:
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")
# ... da qui in poi, come con una tabella normale
9. Pro e contro degli approcci
| Approccio | Pro | Contro |
|---|---|---|
| pandas.read_html | una sola riga, parsing automatico, DataFrame subito pronto | inciampa sul markup non standard e sulle unioni complesse |
| BeautifulSoup | controllo totale, qualsiasi markup | più codice; le celle unite si gestiscono a mano |
| lxml + XPath | massima velocità sui grandi volumi | API meno amichevole (vedi l’articolo su lxml) |
| Playwright/Selenium | funziona con le tabelle JS | lento, dipendenza pesante |
Consiglio pratico: inizia con pandas.read_html. Se non ce la fa, passa a BeautifulSoup. Se la tabella è generata in JavaScript, cerca l’API JSON e solo in ultima istanza renderizza con il browser. E se tabelle identiche sono sparse su centinaia di pagine (un catalogo paginato, un archivio di quotazioni), scaricale in parallelo: accelera la raccolta di parecchie volte; vedi «Scraping asincrono in Python».