Le scraping par langage 26 min de lecture

Web scraping en Rust : le guide complet, du simple au complexe

Web scraping en Rust : reqwest, scraper, tokio — comment le typage strict et la vitesse d'un langage compilé servent les gros volumes.

ÉW
Équipe Web-Scraping.fr
Collecte de données pour votre activité
Publié le: 5 avril 2025

À jour pour 2026. Versions des crates : reqwest 0.13, scraper 0.27, tokio 1.x, encoding_rs 0.8. Changement important : depuis reqwest 0.13, le backend TLS par défaut est rustls (Rust pur), et non plus l'OpenSSL/native-tls du système.


Sommaire

  1. Introduction : pourquoi Rust pour le scraping
  2. Récupérer la page (client HTTP)
  3. Les bibliothèques de parsing du contenu
  4. Résoudre les problèmes de cyrillique (encodages)
  5. Multithreading et asynchrone
  6. Utiliser des proxys
  7. Scraper via TOR
  8. Travailler avec HTTPS / SSL
  9. Travailler avec les cookies
  10. Statut de la réponse et en-têtes
  11. Stockage des URL et files d'attente (survol)
  12. En complément : ce qu'on oublie souvent
    • Politesse, robots.txt, rate limiting
    • Nouvelles tentatives et backoff
    • Pages JavaScript (navigateurs headless)
    • User-Agent et protection anti-bot
    • Gestion des erreurs et logging
  13. Architecture d'un crawler complet
  14. Principaux avantages et inconvénients d'une implémentation en Rust
  15. Aspects juridiques et éthiques

1. Introduction

Le scraping consiste à récupérer automatiquement du HTML/JSON/XML depuis des pages web et à en extraire des données structurées. Tout scraper se compose de deux grandes parties :

  • la couche réseau — télécharge la page (client HTTP) ;
  • la couche d'analyse — transforme le HTML « brut » en champs exploitables (parseur + sélecteurs).

Viennent ensuite les proxys, le multithreading, le contournement des protections anti-bots, le stockage de la file de liens, etc. L'atout de Rust : une vitesse proche du C et une consommation mémoire minimale, avec un parallélisme sûr — exactement ce qui compte quand vous téléchargez des millions de pages.

Le Cargo.toml de départ, que nous enrichirons progressivement :

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"          # gestion des erreurs simplifiée

Pages officielles des crates de base : reqwest, tokio, scraper, encoding_rs, anyhow.


2. Récupérer la page

L'écosystème Rust compte plusieurs clients HTTP. Pour le scraping, dans 99 % des cas, on prend reqwest.

Crate Quand l'utiliser
reqwest Le choix par défaut. Async + blocking, proxys, cookies, TLS — tout y est.
ureq Client synchrone léger, sans tokio. Pour les scripts simples.
isahc Client async basé sur libcurl.
hyper Bas niveau. Utile quand vous construisez votre propre client/serveur.

Documentation : docs.rs/reqwest.

2.1 La requête la plus simple (async)

rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let body = reqwest::get("https://example.com")
        .await?      // on attend la réponse
        .text()      // on lit le corps comme une chaîne
        .await?;
    println!("{body}");
    Ok(())
}

2.2 La bonne méthode — un Client réutilisable

reqwest::get crée un nouveau client à chaque appel. C'est coûteux : on perd le pool de connexions (keep-alive). Créez un seul Client et clonez-le — c'est un Arc en interne, le clone est bon marché.

rust
use std::time::Duration;
use reqwest::Client;

fn build_client() -> anyhow::Result<Client> {
    let client = Client::builder()
        // on se fait passer pour un navigateur classique
        .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 global de la requête
        .connect_timeout(Duration::from_secs(10))  // timeout d'établissement de la connexion
        .gzip(true)                                // décompression gzip automatique
        .brotli(true)                              // décompression brotli automatique
        .build()?;
    Ok(client)
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let client = build_client()?;
    let resp = client
        .get("https://example.com")
        .header("Accept-Language", "ru-RU,ru;q=0.9")
        .send()
        .await?;

    println!("Statut : {}", resp.status());
    let html = resp.text().await?;
    println!("Longueur du HTML : {}", html.len());
    Ok(())
}

2.3 La variante synchrone (sans tokio)

Si vous ne voulez pas embarquer un runtime async pour un petit script, il y a 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(())
}

