Scraping per linguaggio 12 min di lettura

Web scraping con Python: guida completa

Percorriamo l’intero processo di web scraping con Python: requests e BeautifulSoup, contenuti dinamici, come evitare i blocchi e la struttura di uno scraper completo.

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

Il web scraping è la raccolta automatica di dati dalle pagine web e la loro conversione in un formato strutturato: tabelle, JSON o una base di dati. Python è diventato lo standard de facto per questo compito: ha una sintassi semplice, un ecosistema di librerie enorme e una community molto attiva.

Questo materiale è un «hub» panoramico. Qui ripercorreremo l’intero processo, dalla richiesta della pagina all’archiviazione delle code di URL, daremo una mappa di tutte le librerie e soluzioni chiave e, per i temi più specifici, rimanderemo ad articoli dettagliati a parte.

Indice

  1. Che cos’è il web scraping e di che cosa si compone
  2. Panoramica di librerie e soluzioni
  3. Come otteniamo la pagina
  4. Librerie per il parsing del contenuto
  5. Risolvere i problemi di codifica e caratteri speciali
  6. Uso del multithreading
  7. Uso dei proxy
  8. Scraping attraverso TOR
  9. Lavorare con HTTPS/SSL
  10. Lavorare con i cookie
  11. Stato della risposta e intestazioni
  12. Archiviazione di URL e code
  13. Pro e contro dell’implementazione
  14. Aspetti legali ed etici

1. Che cos’è il web scraping e di che cosa si compone

Qualunque scraper, a prescindere dalla scala, si compone di quattro fasi:

  1. Recupero della pagina — richiesta HTTP al server e ricezione della risposta (HTML, XML, JSON).
  2. Parsing del contenuto — estrazione dei dati necessari dalla struttura tramite selettori (CSS, XPath) o espressioni regolari.
  3. Normalizzazione e archiviazione — conversione dei dati in un formato uniforme e scrittura su file o database.
  4. Gestione della scansione — coda di URL, deduplicazione, limite di velocità, tentativi ripetuti.

Uno script semplice può risolvere tutto questo in 10 righe. Un crawler industriale separa ogni fase in un livello a sé, con code, proxy e worker distribuiti.


2. Panoramica di librerie e soluzioni

Per non perdersi, dividiamo gli strumenti in base al loro ruolo.

Scaricare le pagine (client HTTP)

Libreria Tipo Quando usarla
requests sincrono lo standard per la maggior parte dei compiti, API comoda
urllib sincrono inclusa nella libreria standard, senza dipendenze
httpx sincr./asincr. sostituto moderno di requests con supporto async e HTTP/2
aiohttp asincrono alta concorrenza, migliaia di richieste
pycurl sincrono controllo fine della richiesta, massima velocità

Parsing di HTML/XML

Libreria Motore Particolarità
BeautifulSoup (bs4) html.parser / lxml l’API più amichevole, tollera l’HTML «sporco»
lxml libxml2 (C) massima velocità, XPath completo
parsel lxml CSS + XPath, la base di Scrapy
selectolax Modest/Lexbor (C) parser CSS molto veloce per grandi volumi
pyquery lxml sintassi in stile jQuery

Siti dinamici (JavaScript)

Strumento Scopo
Selenium controllo di un browser reale, il classico
Playwright alternativa moderna, più veloce e stabile
Pyppeteer port di Puppeteer in Python

Framework e piattaforme

Soluzione Scopo
Scrapy framework completo per crawler: code, pipeline, middleware
Scrapy + Splash/Playwright Scrapy con rendering di JS
Django + Celery scraping come parte di un’applicazione web con task in background

Come scegliere

  • Pagina semplice senza JS, compito una tantum → requests + BeautifulSoup.
  • Ti serve velocità su grandi volumi → httpx/aiohttp + lxml/selectolax.
  • Centinaia di migliaia di pagine, scansione dell’intero sito → Scrapy. Maggiori dettagli nell’articolo «Web scraping in Python con Scrapy».
  • Il contenuto viene renderizzato con JavaScript → Playwright/Selenium.
  • Lo scraping fa parte di un servizio web → Django, vedi «Web scraping con Django».
  • Ti serve la massima concorrenza → approccio asincrono.

3. Come otteniamo la pagina

Richiesta di base con requests:

python
import requests

url = "https://example.com"
headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
    "Accept-Language": "it-IT,it;q=0.9",
}

