Aggiornato al 2026. Versioni dei crate:
reqwest 0.13,scraper 0.27,tokio 1.x,encoding_rs 0.8. Cambio importante: a partire dareqwest 0.13il backend TLS predefinito è rustls (Rust puro), e non l’OpenSSL/native-tls di sistema.
Indice
- Introduzione: perché Rust per lo scraping
- Come scarichiamo la pagina (client HTTP)
- Librerie per il parsing del contenuto
- Come risolvere i problemi di codifica
- Multithreading e asincronia
- Uso dei proxy
- Scraping attraverso TOR
- Lavorare con HTTPS / SSL
- Lavorare con i cookie
- Stato della risposta e header
- Archiviazione degli URL e code (una panoramica)
- Extra: quello che spesso si dimentica
- Cortesia, robots.txt, rate limiting
- Retry e backoff
- Pagine JavaScript (browser headless)
- User-Agent e protezioni anti-bot
- Gestione degli errori e logging
- Architettura di un crawler completo
- Principali vantaggi e svantaggi dell’implementazione in Rust
- Aspetti legali ed etici
1. Introduzione
Il web scraping è l’ottenimento automatico dell’HTML/JSON/XML delle pagine e l’estrazione di dati strutturati da questi formati. Ogni scraper si compone di due grandi parti:
- il livello di rete — scarica la pagina (client HTTP);
- il livello di parsing — trasforma l’HTML «grezzo» nei campi che ti servono (parser + selettori).
Poi si aggiungono proxy, concorrenza, elusione delle protezioni anti-bot, archiviazione della coda dei link e così via. Rust si distingue perché offre una velocità paragonabile al C e un consumo di memoria minimo, con un parallelismo sicuro — esattamente ciò che diventa critico quando scarichi milioni di pagine.
Il Cargo.toml di partenza, a cui aggiungeremo funzionalità un po’ alla volta:
[package]
name = "parser-demo"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.13", features = ["json", "gzip", "brotli"] }
tokio = { version = "1", features = ["full"] }
scraper = "0.27"
encoding_rs = "0.8"
anyhow = "1" # gestione comoda degli erroriPagine ufficiali dei crate di base: reqwest, tokio, scraper, encoding_rs, anyhow.
2. Come scarichiamo la pagina
Nell’ecosistema Rust esistono diversi client HTTP. Per lo scraping, nel 99% dei casi si sceglie reqwest.
| Crate | Quando usarlo |
|---|---|
reqwest |
La scelta principale. Async + blocking, proxy, cookie, TLS: c’è tutto. |
ureq |
Client sincrono leggero, senza tokio. Per script semplici. |
isahc |
Client async basato su libcurl. |
hyper |
Di basso livello. Serve quando costruisci il tuo client/server. |
Documentazione: docs.rs/reqwest.
2.1 La richiesta più semplice (async)
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let body = reqwest::get("https://example.com")
.await? // attendiamo la risposta
.text() // leggiamo il corpo come stringa
.await?;
println!("{body}");
Ok(())
}2.2 Il modo giusto — un Client riutilizzabile
reqwest::get crea un client nuovo a ogni chiamata. È costoso: si perde il pool di connessioni (keep-alive). Crea un solo Client e clonalo — all’interno è un Arc, e il clone costa poco.
use std::time::Duration;
use reqwest::Client;
fn build_client() -> anyhow::Result<Client> {
let client = Client::builder()
// ci fingiamo un normale browser
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/124.0 Safari/537.36")
.timeout(Duration::from_secs(30)) // timeout complessivo della richiesta
.connect_timeout(Duration::from_secs(10)) // timeout di apertura della connessione
.gzip(true) // decompressione automatica gzip
.brotli(true) // decompressione automatica brotli
.build()?;
Ok(client)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let client = build_client()?;
let resp = client
.get("https://example.com")
.header("Accept-Language", "it-IT,it;q=0.9")
.send()
.await?;
println!("Stato: {}", resp.status());
let html = resp.text().await?;
println!("Lunghezza dell'HTML: {}", html.len());
Ok(())
}2.3 La variante sincrona (senza tokio)
Se per un piccolo script non vuoi trascinarti dietro un runtime async, c’è blocking:
reqwest = { version = "0.13", features = ["blocking"] }fn main() -> anyhow::Result<()> {
let body = reqwest::blocking::get("https://example.com")?.text()?;
println!("{body}");
Ok(())
}Non chiamare il client
blockingdentro un runtime async: andrà in panic. Scegli una delle due strade.
3. Librerie per il parsing
Una volta scaricato l’HTML, bisogna analizzarlo. La regola d’oro: non fare il parsing dell’HTML con le espressioni regolari. L’HTML non è un linguaggio regolare: quell’approccio si romperà alla prima virgoletta senza escape. Le regex hanno senso solo per estrarre piccoli dettagli da un testo già individuato.
| Crate | Approccio | Note |
|---|---|---|
scraper |
Selettori CSS | Il più popolare. Wrapper sopra html5ever, di Servo. |
dom_query |
Selettori CSS + manipolazione | Alternativa recente; sa modificare il DOM. |
select |
DSL di predicati proprio | Più datato, ma funzionante. |
html5ever |
tokenizer di basso livello | Parser di qualità browser. Usato all’interno di scraper. |
lol_html |
rewriter in streaming | Di Cloudflare. Per documenti molto grandi, «al volo». |
quick-xml |
XML / RSS / sitemap | Parser XML veloce in streaming. |
serde_json |
JSON | Per risposte di API e JSON incorporato. |
3.1 scraper + selettori CSS
Documentazione ed esempi: docs.rs/scraper.
use scraper::{Html, Selector};
fn parse_articles(html: &str) -> anyhow::Result<()> {
let document = Html::parse_document(html);
// Meglio compilare i selettori una sola volta (fuori dal ciclo).
let item_sel = Selector::parse("article.post").unwrap();
let title_sel = Selector::parse("h2.title > a").unwrap();
let date_sel = Selector::parse("time.published").unwrap();
for item in document.select(&item_sel) {
let title = item
.select(&title_sel)
.next()
.map(|e| e.text().collect::<String>().trim().to_string())
.unwrap_or_default();
// il link, dall'attributo href
let link = item
.select(&title_sel)
.next()
.and_then(|e| e.value().attr("href"))
.unwrap_or("");
// la data, dall'attributo datetime
let date = item
.select(&date_sel)
.next()
.and_then(|e| e.value().attr("datetime"))
.unwrap_or("");
println!("{title} | {date} | {link}");
}
Ok(())
}Trucchi utili di scraper:
element.text().collect::<String>()— raccoglie tutto il testo interno (compreso quello annidato).element.value().attr("href")— prende un attributo.element.html()/element.inner_html()— restituisce l’HTML originale.- I selettori supportano
[attr="value"],:nth-child,>, (discendenti) ecc.
3.2 JSON da un’API
Spesso è più semplice prendere i dati non dall’HTML, ma dall’API JSON nascosta che la pagina stessa interroga. Apri DevTools → scheda Network → trova la richiesta che restituisce JSON. È più affidabile di qualsiasi parsing del markup. La deserializzazione si fa con serde + serde_json.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Product {
id: u64,
name: String,
price: f64,
}
async fn fetch_products(client: &reqwest::Client) -> anyhow::Result<Vec<Product>> {
let products = client
.get("https://shop.example.com/api/products")
.send()
.await?
.json::<Vec<Product>>() // deserializzazione diretta nelle struct
.await?;
Ok(products)
}3.3 Sitemap e RSS con quick-xml
Le mappe del sito (sitemap.xml) sono il modo migliore per conoscere tutti gli URL di un sito senza percorrerne i link. Si parsano come XML normale con quick-xml (o con un crate specializzato come sitemap).
4. Codifiche e caratteri speciali
Un classico grattacapo quando si fa scraping della parte più datata del web in lingua italiana: molti siti vecchi servono il contenuto in Windows-1252 o ISO-8859-1 (Latin-1), non in UTF-8.
Perché si rompe
Il metodo resp.text() determina la codifica dall’header Content-Type: text/html; charset=.... Se l’header non indica il charset e questo compare solo nell’HTML (<meta charset="windows-1252">), reqwest assume per impostazione predefinita che sia UTF-8 — e ti ritrovi con il «mojibake»: perch� al posto di perché (o, nel caso inverso, perché al posto di perché).
La soluzione: leggere i byte e decodificare da soli
Prendi i byte «grezzi» con .bytes() e decodificali con la codifica corretta usando encoding_rs (lo stesso motore di Firefox).
use encoding_rs::{Encoding, WINDOWS_1252, UTF_8};
async fn get_text_win1252(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
let resp = client.get(url).send().await?;
let bytes = resp.bytes().await?;
// Decodifichiamo come Windows-1252.
let (text, _enc, had_errors) = WINDOWS_1252.decode(&bytes);
if had_errors {
eprintln!("Attenzione: ci sono stati errori durante la decodifica");
}
Ok(text.into_owned())
}Rilevamento automatico della codifica
Meglio non fissare la codifica nel codice, ma rilevarla. L’algoritmo:
- Prima guarda il
charsetnell’headerContent-Type. - Se non c’è, cerca
<meta charset=...>/<meta http-equiv="Content-Type">nei primi kilobyte dell’HTML. - Se non c’è neanche lì, prova a indovinarla statisticamente (crate
chardetng).
use encoding_rs::Encoding;
use reqwest::header::CONTENT_TYPE;
async fn get_text_smart(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
let resp = client.get(url).send().await?;
// 1) proviamo a prendere il charset dall'header
let header_charset = resp
.headers()
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.and_then(|ct| ct.split("charset=").nth(1))
.map(|s| s.trim().to_string());
let bytes = resp.bytes().await?;
// 2) se nell'header non c'è, lo cerchiamo nei <meta> (semplificato: primi 1024 byte)
let charset = header_charset.or_else(|| {
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(1024)]);
head.to_lowercase()
.split("charset=")
.nth(1)
.map(|s| s.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-')
.to_string())
});
// 3) scegliamo la codifica (UTF-8 per impostazione predefinita)
let enc = charset
.as_deref()
.and_then(|name| Encoding::for_label(name.as_bytes()))
.unwrap_or(encoding_rs::UTF_8);
let (text, _, _) = enc.decode(&bytes);
Ok(text.into_owned())
}Alternativa: il metodo
resp.text_with_charset("windows-1252")direqwestusa la codifica indicata come riserva quando il charset non arriva nell’header. È più semplice, ma non copre il caso «l’header dice UTF-8 e in realtà è 1252».
5. Multithreading e asincronia
Lo scraping è quasi sempre I/O-bound: il processore resta fermo mentre i pacchetti viaggiano in rete. Per questo in Rust qui non vincono i «thread», ma l’asincronia su tokio: migliaia di richieste simultanee in uno o due thread del sistema operativo.
Distingui due compiti:
- Scaricare (I/O-bound) → async/
tokio, molte connessioni simultanee. - Fare parsing (CPU-bound: html5ever mette sotto sforzo la CPU) → con grandi volumi spostalo su
rayono sutokio::task::spawn_blocking, per non bloccare il runtime async.
5.1 Concorrenza con limite — buffer_unordered
Il modo più idiomatico: trasformiamo il flusso di URL in un flusso di future, e buffer_unordered(N) ne esegue al massimo N alla volta (dal crate futures).
use futures::stream::{self, StreamExt};
async fn crawl_many(client: &reqwest::Client, urls: Vec<String>) {
let concurrency = 20; // non più di 20 richieste alla volta
let results = stream::iter(urls)
.map(|url| {
let client = client.clone(); // il clone costa poco (Arc all'interno)
async move {
match client.get(&url).send().await {
Ok(resp) => {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
(url, status.as_u16(), body.len())
}
Err(e) => {
eprintln!("Errore {url}: {e}");
(url, 0, 0)
}
}
}
})
.buffer_unordered(concurrency)
.collect::<Vec<_>>()
.await;
for (url, status, len) in results {
println!("{status} {len:>8} {url}");
}
}5.2 Limite con Semaphore
Quando i task si lanciano con tokio::spawn, il limite si tiene con un semaforo:
use std::sync::Arc;
use tokio::sync::Semaphore;
async fn crawl_with_semaphore(client: reqwest::Client, urls: Vec<String>) {
let sem = Arc::new(Semaphore::new(20)); // massimo 20 «in volo»
let mut handles = Vec::new();
for url in urls {
let client = client.clone();
let sem = sem.clone();
handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap(); // aspettiamo uno slot libero
let _ = client.get(&url).send().await;
// il permesso si libera all'uscita dallo scope
}));
}
for h in handles {
let _ = h.await;
}
}5.3 Parsing CPU-bound con rayon
Se hai già scaricato migliaia di HTML e devi farne il parsing in fretta, è un lavoro per tutti i core (rayon):
use rayon::prelude::*;
fn parse_all(pages: Vec<String>) -> Vec<usize> {
pages
.par_iter() // iteratore parallelo
.map(|html| {
let doc = scraper::Html::parse_document(html);
doc.select(&scraper::Selector::parse("a").unwrap()).count()
})
.collect()
}6. Proxy
I proxy servono per (a) non sbattere contro un ban dell’IP durante lo scraping massivo e (b) aggirare le restrizioni geografiche. reqwest supporta proxy HTTP, HTTPS e SOCKS5.
Per SOCKS attiva la feature:
reqwest = { version = "0.13", features = ["socks"] }6.1 Un proxy per client
use reqwest::{Client, Proxy};
fn client_with_proxy() -> anyhow::Result<Client> {
let proxy = Proxy::all("http://proxy.example.com:8080")?
.basic_auth("user", "password"); // se serve l'autenticazione
let client = Client::builder()
.proxy(proxy)
.build()?;
Ok(client)
}Proxy::http(...), Proxy::https(...) e Proxy::all(...) impostano il proxy per gli schemi corrispondenti. SOCKS5:
let proxy = reqwest::Proxy::all("socks5://127.0.0.1:1080")?;6.2 Rotazione di un pool di proxy
Un Client è legato a un solo proxy. Per ruotare, la cosa più comoda è tenere un client per proxy e sceglierli a turno:
use std::sync::atomic::{AtomicUsize, Ordering};
use reqwest::{Client, Proxy};
struct ProxyPool {
clients: Vec<Client>,
idx: AtomicUsize,
}
impl ProxyPool {
fn new(proxies: &[&str]) -> anyhow::Result<Self> {
let clients = proxies
.iter()
.map(|p| {
Client::builder()
.proxy(Proxy::all(*p)?)
.build()
.map_err(Into::into)
})
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(Self { clients, idx: AtomicUsize::new(0) })
}
/// Restituisce il client successivo in round-robin.
fn next(&self) -> &Client {
let i = self.idx.fetch_add(1, Ordering::Relaxed) % self.clients.len();
&self.clients[i]
}
}I proxy residenziali/mobili con rotazione automatica lato provider di solito espongono un unico indirizzo «gateway»: in quel caso la rotazione dalla tua parte non serve, basta un solo client.
7. Scraping attraverso TOR
TOR offre anonimato e una «rotazione» gratuita degli IP (nuovo circuito → nuovo nodo di uscita). Ci sono due strade.
7.1 La strada semplice: TOR esterno + SOCKS5
Avvia il TOR di sistema (il demone tor o Tor Browser), che apre un proxy SOCKS5 su 127.0.0.1:9050 (per Tor Browser, il 9150). Da lì in poi funziona come un normale proxy SOCKS:
use reqwest::{Client, Proxy};
fn tor_client() -> anyhow::Result<Client> {
// IMPORTANTE: socks5h (con la lettera h), non socks5.
// 'h' = risoluzione DNS lato proxy (dentro TOR);
// altrimenti avrai fughe DNS e i .onion non funzioneranno.
let proxy = Proxy::all("socks5h://127.0.0.1:9050")?;
let client = Client::builder()
.proxy(proxy)
.build()?;
Ok(client)
}Verifica che il traffico passi davvero da TOR:
async fn check_tor(client: &reqwest::Client) -> anyhow::Result<()> {
let txt = client
.get("https://check.torproject.org/api/ip")
.send().await?
.text().await?;
println!("{txt}"); // {"IsTor":true,"IP":"..."}
Ok(())
}Il cambio di circuito (nuovo IP) si fa attraverso la control-port di TOR (di solito la 9051): bisogna inviare il segnale NEWNYM. Si fa a mano con il protocollo della control-port o con un crate che lo incapsula. Dopo un NEWNYM di solito si rispetta una pausa (TOR limita la frequenza dei cambi a ~una volta ogni 10 secondi).
7.2 TOR integrato: arti
Arti è l’implementazione di TOR in Rust puro dello stesso Tor Project. Permette di integrare TOR direttamente nell’applicazione, senza demone esterno. L’API client di alto livello sta nel crate arti-client (docs.rs).
arti-client = "..." # verifica la versione attuale: cargo add arti-client
tor-rtcompat = "..."use arti_client::{TorClient, TorClientConfig};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = TorClientConfig::default();
// Avviamo il client TOR integrato e attendiamo il bootstrap.
let tor = TorClient::create_bootstrapped(config).await?;
// Poi si possono aprire stream TCP anonimi (AsyncRead/AsyncWrite)
// e inviarci sopra HTTP a mano oppure tramite hyper.
let mut stream = tor.connect(("example.com", 80)).await?;
// ... invio della richiesta HTTP sullo stream ...
Ok(())
}C’è anche un crate «collante», artiqwest, che instrada le richieste HTTP attraverso arti con un’API in stile reqwest (get/post), inclusi i .onion e i websocket.
Svantaggi di arti: l’API non è ancora stabilizzata (fino alla 1.x sono possibili breaking change) e non copre tutte le funzioni di C-Tor. In compenso non serve un processo esterno e il deploy è più semplice.
Avvertenza: i nodi di uscita di TOR sono saturi, lenti e spesso bannati sui siti popolari. TOR va bene per l’anonimato e per accedere ai .onion, ma è pessimo come «pool gratuito di proxy veloci».
8. HTTPS / SSL
Da reqwest 0.13 HTTPS funziona «di serie»: il backend predefinito è rustls (Rust puro, non richiede l’OpenSSL di sistema). Di solito non c’è nulla da configurare.
8.1 Scelta del backend TLS
# rustls (predefinito) — multipiattaforma, non serve OpenSSL
reqwest = { version = "0.13" }
# oppure il TLS di sistema (schannel su Windows, Secure Transport su macOS, OpenSSL su Linux)
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }
# oppure OpenSSL compilato staticamente (comodo per distribuire il binario)
reqwest = { version = "0.13", default-features = false, features = ["native-tls-vendored"] }Backend TLS: rustls, native-tls, openssl.
8.2 Ignorare gli errori di certificato (pericoloso!)
A volte devi fare scraping di un sito con un certificato autofirmato o scaduto. Puoi disattivare la verifica — ma solo per i test e per host fidati, perché elimina la protezione dagli attacchi MITM:
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) // ⚠ non sicuro
.build()?;8.3 Certificato radice proprio / certificato client
use reqwest::{Certificate, Identity};
// Aggiungere una CA aziendale o autofirmata:
let ca = Certificate::from_pem(&std::fs::read("my-ca.pem")?)?;
// Certificato client (mTLS):
let id = Identity::from_pem(&std::fs::read("client.pem")?)?;
let client = reqwest::Client::builder()
.add_root_certificate(ca)
.identity(id)
.build()?;9. Cookie
I cookie servono per le sessioni, l’autenticazione e per superare alcune protezioni. reqwest sa memorizzarli e allegarli automaticamente tra una richiesta e l’altra.
Attiva la feature:
reqwest = { version = "0.13", features = ["cookies"] }9.1 Cookie store automatico
let client = reqwest::Client::builder()
.cookie_store(true) // attivare la memorizzazione automatica dei cookie
.build()?;
// 1) facciamo il login — il server restituirà Set-Cookie e il client li ricorderà
client.post("https://site.example/login")
.form(&[("user", "alice"), ("pass", "secret")])
.send().await?;
// 2) le richieste successive partiranno automaticamente con quei cookie
let dashboard = client.get("https://site.example/dashboard")
.send().await?
.text().await?;9.2 Un cookie jar proprio (accesso ai valori / riutilizzo)
Quando devi leggere o impostare i cookie a mano, o trasferirli da una sessione all’altra:
use std::sync::Arc;
use reqwest::cookie::{Jar, CookieStore};
use reqwest::Url;
let jar = Arc::new(Jar::default());
// Inserire un cookie a mano, in anticipo:
let url: Url = "https://site.example/".parse()?;
jar.add_cookie_str("session=abc123; Domain=site.example; Path=/", &url);
let client = reqwest::Client::builder()
.cookie_provider(jar.clone()) // usiamo il nostro jar
.build()?;
// dopo le richieste si possono leggere dal jar i cookie accumulati9.3 Cookie a mano nell’header
Se non vuoi attivare la gestione automatica, puoi passare i cookie direttamente come header:
let resp = client.get(url)
.header(reqwest::header::COOKIE, "session=abc123; lang=it")
.send().await?;10. Stato della risposta e header
Prima di fare il parsing dell’HTML conviene quasi sempre verificare che la pagina sia arrivata davvero (200), e non come 404/403/429/5xx.
use reqwest::StatusCode;
use reqwest::header::{CONTENT_TYPE, CONTENT_LENGTH, LOCATION, RETRY_AFTER};
async fn fetch(client: &reqwest::Client, url: &str) -> anyhow::Result<Option<String>> {
let resp = client.get(url).send().await?;
let status = resp.status();
println!("HTTP {} ({})", status.as_u16(), status.canonical_reason().unwrap_or(""));
// Controlli comodi per categoria di stato:
if status.is_success() { // 2xx
// leggiamo gli header che ci interessano
let headers = resp.headers();
if let Some(ct) = headers.get(CONTENT_TYPE).and_then(|v| v.to_str().ok()) {
println!("Content-Type: {ct}");
// facciamo il parsing solo dell'HTML; le immagini si saltano
if !ct.contains("text/html") {
return Ok(None);
}
}
if let Some(len) = headers.get(CONTENT_LENGTH) {
println!("Content-Length: {len:?}");
}
let body = resp.text().await?;
return Ok(Some(body));
}
if status.is_redirection() { // 3xx
if let Some(loc) = resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()) {
println!("Redirect verso: {loc}");
}
}
if status == StatusCode::TOO_MANY_REQUESTS { // 429
// il server chiede di aspettare
if let Some(ra) = resp.headers().get(RETRY_AFTER).and_then(|v| v.to_str().ok()) {
println!("Ci stanno rallentando. Retry-After: {ra} s");
}
}
Ok(None)
}Metodi utili:
resp.status()→StatusCode; ci sono.is_success(),.is_client_error(),.is_server_error(),.is_redirection().resp.error_for_status()— trasforma i 4xx/5xx inErr, comodo con?.resp.headers()→HeaderMap, iterabile come una map.resp.url()— l’URL finale dopo i redirect.resp.content_length()— la lunghezza del corpo, se nota.
Per impostazione predefinita
reqwestsegue da solo i redirect (fino a 10). Il comportamento si regola con.redirect(reqwest::redirect::Policy::none())o.limited(n).
11. Archiviazione degli URL e code
Ogni crawler è, in sostanza, un ciclo: «prendere un URL dalla coda → scaricare la pagina → estrarre i link nuovi → rimetterli in coda». Qui servono due strutture:
- la coda (frontier) — cosa scaricare dopo;
- l’insieme degli URL visitati (visited/seen) — per non scaricare due volte la stessa cosa.
11.1 In memoria (per compiti piccoli)
use std::collections::{VecDeque, HashSet};
struct Frontier {
queue: VecDeque<String>,
seen: HashSet<String>,
}
impl Frontier {
fn new() -> Self {
Self { queue: VecDeque::new(), seen: HashSet::new() }
}
/// Aggiunge l'URL se non è già stato visto.
fn push(&mut self, url: String) {
if self.seen.insert(url.clone()) { // insert restituisce false se c'era già
self.queue.push_back(url);
}
}
fn pop(&mut self) -> Option<String> {
self.queue.pop_front()
}
}Per l’accesso concorrente da più task async, la coda si costruisce sui canali: tokio::sync::mpsc, flume o crossbeam-channel. I worker leggono dal canale e ci riscrivono i link nuovi.
11.2 Persistenza (per esplorazioni grandi e lunghe)
Con milioni di URL la memoria finisce, e se il processo cade perdi i progressi. Per questo la coda e i «visitati» si portano in uno storage esterno:
| Storage | Crate | Quando |
|---|---|---|
| Redis | redis |
Coda distribuita tra più worker. |
| SQLite | rusqlite / sqlx |
Un solo processo, serve una persistenza semplice. |
| PostgreSQL | sqlx |
Grandi volumi, analisi, più macchine. |
| RocksDB / sled | rocksdb / sled |
Key-value locale molto veloce. |
Deduplicazione su larga scala: tenere tutti gli URL in un HashSet è costoso. Si usano: - la normalizzazione degli URL (togliere il #fragment, ordinare i parametri della query, portare l’host in minuscolo) con il crate url — altrimenti la stessa pagina entrerà con URL diversi; - un hash dell’URL (per esempio xxhash-rust / blake3) al posto della stringa intera; - il filtro di Bloom (bloomfilter) — una struttura probabilistica compatta del tipo «forse visto / sicuramente mai visto».
Questa sezione è solo una panoramica. In pratica la scelta dipende dalla scala: per un paio di migliaia di pagine basta un
HashSetin memoria; per un crawler industriale, coda su Redis + filtro di Bloom + normalizzazione degli URL.
12. Extra
Quello che non è entrato nell’elenco iniziale, ma senza cui uno scraper reale si rompe o si becca un ban.
12.1 Cortesia, robots.txt e rate limiting
- robots.txt — il file in cui il sito indica cosa si può esplorare e cosa no. Lo scraping etico (e a volte anche quello giuridicamente prudente) lo rispetta. Crate:
texting_robots,robotstxt. - Pause tra le richieste allo stesso dominio, per non buttare giù il sito e non farti bannare. La variante più semplice è
tokio::time::sleep; quella professionale, il limitatoregovernor(token bucket):
use std::num::NonZeroU32;
use governor::{Quota, RateLimiter};
// non più di 5 richieste al secondo
let limiter = RateLimiter::direct(Quota::per_second(NonZeroU32::new(5).unwrap()));
// prima di ogni richiesta:
limiter.until_ready().await;
// client.get(...).send().await?;12.2 Retry e backoff
La rete è instabile: timeout, 503, connessioni interrotte. Servono retry con attesa esponenziale (1 s → 2 s → 4 s...). La via più semplice sono i crate reqwest-middleware + reqwest-retry:
reqwest-middleware = "0.5"
reqwest-retry = "0.9"use reqwest_middleware::ClientBuilder;
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
let client = ClientBuilder::new(reqwest::Client::new())
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();
// da qui in poi client.get(...).send().await — i retry avvengono da soli12.3 Pagine JavaScript (browser headless)
reqwest + scraper vedono solo l’HTML originale. Se il contenuto viene disegnato dal JavaScript (una SPA in React/Vue), in quell’HTML non ci sarà. Le opzioni:
- Trovare l’API nascosta (vedi §3.2) — quasi sempre la strada migliore: più veloce, più affidabile, più leggera.
- Pilotare un browser vero (che il JS lo renderizza davvero):
| Crate | Protocollo | Note |
|---|---|---|
chromiumoxide |
CDP (Chrome DevTools) | Async; controlla Chrome direttamente. |
thirtyfour |
WebDriver | Compatibile con Selenium; comoda API di alto livello. |
fantoccini |
WebDriver | Più leggero di thirtyfour. |
headless_chrome |
CDP | Wrapper sincrono sopra CDP. |
Un browser costa decine di volte di più in risorse: usalo solo dove senza JS non si va da nessuna parte.
12.4 User-Agent, header e protezioni anti-bot
I siti distinguono i bot dalle persone. Il camuffamento minimo:
- uno
User-Agentcredibile (nonreqwest/0.13!); - un set realistico di header:
Accept,Accept-Language,Accept-Encoding,Referer,Sec-Fetch-*; - rotazione di User-Agent e proxy;
- pause dal ritmo umano.
Le protezioni serie (Cloudflare, DataDome, PerimeterX) verificano in più il fingerprint TLS (JA3/JA4) e l’ordine degli header HTTP/2. Un reqwest normale lascia un’impronta «alla Rust», diversa da quella di Chrome. Per aggirarla esistono crate che imitano l’impronta del browser sulla base di curl-impersonate, per esempio rquest. È una «corsa agli armamenti»: garanzie non ce ne sono.
12.5 Gestione degli errori e logging
- Errori:
anyhowper le applicazioni (un?comodo e con contesto),thiserrorper le librerie (tipi di errore propri). Non andare in panic a ogni 404: trattala come un risultato normale. - Logging/tracing:
tracing+tracing-subscriber(oppurelog+env_logger). Dai log si vede dove sono i colli di bottiglia e i ban.
use anyhow::Context;
let html = client.get(url).send().await
.with_context(|| format!("impossibile scaricare {url}"))?
.text().await
.context("impossibile leggere il corpo")?;13. Architettura del crawler
Lo schema di uno scraper di livello industriale che mette insieme tutto quanto sopra:
Principi chiave: - un unico Client condiviso (pool di connessioni), clonato verso i worker; - la concorrenza è limitata da un semaforo, la velocità per dominio da un limitatore; - ogni chiamata di rete è avvolta in retry/backoff; - la coda e l’insieme seen sono l’unica fonte di verità sui progressi.
Se non vuoi assemblare tutto a mano, esistono framework di crawling già pronti, per esempio
spider.
14. Vantaggi e svantaggi
Vantaggi dell’implementazione in Rust
- Prestazioni: velocità vicina a C/C++. Sui grandi volumi Rust supera di parecchie volte Python (
requests/BeautifulSoup) e Go per CPU e memoria. - Memoria: consumo minimo e niente pause di GC — importante con milioni di pagine ed esplorazioni di lunga durata.
- Multithreading senza paura: il sistema di tipi e il borrow-checker catturano le data race in fase di compilazione. Un vantaggio enorme per uno scraper concorrente.
- Affidabilità: gestione esplicita degli errori (
Result) eOptional posto dinull— meno crash in produzione. - Un unico binario statico: facile da distribuire, senza portarsi dietro un interprete e le dipendenze.
- Ecosistema async maturo:
tokio+reqwestsono production-grade.
Svantaggi
- Curva di apprendimento: borrow-checker, lifetimes, async — imparare richiede più tempo che mettere insieme «in una serata» uno scraper in Python.
- Velocità di sviluppo: un prototipo in Python/
scrapysi scrive prima. Per un compito una tantum Rust può essere eccessivo. - Contenuti dinamici: meno soluzioni pronte «di serie» per il rendering JS e per aggirare le protezioni anti-bot rispetto a Python (dove ci sono Playwright, Scrapy, undetected-chromedriver ecc.).
- Compilazione: tempi di build lunghi, soprattutto con dipendenze pesanti.
- Meno framework completi: Python ha uno
scrapyfatto e finito; in Rust la pipeline di solito la assembli mattone dopo mattone (anche se esistonospidere altri).
Conclusione: Rust si giustifica quando lo scraping è massivo, continuativo e sensibile alle risorse (milioni di pagine, requisiti stringenti su velocità e memoria, un servizio che vive a lungo). Per uno «scrapiamo 500 pagine» una tantum, Python di solito richiede meno sforzo complessivo.
15. Aspetti legali ed etici
Tecnicamente si può fare molto — il che non significa che si debba. In breve:
- robots.txt e ToS: rispetta il
robots.txte le condizioni d’uso del sito. - Carico: non mettere in ginocchio il server altrui — limita la frequenza delle richieste e fai scraping fuori dalle ore di punta.
- Dati personali: la loro raccolta è regolata dalla legge (il GDPR nell’UE e norme analoghe in altri Paesi). Muoviti con cautela.
- Diritto d’autore: i contenuti possono essere protetti; copiarli e ripubblicarli in massa può essere illegale.
- Identificati: è sensato indicare un contatto nello User-Agent, così l’amministratore del sito può scriverti invece di bannare alla cieca.
Questo è un orientamento generale, non una consulenza legale: nei casi dubbi rivolgiti a un avvocato.
Scheda rapida dei crate
| Compito | Crate |
|---|---|
| Client HTTP | reqwest (async/blocking), ureq (sync) |
| Parsing dell’HTML | scraper, dom_query, select |
| JSON / XML | serde_json, quick-xml |
| Codifiche legacy | encoding_rs, chardetng |
| Runtime async | tokio, futures |
| Parallelismo CPU | rayon |
| Rate limiting | governor |
| Retry | reqwest-middleware, reqwest-retry |
| TOR | TOR esterno + socks, oppure arti-client / artiqwest |
| Browser headless | chromiumoxide, thirtyfour, fantoccini |
| robots.txt | texting_robots |
| Code/storage | redis, rusqlite / sqlx, sled, bloomfilter |
| Errori/log | anyhow, thiserror, tracing |
| Framework già pronto | spider |