N'appelez jamais le client blocking depuis un runtime async — cela provoque une panique. Choisissez l'un ou l'autre.


3. Les bibliothèques de parsing

Une fois le HTML téléchargé, il faut l'analyser. Règle d'or : ne parsez pas le HTML avec des expressions régulières. Le HTML n'est pas un langage régulier : tout casse au premier guillemet non échappé. Les regex ne servent qu'à extraire des détails d'un texte déjà isolé.

Crate Approche Notes
scraper Sélecteurs CSS Le plus populaire. Surcouche de html5ever, issu de Servo.
dom_query Sélecteurs CSS + manipulation Alternative récente, capable de modifier le DOM.
select DSL de prédicats maison Plus ancien, mais fonctionnel.
html5ever tokenizer bas niveau Parseur de navigateur. Utilisé en interne par scraper.
lol_html rewriter en streaming Par Cloudflare. Pour les très gros documents, « à la volée ».
quick-xml XML / RSS / sitemap Parseur XML en streaming, rapide.
serde_json JSON Pour les réponses d'API et le JSON embarqué.

3.1 scraper + sélecteurs CSS

Documentation et exemples : docs.rs/scraper.

rust
use scraper::{Html, Selector};

fn parse_articles(html: &str) -> anyhow::Result<()> {
    let document = Html::parse_document(html);

    // Mieux vaut compiler les sélecteurs une seule fois (hors boucle).
    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();

        // le lien depuis l'attribut href
        let link = item
            .select(&title_sel)
            .next()
            .and_then(|e| e.value().attr("href"))
            .unwrap_or("");

        // la date depuis l'attribut datetime
        let date = item
            .select(&date_sel)
            .next()
            .and_then(|e| e.value().attr("datetime"))
            .unwrap_or("");

        println!("{title} | {date} | {link}");
    }
    Ok(())
}

Astuces utiles de scraper :

  • element.text().collect::<String>() — rassembler tout le texte interne (y compris imbriqué).
  • element.value().attr("href") — récupérer un attribut.
  • element.html() / element.inner_html() — obtenir le HTML source.
  • Les sélecteurs gèrent [attr="value"], :nth-child, >, (descendants), etc.

3.2 Le JSON des API

Souvent, il est plus simple de prendre les données non pas dans le HTML, mais dans l'API JSON cachée que la page appelle elle-même. Ouvrez les DevTools → onglet Network → repérez la requête qui renvoie du JSON. C'est plus fiable que n'importe quel parsing de balisage. Désérialisation via 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>>()   // désérialisation directement en structures
        .await?;
    Ok(products)
}

3.3 Sitemap et RSS avec quick-xml

Les plans de site (sitemap.xml) sont le meilleur moyen de connaître toutes les URL d'un site sans suivre les liens. Ils se parsent comme du XML ordinaire avec quick-xml (ou un crate spécialisé comme sitemap).


4. Encodages et Unicode

C'est la douleur classique du scraping des sites russes. Beaucoup de sites anciens servent leur contenu en Windows-1251 ou KOI8-R, et non en UTF-8.

Pourquoi ça casse

La méthode resp.text() détermine l'encodage d'après l'en-tête Content-Type: text/html; charset=.... Si le charset est absent de l'en-tête et n'est indiqué que dans le HTML (<meta charset="windows-1251">), reqwest suppose par défaut de l'UTF-8 — et vous récupérez des caractères illisibles (пÑÐ¸Ð²ÐµÑ ou ïðèâåò).

La solution : lire les octets et décoder soi-même

On récupère les octets « bruts » via .bytes() et on les décode dans le bon encodage avec encoding_rs (le même moteur que dans Firefox).

rust
use encoding_rs::{Encoding, WINDOWS_1251, UTF_8};

async fn get_text_cp1251(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
    let resp = client.get(url).send().await?;
    let bytes = resp.bytes().await?;

    // On décode en Windows-1251.
    let (text, _enc, had_errors) = WINDOWS_1251.decode(&bytes);
    if had_errors {
        eprintln!("Attention : erreurs lors du décodage");
    }
    Ok(text.into_owned())
}

Détection automatique de l'encodage