response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()   # lancia un'eccezione in caso di 4xx/5xx
html = response.text

Punti chiave:

  • User-Agent: impostalo sempre — molti siti bloccano le richieste che arrivano con il python-requests di default.
  • timeout: indicalo sempre, altrimenti lo script può restare appeso per sempre.
  • raise_for_status() ti risparmia il controllo manuale del codice di risposta.

Se la pagina viene renderizzata con JavaScript, requests restituirà uno scheletro vuoto. In quel caso serve un motore browser:

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("https://example.com")
    page.wait_for_selector(".content")   # aspettiamo che compaiano i dati
    html = page.content()
    browser.close()

Se in uscita non ti aspetti HTML ma dati strutturati di un’API, vedi il materiale a parte «Parsing di JSON».


4. Librerie per il parsing del contenuto

BeautifulSoup — il punto d’ingresso per chi inizia

python
from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "lxml")   # il parser lxml è più veloce di html.parser

title = soup.find("h1").get_text(strip=True)
links = [a["href"] for a in soup.select("a.product-link")]
price = soup.select_one(".price").text

BeautifulSoup tollera il markup rotto e si legge quasi come linguaggio naturale. Per l’estrazione delle tabelle c’è un approfondimento a parte: «Estrarre tabelle HTML con Python e BeautifulSoup».

lxml — velocità e XPath

python
from lxml import html as lxml_html

tree = lxml_html.fromstring(html)
titles = tree.xpath('//h2[@class="title"]/text()')
prices = tree.xpath('//span[@class="price"]/text()')

lxml è scritto in C e sui grandi volumi lavora molte volte più veloce. L’XPath completo offre una flessibilità che i selettori CSS non raggiungono. L’analisi approfondita è nell’articolo «Web scraping in Python con lxml».

parsel e selectolax

parsel (il nucleo di Scrapy) combina CSS e XPath:

python
from parsel import Selector

sel = Selector(text=html)
sel.css("h1::text").get()
sel.xpath("//a/@href").getall()

selectolax è la scelta giusta quando bisogna analizzare centinaia di migliaia di documenti: sulle selezioni CSS è nettamente più veloce perfino di lxml.

Espressioni regolari

re è appropriato solo per pattern semplici e piatti (telefono, email, codice articolo). Non conviene fare il parsing di HTML annidato con le regex: la struttura si rompe con troppa facilità.


5. Risolvere i problemi di codifica e caratteri speciali

Il fastidio più frequente sono i caratteri corrotti (mojibake) al posto delle lettere accentate: perché invece di perché, più invece di più. La causa è una codifica della risposta rilevata male.

Perché succede

requests prova a indovinare la codifica dall’intestazione Content-Type. Se il server non la invia o la invia sbagliata, il testo viene decodificato con la codifica errata (spesso si assume ISO-8859-1 al posto di windows-1252 o utf-8).

Soluzione 1: impostare la codifica a mano

python
response = requests.get(url)
response.encoding = "utf-8"      # oppure "windows-1252" per i siti più vecchi
html = response.text

Soluzione 2: rilevamento automatico

python
response = requests.get(url)
response.encoding = response.apparent_encoding   # viene rilevata dal contenuto
html = response.text

apparent_encoding usa la libreria charset-normalizer (o chardet), che analizza i byte e deduce la codifica.

Soluzione 3: lavorare direttamente con i byte

La via più affidabile è consegnare i byte al parser e lasciare che sia lui a leggere il <meta charset>:

python
from bs4 import BeautifulSoup

response = requests.get(url)
soup = BeautifulSoup(response.content, "lxml")   # .content, non .text

response.content sono i byte «grezzi»; lxml e BeautifulSoup trovano da soli la dichiarazione di codifica dentro l’HTML.

Soluzione 4: decodifica manuale

python
html = response.content.decode("windows-1252", errors="replace")

Il parametro errors="replace" sostituisce i caratteri non convertibili con senza far crollare lo script. Le sfumature dell’XML con caratteri speciali sono descritte in «Parsing di XML in Python».


6. Uso del multithreading

Lo scraping è soprattutto attesa della rete (I/O-bound), quindi i thread danno un’accelerazione tangibile nonostante il GIL: mentre un thread aspetta la risposta, un altro lavora.

ThreadPoolExecutor — la via più semplice

python
from concurrent.futures import ThreadPoolExecutor
import requests

urls = [f"https://example.com/page/{i}" for i in range(1, 101)]

