Scraping per linguaggio 26 min di lettura

Web scraping in Rust: guida completa, dal semplice al complesso

Web scraping in Rust: reqwest, scraper e tokio — come un sistema di tipi rigoroso e la velocità di un linguaggio compilato rendono sui grandi volumi.

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

Aggiornato al 2026. Versioni dei crate: reqwest 0.13, scraper 0.27, tokio 1.x, encoding_rs 0.8. Cambio importante: a partire da reqwest 0.13 il backend TLS predefinito è rustls (Rust puro), e non l’OpenSSL/native-tls di sistema.


Indice

  1. Introduzione: perché Rust per lo scraping
  2. Come scarichiamo la pagina (client HTTP)
  3. Librerie per il parsing del contenuto
  4. Come risolvere i problemi di codifica
  5. Multithreading e asincronia
  6. Uso dei proxy
  7. Scraping attraverso TOR
  8. Lavorare con HTTPS / SSL
  9. Lavorare con i cookie
  10. Stato della risposta e header
  11. Archiviazione degli URL e code (una panoramica)
  12. 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
  13. Architettura di un crawler completo
  14. Principali vantaggi e svantaggi dell’implementazione in Rust
  15. 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:

toml
[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 errori

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

rust
#[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.

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

toml
reqwest = { version = "0.13", features = ["blocking"] }
rust
fn main() -> anyhow::Result<()> {
    let body = reqwest::blocking::get("https://example.com")?.text()?;
    println!("{body}");
    Ok(())
}

Non chiamare il client blocking dentro 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.

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

rust
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).

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

  1. Prima guarda il charset nell’header Content-Type.
  2. Se non c’è, cerca <meta charset=...> / <meta http-equiv="Content-Type"> nei primi kilobyte dell’HTML.
  3. Se non c’è neanche lì, prova a indovinarla statisticamente (crate chardetng).
rust
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") di reqwest usa 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 rayon o su tokio::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).

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

rust
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):

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

toml
reqwest = { version = "0.13", features = ["socks"] }

6.1 Un proxy per client

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

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

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

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

rust
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).

toml
arti-client = "..."   # verifica la versione attuale: cargo add arti-client
tor-rtcompat = "..."
rust
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

toml
# 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:

rust
let client = reqwest::Client::builder()
    .danger_accept_invalid_certs(true) // ⚠ non sicuro
    .build()?;

8.3 Certificato radice proprio / certificato client

rust
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()?;

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:

toml
reqwest = { version = "0.13", features = ["cookies"] }
rust
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?;

Quando devi leggere o impostare i cookie a mano, o trasferirli da una sessione all’altra:

rust
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 accumulati

Se non vuoi attivare la gestione automatica, puoi passare i cookie direttamente come header:

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

rust
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 in Err, 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 reqwest segue 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)

rust
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 HashSet in 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 limitatore governor (token bucket):
rust
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:

toml
reqwest-middleware = "0.5"
reqwest-retry = "0.9"
rust
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 soli

12.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:

  1. Trovare l’API nascosta (vedi §3.2) — quasi sempre la strada migliore: più veloce, più affidabile, più leggera.
  2. 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-Agent credibile (non reqwest/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: anyhow per le applicazioni (un ? comodo e con contesto), thiserror per le librerie (tipi di errore propri). Non andare in panic a ogni 404: trattala come un risultato normale.
  • Logging/tracing: tracing + tracing-subscriber (oppure log + env_logger). Dai log si vede dove sono i colli di bottiglia e i ban.
rust
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:

Architettura di un crawler industriale in Rust: coda frontier con visited-set, pool di worker tokio con rate limit e rotazione dei proxy, client HTTP reqwest, decodifica e parsing (encoding_rs, scraper, serde_json), storage dei risultati e ritorno dei nuovi link al frontier

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) e Option al posto di null — meno crash in produzione.
  • Un unico binario statico: facile da distribuire, senza portarsi dietro un interprete e le dipendenze.
  • Ecosistema async maturo: tokio + reqwest sono 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/scrapy si 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 scrapy fatto e finito; in Rust la pipeline di solito la assembli mattone dopo mattone (anche se esistono spider e 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.txt e 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