Mieux vaut détecter l'encodage que le coder en dur. L'algorithme :

  1. D'abord, regarder le charset dans l'en-tête Content-Type.
  2. À défaut, chercher <meta charset=...> / <meta http-equiv="Content-Type"> dans les premiers kilooctets du HTML.
  3. Sinon, tenter une détection statistique (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) on tente de récupérer le charset depuis l'en-tête
    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) s'il manque dans l'en-tête — on cherche dans <meta> (simplifié : les 1024 premiers octets)
    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) on choisit l'encodage (UTF-8 par défaut)
    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())
}

Alternative : la méthode resp.text_with_charset("windows-1251") de reqwest — elle utilise l'encodage indiqué comme encodage de repli si le charset n'est pas arrivé dans l'en-tête. C'est plus simple, mais cela ne couvre pas le cas « l'en-tête annonce UTF-8 alors que c'est en réalité du 1251 ».


5. Multithreading et asynchrone

Le scraping est presque toujours I/O-bound : le processeur attend pendant que les paquets réseau voyagent. En Rust, ce ne sont donc pas les « threads » qui gagnent ici, mais l'asynchrone sur tokio : des milliers de requêtes simultanées sur un ou deux threads de l'OS.

Distinguez deux tâches :

  • Le téléchargement (I/O-bound) → async/tokio, beaucoup de connexions simultanées.
  • Le parsing (CPU-bound, html5ever sollicite le CPU) → sur de gros volumes, déportez-le vers rayon ou tokio::task::spawn_blocking, pour ne pas bloquer le runtime async.

5.1 Concurrence bornée — buffer_unordered

La méthode la plus idiomatique : on transforme le flux d'URL en flux de futures, et buffer_unordered(N) en exécute au maximum N à la fois (du crate futures).

rust
use futures::stream::{self, StreamExt};

async fn crawl_many(client: &reqwest::Client, urls: Vec<String>) {
    let concurrency = 20; // pas plus de 20 requêtes simultanées

    let results = stream::iter(urls)
        .map(|url| {
            let client = client.clone(); // le clone est bon marché (Arc en interne)
            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!("Erreur {url} : {e}");
                        (url, 0, 0)
                    }
                }
            }
        })
        .buffer_unordered(concurrency)
        .collect::<Vec<_>>()
        .await;

    for (url, status, len) in results {
        println!("{status} {len:>8} {url}");
    }
}

5.2 Limiter avec un Semaphore

Quand les tâches sont lancées via tokio::spawn, on tient la limite avec un sémaphore :

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)); // 20 « en vol » maximum
    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(); // on attend un slot libre
            let _ = client.get(&url).send().await;
            // le permit est libéré à la sortie du scope
        }));
    }

    for h in handles {
        let _ = h.await;
    }
}

5.3 Parsing CPU-bound avec rayon

Si vous avez déjà téléchargé des milliers de pages HTML et qu'il faut les parser vite, c'est un travail pour tous les cœurs (rayon) :

rust
use rayon::prelude::*;

fn parse_all(pages: Vec<String>) -> Vec<usize> {
    pages
        .par_iter()                      // itérateur parallèle
        .map(|html| {
            let doc = scraper::Html::parse_document(html);
            doc.select(&scraper::Selector::parse("a").unwrap()).count()
        })
        .collect()
}

6. Les proxys

Les proxys servent (a) à ne pas buter sur un bannissement d'IP lors d'un scraping massif, (b) à contourner les restrictions géographiques. reqwest gère les proxys HTTP, HTTPS et SOCKS5.

Pour SOCKS, activez la feature :

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

6.1 Un proxy par 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"); // si une authentification est requise

    let client = Client::builder()
        .proxy(proxy)
        .build()?;
    Ok(client)
}

Proxy::http(...), Proxy::https(...) et Proxy::all(...) définissent le proxy pour les schémas correspondants. SOCKS5 :

rust
let proxy = reqwest::Proxy::all("socks5://127.0.0.1:1080")?;

6.2 Rotation d'un pool de proxys

Un Client est lié à un seul proxy. Pour faire tourner les proxys, le plus commode est de garder un client par proxy et de les sélectionner en round-robin :

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

    /// Renvoie le client suivant, en round-robin.
    fn next(&self) -> &Client {
        let i = self.idx.fetch_add(1, Ordering::Relaxed) % self.clients.len();
        &self.clients[i]
    }
}

Les proxys résidentiels/mobiles à rotation automatique côté fournisseur exposent en général une seule adresse « passerelle » — la rotation de votre côté devient alors inutile, un seul client suffit.