def fetch(url):
    r = requests.get(url, timeout=10)
    return url, r.status_code

with ThreadPoolExecutor(max_workers=10) as executor:
    for url, status in executor.map(fetch, urls):
        print(url, status)

Quando ricorrere a multiprocessing

Se il collo di bottiglia non è la rete ma il parsing pesante dell’HTML e l’elaborazione (CPU-bound), i thread si scontrano con il GIL. Lì aiuta multiprocessing: più processi, ognuno con il proprio interprete.

L’alternativa migliore: async

Per migliaia di richieste simultanee i thread consumano troppa memoria. L’asincronia (asyncio + aiohttp) tiene decine di migliaia di connessioni in un solo thread. È un grande tema a parte: vedi «Scraping asincrono in Python».

Importante: un’alta concorrenza non dà il diritto di «buttare giù» il server altrui. Limita la velocità e rispetta robots.txt.


7. Uso dei proxy

Con uno scraping intensivo il sito banna il tuo IP in base al numero di richieste. La soluzione: un pool di proxy con rotazione.

Collegamento semplice di un proxy

python
proxies = {
    "http":  "http://user:pass@123.45.67.89:8080",
    "https": "http://user:pass@123.45.67.89:8080",
}
response = requests.get(url, proxies=proxies, timeout=15)

Rotazione dei proxy

python
import random
import requests

PROXIES = [
    "http://user:pass@ip1:port",
    "http://user:pass@ip2:port",
    "http://user:pass@ip3:port",
]

def fetch_with_rotation(url):
    proxy = random.choice(PROXIES)
    return requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=15)

Tipi di proxy

  • Datacenter — economici e veloci, ma facili da individuare e bannare.
  • Residenziali — IP di provider reali; più cari, ma meno visibili.
  • Mobili — IP di operatori mobili, i più «affidabili» e i più costosi.

In produzione conviene mantenere una lista di proxy «sani»: scartare quelli che restituiscono timeout o 403 e verificarli periodicamente.


8. Scraping attraverso TOR

TOR è un modo gratuito di cambiare l’IP di uscita. È più lento dei proxy a pagamento e va bene per volumi modesti, ma non richiede spese.

Collegamento

Dopo l’installazione di TOR (il demone o Tor Browser), questo avvia un proxy SOCKS5 su 127.0.0.1:9050:

python
import requests

proxies = {
    "http":  "socks5h://127.0.0.1:9050",
    "https": "socks5h://127.0.0.1:9050",
}
# serve il pacchetto: pip install requests[socks]
r = requests.get("https://httpbin.org/ip", proxies=proxies)
print(r.json())   # vedrai l'IP del nodo di uscita di TOR, non il tuo

Lo schema socks5h (con la lettera h) è importante: la risoluzione DNS passa attraverso TOR e non avviene in locale; altrimenti la tua vera richiesta DNS finirebbe esposta.

Cambiare IP (nuovo nodo di uscita)

Per ottenere un nuovo nodo di uscita, invia il segnale NEWNYM attraverso la porta di controllo (9051) con la libreria stem:

python
from stem import Signal
from stem.control import Controller

def renew_tor_ip():
    with Controller.from_port(port=9051) as controller:
        controller.authenticate(password="tua_password")
        controller.signal(Signal.NEWNYM)

La porta di controllo va abilitata nel torrc, impostando anche l’hash della password (tor --hash-password).

Tieni presente: molti grandi siti conoscono la lista dei nodi di uscita di TOR e li bloccano o mostrano un captcha. TOR va bene per compiti senza esigenze di velocità e con volumi bassi.


9. Lavorare con HTTPS/SSL

Di default requests verifica i certificati SSL tramite il pacchetto certifi. Nella maggior parte dei casi non c’è nulla da configurare. I problemi nascono sui siti con certificati autofirmati o scaduti.

Disattivare la verifica (solo per il debug)

python
import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
response = requests.get(url, verify=False)   # NON adatto alla produzione

Disattivare la verifica è insicuro: apre la porta a un attacco MITM. È ammissibile solo in locale, per il debug.

La via corretta: indicare la propria CA

python
response = requests.get(url, verify="/path/to/custom-ca-bundle.crt")

Aggiornare i certificati radice

Se ricevi SSLCertVerificationError su siti normali, aggiorna certifi:

bash
pip install --upgrade certifi

I cookie servono per le sessioni, l’autenticazione e per superare le pagine «di protezione» che impostano un token e fanno un redirect.

