Scraping per linguaggio 7 min di lettura

Estrarre tabelle HTML con Python e BeautifulSoup

Estrai le tabelle HTML passo dopo passo con Python e BeautifulSoup: celle annidate, colspan e rowspan ed esportazione del risultato in CSV ed Excel.

TW
Team Web-Scraping.it
Raccolta dati per le esigenze del business
Pubblicato il 15 gennaio 2025

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

  1. Struttura di una tabella HTML
  2. La via rapida: pandas.read_html
  3. La via flessibile: BeautifulSoup a mano
  4. Estrazione delle intestazioni
  5. Tabelle complesse: colspan e rowspan
  6. Codifiche e caratteri speciali
  7. Pulizia e salvataggio dei dati
  8. Tabelle dinamiche (JavaScript)
  9. Pro e contro degli approcci

1. Struttura di una tabella HTML

Prima di estrarre qualsiasi cosa, bisogna capire il markup:

html
<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.

python
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:

python
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 requests impostando 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:

python
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:

python
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:

python
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)

python
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 cells

rowspan (unione verticale)

Il rowspan è più difficile: il valore «si propaga» alle righe inferiori. Bisogna tenere un buffer dei riporti:

python
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:

python
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.

python
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:

python
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:

  1. 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».
  2. Renderizzare con un browser. Playwright/Selenium aspettano il rendering, e poi analizzi l’HTML già pronto:
python
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».