7. Scraper via TOR

TOR apporte l'anonymat et une « rotation » d'IP gratuite (nouveau circuit → nouveau nœud de sortie). Deux voies possibles.

7.1 La voie simple : TOR externe + SOCKS5

Vous lancez le TOR système (le démon tor ou Tor Browser), qui ouvre un proxy SOCKS5 sur 127.0.0.1:9050 (9150 pour Tor Browser). Ensuite, il se manipule comme un proxy SOCKS ordinaire :

rust
use reqwest::{Client, Proxy};

fn tor_client() -> anyhow::Result<Client> {
    // IMPORTANT : socks5h (avec la lettre h), et non socks5.
    // 'h' = résolution DNS côté proxy (au sein de TOR),
    // sinon fuite DNS, et les .onion ne fonctionneront pas.
    let proxy = Proxy::all("socks5h://127.0.0.1:9050")?;
    let client = Client::builder()
        .proxy(proxy)
        .build()?;
    Ok(client)
}

Vérifier que le trafic passe bien par 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(())
}

Changer de circuit (obtenir une nouvelle IP) se fait via le control-port de TOR (en général 9051) : il faut envoyer le signal NEWNYM. On le fait à la main via le protocole du control-port, ou avec un crate wrapper. Après un NEWNYM, on marque en général une pause (TOR limite la fréquence de changement à ~une fois toutes les 10 s).

7.2 TOR embarqué : arti

Arti est l'implémentation de TOR en Rust pur, par le Tor Project lui-même. On peut embarquer TOR directement dans l'application, sans démon externe. L'API client de haut niveau se trouve dans le crate arti-client (docs.rs).

toml
arti-client = "..."   # vérifiez la version actuelle : cargo add arti-client
tor-rtcompat = "..."
rust
use arti_client::{TorClient, TorClientConfig};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = TorClientConfig::default();
    // On démarre le client TOR embarqué et on attend la fin du bootstrap.
    let tor = TorClient::create_bootstrapped(config).await?;

    // On peut ensuite ouvrir des flux TCP anonymes (AsyncRead/AsyncWrite)
    // et envoyer du HTTP par-dessus, à la main ou via hyper.
    let mut stream = tor.connect(("example.com", 80)).await?;
    // ... envoi de la requête HTTP sur le stream ...
    Ok(())
}

Il existe aussi un crate « colle », artiqwest — il route les requêtes HTTP à travers arti avec une API à la reqwest (get/post), y compris .onion et les websockets.

Les inconvénients d'arti : l'API n'est pas encore stabilisée (des breaking changes sont possibles avant la 1.x), et toutes les fonctionnalités du C-Tor ne sont pas couvertes. En contrepartie, pas de processus externe et un déploiement plus simple.

Avertissement : les nœuds de sortie TOR sont surchargés, lents et souvent bannis des sites populaires. TOR est bon pour l'anonymat et l'accès aux .onion, mais mauvais comme « pool gratuit de proxys rapides ».


8. HTTPS / SSL

Avec reqwest 0.13, HTTPS fonctionne « clés en main » — le backend par défaut est rustls (Rust pur, aucun OpenSSL système requis). En général, il n'y a rien à configurer.

8.1 Choisir le backend TLS

toml
# rustls (par défaut) — multiplateforme, pas besoin d'OpenSSL
reqwest = { version = "0.13" }

# ou le TLS système (schannel sous Windows, Secure Transport sous macOS, OpenSSL sous Linux)
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }

# ou OpenSSL compilé en statique (pratique pour distribuer le binaire)
reqwest = { version = "0.13", default-features = false, features = ["native-tls-vendored"] }

Backends TLS : rustls, native-tls, openssl.

8.2 Ignorer les erreurs de certificat (dangereux !)

Il faut parfois scraper un site au certificat auto-signé ou expiré. On peut désactiver la vérification — mais uniquement pour des tests et des hôtes de confiance : cela supprime la protection contre le MITM :

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

8.3 Certificat racine personnalisé / certificat client

rust
use reqwest::{Certificate, Identity};

// Ajouter un CA d'entreprise/auto-signé :
let ca = Certificate::from_pem(&std::fs::read("my-ca.pem")?)?;

// Certificat client (mTLS) :
let id = Identity::from_pem(&std::fs::read("client.pem")?)?;

let client = reqwest::Client::builder()
    .add_root_certificate(ca)
    .identity(id)
    .build()?;