python
import requests

session = requests.Session()

# facciamo il login: il server restituirà il cookie di sessione
session.post("https://example.com/login", data={"user": "u", "pass": "p"})

# le richieste successive sono già autenticate
profile = session.get("https://example.com/profile")

Session conserva e reinvia da sola i cookie tra le richieste e, in più, riutilizza le connessioni TCP (più veloce) e le intestazioni comuni.

python
cookies = {"sessionid": "abc123", "csrftoken": "xyz789"}
response = requests.get(url, cookies=cookies)
python
response = requests.get(url)
for name, value in response.cookies.items():
    print(name, value)

11. Stato della risposta e intestazioni

Controllare la risposta del server è obbligatorio; altrimenti finirai per fare il parsing di una pagina di errore come se fossero «dati».

python
response = requests.get(url)

print(response.status_code)      # 200, 404, 403, 500 ...
print(response.reason)           # 'OK', 'Not Found'
print(response.headers["Content-Type"])
print(response.headers.get("Server"))
print(response.url)              # URL finale dopo i redirect
print(response.elapsed)          # tempo di risposta

Gestione corretta degli stati

python
if response.status_code == 200:
    parse(response.text)
elif response.status_code == 404:
    log("Pagina non trovata")
elif response.status_code == 429:
    # Too Many Requests: ci stanno rallentando
    wait = int(response.headers.get("Retry-After", 60))
    time.sleep(wait)
elif response.status_code in (403, 503):
    rotate_proxy()   # probabilmente un ban: cambiamo IP

L’intestazione Retry-After indica quanto aspettare prima di riprovare. Gli stati 403/503 tradiscono spesso una protezione anti-bot: aiutano il cambio di proxy, un altro User-Agent e una pausa.


12. Archiviazione di URL e code (panoramica)

Quando lo scraper percorre un sito intero, bisogna conservare da qualche parte gli URL «ancora da visitare» e quelli «già visitati». È quello che si chiama frontier (frontiera di scansione).

La variante più semplice: strutture in memoria

python
from collections import deque

to_visit = deque(["https://example.com"])
visited = set()

while to_visit:
    url = to_visit.popleft()
    if url in visited:
        continue
    visited.add(url)
    # ... scaricare, analizzare, aggiungere i nuovi link a to_visit

set garantisce una deduplicazione istantanea; deque funziona come coda FIFO.

Quando i dati crescono

  • Redis — coda condivisa per più worker, sopravvive ai riavvii. Le liste e i set di Redis sono ideali per la scansione distribuita.
  • Database (PostgreSQL/SQLite) — tabella di URL con stati new / in_progress / done / failed; comoda per la resilienza e l’analisi.
  • Code di task (Celery + broker, RabbitMQ) — quando lo scraping è integrato in un’applicazione; vedi «Web scraping con Django».
  • Filtro di Bloom — risparmia memoria con milioni di URL: verifica probabilistica dell’«abbiamo già visto questo URL».

In Scrapy la gestione della coda, la deduplicazione e le priorità sono integrate di serie: è una delle ragioni principali per scegliere questo framework nei progetti grandi. Maggiori dettagli in «Web scraping in Python con Scrapy».


13. Pro e contro dell’implementazione in Python

Pro:

  • Barriera d’ingresso bassa, codice leggibile, prototipo rapido.
  • Un ecosistema ricchissimo: da requests a Scrapy e Playwright.
  • Una community enorme: quasi ogni problema è già stato risolto.
  • Integrazione semplice con l’analisi dei dati (pandas, numpy) e i database.

Contro:

  • Il GIL limita l’elaborazione CPU-bound (si aggira con async e multiprocessing).
  • Il Python puro è più lento dei linguaggi compilati nel parsing pesante (lo salvano lxml/selectolax, scritti in C).
  • I siti dinamici con JS richiedono motori browser pesanti.
  • Fragilità: quando il sito cambia il layout, i selettori si rompono e serve manutenzione.

14. Aspetti legali ed etici

Il web scraping è uno strumento potente, e conviene usarlo con responsabilità:

  • Rispetta robots.txt e le condizioni d’uso del sito.
  • Non generare un carico eccessivo: aggiungi pause e limita la concorrenza.
  • Non raccogliere dati personali senza una base giuridica (ricorda il GDPR).
  • Indica uno User-Agent onesto dove è opportuno e metti in cache le risposte per non stressare il server più del necessario.