Les cookies servent aux sessions, à l'authentification et au contournement de certaines protections. reqwest sait les stocker et les renvoyer automatiquement d'une requête à l'autre.

On active la feature :

toml
reqwest = { version = "0.13", features = ["cookies"] }
rust
let client = reqwest::Client::builder()
    .cookie_store(true) // activer le stockage automatique des cookies
    .build()?;

// 1) on se connecte — le serveur renvoie des Set-Cookie, le client les mémorise
client.post("https://site.example/login")
    .form(&[("user", "alice"), ("pass", "secret")])
    .send().await?;

// 2) les requêtes suivantes partiront automatiquement avec ces cookies
let dashboard = client.get("https://site.example/dashboard")
    .send().await?
    .text().await?;

Quand il faut lire ou poser des cookies à la main, ou les transférer entre sessions :

rust
use std::sync::Arc;
use reqwest::cookie::{Jar, CookieStore};
use reqwest::Url;

let jar = Arc::new(Jar::default());

// Déposer un cookie à la main, au préalable :
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()) // on utilise notre jar
    .build()?;

// après les requêtes, on peut lire les cookies accumulés dans le jar

Si vous ne voulez pas activer l'automatisme, un cookie peut simplement être passé en en-tête :

rust
let resp = client.get(url)
    .header(reqwest::header::COOKIE, "session=abc123; lang=ru")
    .send().await?;

10. Statut de la réponse et en-têtes

Avant d'analyser le HTML, il faut presque toujours vérifier que la page est bien arrivée (200), et non un 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(""));

    // Vérifications pratiques par catégorie de statut :
    if status.is_success() {            // 2xx
        // on lit les en-têtes utiles
        let headers = resp.headers();

        if let Some(ct) = headers.get(CONTENT_TYPE).and_then(|v| v.to_str().ok()) {
            println!("Content-Type: {ct}");
            // on ne parse que le HTML, on ignore les images
            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!("Redirection vers : {loc}");
        }
    }

    if status == StatusCode::TOO_MANY_REQUESTS {  // 429
        // le serveur demande d'attendre
        if let Some(ra) = resp.headers().get(RETRY_AFTER).and_then(|v| v.to_str().ok()) {
            println!("On nous ralentit. Retry-After : {ra} s");
        }
    }

    Ok(None)
}

Méthodes utiles :

  • resp.status()StatusCode ; avec .is_success(), .is_client_error(), .is_server_error(), .is_redirection().
  • resp.error_for_status() — transforme les 4xx/5xx en Err, pratique avec ?.
  • resp.headers()HeaderMap, itérable comme une map.
  • resp.url() — l'URL finale après les redirections.
  • resp.content_length() — la taille du corps, si elle est connue.

Par défaut, reqwest suit lui-même les redirections (jusqu'à 10). Le comportement se règle via .redirect(reqwest::redirect::Policy::none()) ou .limited(n).


11. Stockage des URL et files d'attente

Tout robot d'exploration (crawler) est, au fond, une boucle : « prendre une URL dans la file → télécharger → extraire les nouveaux liens → les remettre dans la file ». Il faut ici deux structures :

  • la file (frontier) — ce qu'il faut télécharger ensuite ;
  • l'ensemble des pages visitées (visited/seen) — pour ne pas télécharger deux fois la même chose.

11.1 En mémoire (pour les petites tâches)

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() }
    }
    /// Ajoute l'URL si on ne l'a pas encore vue.
    fn push(&mut self, url: String) {
        if self.seen.insert(url.clone()) {  // insert renvoie false si déjà présent
            self.queue.push_back(url);
        }
    }
    fn pop(&mut self) -> Option<String> {
        self.queue.pop_front()
    }
}

Pour un accès concurrent depuis plusieurs tâches async, on construit la file sur des canaux : tokio::sync::mpsc, flume ou crossbeam-channel. Les workers lisent le canal et y renvoient les nouveaux liens.

11.2 Persistance (pour les explorations longues et volumineuses)

Avec des millions d'URL, la mémoire s'épuise, et un crash du processus fait perdre toute la progression. On déporte donc la file et les « visitées » vers un stockage externe :

Stockage Crate Quand
Redis redis File distribuée entre plusieurs workers.
SQLite rusqlite / sqlx Un seul processus, besoin d'une persistance simple.
PostgreSQL sqlx Gros volumes, analytique, plusieurs machines.
RocksDB / sled rocksdb / sled Key-value local très rapide.

Déduplication à grande échelle : garder toutes les URL dans un HashSet coûte cher. On utilise : - la normalisation des URL (retirer le #fragment, trier les paramètres de query, normaliser la casse de l'hôte) avec le crate url — sinon une même page apparaîtra sous plusieurs URL ; - un hachage de l'URL (par exemple xxhash-rust / blake3) au lieu de la chaîne elle-même ; - un filtre de Bloom (bloomfilter) — une structure probabiliste compacte « peut-être déjà vue / sûrement jamais vue ».

Cette section reste un survol. En pratique, le choix dépend de l'échelle : pour quelques milliers de pages, un HashSet en mémoire suffit ; pour un crawler industriel — file Redis + filtre de Bloom + normalisation des URL.


12. En complément

Ce qui n'a pas trouvé place dans la liste initiale, mais sans quoi un scraper réel casse ou se fait bannir.

12.1 Politesse, robots.txt et rate limiting

  • robots.txt — le fichier où le site indique ce qui peut ou non être exploré. Un scraping éthique (et parfois juridiquement plus sûr) le respecte. Crates : texting_robots, robotstxt.
  • Des délais entre les requêtes vers un même domaine — pour ne pas faire tomber le site ni récolter un bannissement. Au plus simple : tokio::time::sleep. En version professionnelle : le limiteur governor (token bucket) :
rust
use std::num::NonZeroU32;
use governor::{Quota, RateLimiter};

// pas plus de 5 requêtes par seconde
let limiter = RateLimiter::direct(Quota::per_second(NonZeroU32::new(5).unwrap()));

// avant chaque requête :
limiter.until_ready().await;
// client.get(...).send().await?;

12.2 Nouvelles tentatives et backoff

Le réseau est instable : timeouts, 503, coupures. Il faut des retries avec délai exponentiel (1 s → 2 s → 4 s…). Le plus simple : les crates 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();
// ensuite client.get(...).send().await — les retries se feront automatiquement

12.3 Pages JavaScript (navigateurs headless)

reqwest + scraper ne voient que le HTML source. Si le contenu est dessiné par JavaScript (SPA en React/Vue), il sera absent du HTML. Les options :

  1. Trouver l'API cachée (voir §3.2) — presque toujours la meilleure voie : plus rapide, plus fiable, plus léger.
  2. Piloter un vrai navigateur (qui rend le JS) :
Crate Protocole Notes
chromiumoxide CDP (Chrome DevTools) async, pilotage direct de Chrome.
thirtyfour WebDriver Compatible Selenium, API haut niveau confortable.
fantoccini WebDriver Plus léger que thirtyfour.
headless_chrome CDP Wrapper synchrone au-dessus de CDP.

Un navigateur coûte des dizaines de fois plus cher en ressources : ne l'utilisez que là où le JS est incontournable.

12.4 User-Agent, en-têtes et protection anti-bot

Les sites distinguent les bots des humains. Le camouflage minimal :

  • un User-Agent crédible (pas reqwest/0.13 !) ;
  • un jeu d'en-têtes réaliste : Accept, Accept-Language, Accept-Encoding, Referer, Sec-Fetch-* ;
  • la rotation des User-Agents et des proxys ;
  • des délais « humains ».

Les protections sérieuses (Cloudflare, DataDome, PerimeterX) vérifient en plus le fingerprint TLS (JA3/JA4) et l'ordre des en-têtes HTTP/2. Un reqwest ordinaire présente une empreinte « façon Rust », différente de celle de Chrome. Pour la contourner, il existe des crates qui imitent l'empreinte d'un navigateur, bâtis sur curl-impersonate, par exemple rquest. C'est une « course à l'armement » — sans aucune garantie.

12.5 Gestion des erreurs et logging

  • Erreurs : anyhow pour les applications (un ? commode et du contexte), thiserror pour les bibliothèques (types d'erreurs dédiés). Ne paniquez pas sur chaque 404 — traitez-la comme un résultat normal.
  • Logging/tracing : tracing + tracing-subscriber (ou log + env_logger). Les logs montrent où ça coince et où ça bannit.
rust
use anyhow::Context;

let html = client.get(url).send().await
    .with_context(|| format!("échec du téléchargement de {url}"))?
    .text().await
    .context("échec de la lecture du corps")?;

13. Architecture d'un crawler

Le schéma d'un scraper industriel, qui assemble tout ce qui précède :

Architecture d'un crawler industriel en Rust : file frontier avec ensemble visited, pool de workers tokio avec rate limit et rotation de proxys, client HTTP reqwest, décodage et parsing (encoding_rs, scraper, serde_json), stockage des résultats et retour des nouveaux liens dans le frontier

Les principes clés : - un Client commun (pool de connexions), cloné dans les workers ; - la concurrence bornée par un sémaphore, le débit par domaine par un limiteur ; - chaque appel réseau enveloppé dans du retry/backoff ; - la file et l'ensemble seen sont la seule source de vérité sur la progression.

Si vous ne voulez pas tout assembler à la main, il existe des frameworks d'exploration prêts à l'emploi, par exemple spider.


14. Avantages et inconvénients

Les avantages d'une implémentation en Rust

  • Performance : une vitesse proche du C/C++. Sur de gros volumes, Rust surclasse nettement Python (requests/BeautifulSoup) et Go en CPU et en mémoire.
  • Mémoire : consommation minimale, pas de pauses GC — décisif sur des millions de pages et des explorations longues.
  • Un multithreading sans peur : le système de types et le borrow-checker attrapent les data races à la compilation. Un atout énorme pour un scraper concurrent.
  • Fiabilité : gestion explicite des erreurs (Result), Option au lieu de null — moins de plantages en production.
  • Un seul binaire statique : déploiement facile, pas d'interpréteur ni de dépendances à embarquer.
  • Un écosystème async mûr : tokio + reqwest, du niveau production.

Les inconvénients

  • Le ticket d'entrée : borrow-checker, lifetimes, async — l'apprentissage est plus long qu'un scraper Python écrit « en une soirée ».
  • La vitesse de développement : un prototype en Python/scrapy s'écrit plus vite. Pour une tâche ponctuelle, Rust peut être excessif.
  • Le contenu dynamique : moins de solutions « clés en main » pour le rendu JS et le contournement des protections anti-bots qu'en Python (qui a Playwright, Scrapy, undetected-chromedriver, etc.).
  • La compilation : des temps de build longs, surtout avec des dépendances lourdes.
  • Moins de frameworks prêts à l'emploi : Python a un scrapy complet ; en Rust, on assemble en général le pipeline soi-même à partir de briques (même s'il existe spider et d'autres).

Conclusion : Rust se justifie quand le scraping est massif, permanent et sensible aux ressources (millions de pages, exigences strictes de vitesse/mémoire, service qui tourne longtemps). Pour un « scraper 500 pages » ponctuel, Python reste en général plus rapide en effort total.


15. Aspects juridiques et éthiques

Techniquement, beaucoup de choses sont possibles — cela ne veut pas dire qu'il faut les faire. En bref :

  • robots.txt et CGU : respectez le robots.txt et les conditions d'utilisation du site.
  • Charge : ne mettez pas à terre le serveur d'autrui — limitez la fréquence des requêtes, scrapez aux heures creuses.
  • Données personnelles : leur collecte est encadrée par la loi (RGPD dans l'UE et lois équivalentes ailleurs). Soyez prudent.
  • Droits d'auteur : le contenu peut être protégé ; la copie ou republication massive peut être illégale.
  • Identifiez-vous : il est judicieux d'indiquer un contact dans le User-Agent, pour que l'administrateur du site puisse vous joindre au lieu de bannir à l'aveugle.

Ceci est un repère général, pas un conseil juridique — dans les cas litigieux, consultez un avocat.


Aide-mémoire des crates

Tâche Crate(s)
Client HTTP reqwest (async/blocking), ureq (sync)
Parsing HTML scraper, dom_query, select
JSON / XML serde_json, quick-xml
Encodages (cyrillique) encoding_rs, chardetng
Runtime async tokio, futures
Parallélisme CPU rayon
Rate limiting governor
Retries reqwest-middleware, reqwest-retry
TOR TOR externe + socks, ou arti-client / artiqwest
Navigateur headless chromiumoxide, thirtyfour, fantoccini
robots.txt texting_robots
File/stockage redis, rusqlite / sqlx, sled, bloomfilter
Erreurs/logs anyhow, thiserror, tracing
Framework prêt à l'emploi spider