Scraping por linguagem 26 min de leitura

Web scraping em Rust: guia completo do simples ao complexo

Web scraping em Rust: reqwest, scraper e tokio — como a tipagem estrita e a velocidade de uma linguagem compilada se destacam em grandes volumes.

EW
Equipe Web-Scraping.biz
Coleta de dados para as demandas do negócio
Publicado: 5 abril 2025

Atualizado para 2026. Versões dos crates: reqwest 0.13, scraper 0.27, tokio 1.x, encoding_rs 0.8. Mudança importante: a partir do reqwest 0.13, o backend TLS padrão é o rustls (Rust puro), e não o OpenSSL/native-tls do sistema.


Índice

  1. Introdução: por que Rust para o scraping
  2. Como baixamos a página (cliente HTTP)
  3. Bibliotecas para o parsing do conteúdo
  4. Como resolver os problemas de codificação
  5. Multithreading e assincronia
  6. Uso de proxies
  7. Scraping através do TOR
  8. Trabalhar com HTTPS / SSL
  9. Trabalhar com cookies
  10. Status da resposta e cabeçalhos
  11. Armazenamento de URLs e filas (um panorama)
  12. Extra: o que costuma ser esquecido
    • Cortesia, robots.txt, rate limiting
    • Novas tentativas e backoff
    • Páginas JavaScript (navegadores headless)
    • User-Agent e proteções anti-bots
    • Tratamento de erros e logging
  13. Arquitetura de um crawler completo
  14. Principais vantagens e desvantagens da implementação em Rust
  15. Aspectos legais e éticos

1. Introdução

O web scraping consiste em obter automaticamente o HTML/JSON/XML das páginas e extrair deles dados estruturados. Todo scraper se compõe de duas grandes partes:

  • a camada de rede — baixa a página (cliente HTTP);
  • a camada de parsing — converte o HTML «bruto» nos campos de que você precisa (parser + seletores).

Depois entram proxies, concorrência, contorno de proteções anti-bots, armazenamento da fila de links etc. Rust se destaca porque oferece velocidade comparável à de C e consumo mínimo de memória com paralelismo seguro — exatamente o que é crítico quando você baixa milhões de páginas.

O Cargo.toml inicial, ao qual vamos acrescentando funcionalidades aos poucos:

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"          # tratamento de erros prático

Páginas oficiais dos crates básicos: reqwest, tokio, scraper, encoding_rs, anyhow.


2. Como baixamos a página

No ecossistema de Rust há vários clientes HTTP. Para o scraping, em 99% dos casos a escolha é o reqwest.

Crate Quando usar
reqwest A opção principal. Async + blocking, proxies, cookies, TLS: vem com tudo.
ureq Cliente síncrono leve, sem tokio. Para scripts simples.
isahc Cliente async baseado na libcurl.
hyper De baixo nível. Necessário quando você constrói seu próprio cliente/servidor.

Documentação: docs.rs/reqwest.

2.1 A requisição mais simples (async)

rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let body = reqwest::get("https://example.com")
        .await?      // esperamos a resposta
        .text()      // lemos o corpo como string
        .await?;
    println!("{body}");
    Ok(())
}

2.2 A forma correta — um Client reutilizável

reqwest::get cria um cliente novo a cada chamada. Isso sai caro: perde-se o pool de conexões (keep-alive). Crie um único Client e clone-o — por dentro ele é um Arc, e o clone é barato.

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

fn build_client() -> anyhow::Result<Client> {
    let client = Client::builder()
        // nos passamos por um navegador comum
        .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 da requisição
        .connect_timeout(Duration::from_secs(10))  // timeout de estabelecimento da conexão
        .gzip(true)                                // descompressão automática de gzip
        .brotli(true)                              // descompressão automática de 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", "pt-BR,pt;q=0.9")
        .send()
        .await?;

    println!("Status: {}", resp.status());
    let html = resp.text().await?;
    println!("Tamanho do HTML: {}", html.len());
    Ok(())
}

2.3 A variante síncrona (sem tokio)

Se você não quer carregar um runtime async para um script pequeno, existe o 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ão chame o cliente blocking dentro de um runtime async: isso provoca um panic. Escolha um dos dois caminhos.


3. Bibliotecas para o parsing

Uma vez baixado o HTML, é preciso analisá-lo. A regra de ouro: não parseie HTML com expressões regulares. O HTML não é uma linguagem regular; essa abordagem quebra na primeira aspa sem escape. As regex só fazem sentido para extrair detalhes menores de um texto já localizado.

Crate Abordagem Observações
scraper Seletores CSS O mais popular. Wrapper sobre o html5ever, do Servo.
dom_query Seletores CSS + manipulação Alternativa recente; sabe modificar o DOM.
select DSL própria de predicados Mais antigo, porém funcional.
html5ever tokenizador de baixo nível Parser com qualidade de navegador. Usado dentro do scraper.
lol_html rewriter em streaming Da Cloudflare. Para documentos muito grandes, «em pleno voo».
quick-xml XML / RSS / sitemap Parser XML rápido em streaming.
serde_json JSON Para respostas de API e JSON embutido.

3.1 scraper + seletores CSS

Documentação e exemplos: docs.rs/scraper.

rust
use scraper::{Html, Selector};

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

    // Convém compilar os seletores uma única vez (fora do loop).
    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();

        // o link, do atributo href
        let link = item
            .select(&title_sel)
            .next()
            .and_then(|e| e.value().attr("href"))
            .unwrap_or("");

        // a data, do atributo datetime
        let date = item
            .select(&date_sel)
            .next()
            .and_then(|e| e.value().attr("datetime"))
            .unwrap_or("");

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

Truques úteis do scraper:

  • element.text().collect::<String>() — reúne todo o texto interno (incluindo o aninhado).
  • element.value().attr("href") — pega um atributo.
  • element.html() / element.inner_html() — devolve o HTML original.
  • Os seletores aceitam [attr="value"], :nth-child, >, (descendentes) etc.

3.2 JSON de uma API

Muitas vezes é mais simples pegar os dados não do HTML, mas da API JSON oculta que a própria página consulta. Abra o DevTools → aba Network → localize a requisição que devolve JSON. É mais confiável do que qualquer parsing da marcação. A desserialização é feita com 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>>()   // desserialização direta para structs
        .await?;
    Ok(products)
}

3.3 Sitemap e RSS com quick-xml

Os mapas do site (sitemap.xml) são a melhor maneira de conhecer todas as URLs de um site sem percorrer seus links. Eles são parseados como XML comum com o quick-xml (ou com um crate especializado como o sitemap).


4. Codificações e caracteres especiais

Uma dor clássica ao scrapear a parte mais antiga da web lusófona: muitos sites veteranos servem o conteúdo em Windows-1252 ou ISO-8859-1 (Latin-1), e não em UTF-8.

Por que quebra

O método resp.text() determina a codificação a partir do cabeçalho Content-Type: text/html; charset=.... Se o cabeçalho não indica o charset e ele só aparece no HTML (<meta charset="windows-1252">), o reqwest assume por padrão que é UTF-8 — e você recebe «mojibake»: informa��o em vez de informação (ou, no caso inverso, café em vez de café).

A solução: ler os bytes e decodificar por conta própria

Pegue os bytes «brutos» com .bytes() e decodifique-os com a codificação correta usando o encoding_rs (o mesmo motor que o Firefox usa).

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?;

    // Decodificamos como Windows-1252.
    let (text, _enc, had_errors) = WINDOWS_1252.decode(&bytes);
    if had_errors {
        eprintln!("Atenção: houve erros durante a decodificação");
    }
    Ok(text.into_owned())
}

Detecção automática da codificação

Melhor do que cravar a codificação no código é detectá-la. O algoritmo:

  1. Primeiro, olhe o charset do cabeçalho Content-Type.
  2. Se não estiver lá, procure <meta charset=...> / <meta http-equiv="Content-Type"> nos primeiros kilobytes do HTML.
  3. Se também não aparecer aí, tente adivinhá-la estatisticamente (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) tentamos pegar o charset do cabeçalho
    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 não está no cabeçalho, procuramos no <meta> (simplificado: primeiros 1024 bytes)
    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) escolhemos a codificação (UTF-8 por padrão)
    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: o método resp.text_with_charset("windows-1252") do reqwest usa a codificação indicada como reserva quando o charset não vem no cabeçalho. É mais simples, mas não cobre o caso «o cabeçalho diz UTF-8 e na verdade é 1252».


5. Multithreading e assincronia

O scraping é quase sempre I/O-bound: o processador fica ocioso enquanto os pacotes viajam pela rede. Por isso, em Rust, quem ganha aqui não são as «threads», e sim a assincronia sobre o tokio: milhares de requisições simultâneas em uma ou duas threads do sistema operacional.

Distinga duas tarefas:

  • Baixar (I/O-bound) → async/tokio, muitas conexões simultâneas.
  • Parsear (CPU-bound: o html5ever castiga a CPU) → com grandes volumes, mova o trabalho para o rayon ou para tokio::task::spawn_blocking, para não bloquear o runtime async.

5.1 Concorrência com limite — buffer_unordered

A forma mais idiomática: convertemos o fluxo de URLs em um fluxo de futures, e buffer_unordered(N) executa no máximo N por vez (do crate futures).

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

async fn crawl_many(client: &reqwest::Client, urls: Vec<String>) {
    let concurrency = 20; // no máximo 20 requisições por vez

    let results = stream::iter(urls)
        .map(|url| {
            let client = client.clone(); // o clone é barato (Arc por dentro)
            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!("Erro {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 com Semaphore

Quando as tarefas são disparadas com tokio::spawn, o limite é mantido com um semáforo:

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)); // no máximo 20 «em voo»
    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(); // esperamos uma vaga livre
            let _ = client.get(&url).send().await;
            // a permissão é liberada ao sair do escopo
        }));
    }

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

5.3 Parsing CPU-bound com rayon

Se você já tem milhares de HTMLs baixados e precisa parseá-los rápido, isso é trabalho para todos os núcleos (rayon):

rust
use rayon::prelude::*;

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

6. Proxies

Os proxies servem para (a) não esbarrar em um bloqueio por IP durante o scraping em massa e (b) contornar restrições geográficas. O reqwest aceita proxies HTTP, HTTPS e SOCKS5.

Para SOCKS, ative a feature:

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

6.1 Um proxy por cliente

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 for necessária autenticação

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

Proxy::http(...), Proxy::https(...) e Proxy::all(...) fixam o proxy para os esquemas correspondentes. SOCKS5:

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

6.2 Rotação de um pool de proxies

Cada Client fica vinculado a um único proxy. Para rotacionar, o mais prático é manter um cliente por proxy e escolhê-los em revezamento (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) })
    }

    /// Devolve o próximo cliente do ciclo (round-robin).
    fn next(&self) -> &Client {
        let i = self.idx.fetch_add(1, Ordering::Relaxed) % self.clients.len();
        &self.clients[i]
    }
}

Os proxies residenciais/móveis com rotação automática do lado do provedor costumam entregar um único endereço «gateway»; nesse caso, você não precisa rotacionar nada: basta um único cliente.


7. Scraping através do TOR

O TOR oferece anonimato e uma «rotação» de IP gratuita (novo circuito → novo nó de saída). Há dois caminhos.

7.1 O caminho simples: TOR externo + SOCKS5

Inicie o TOR do sistema (o daemon tor ou o Tor Browser), que sobe um proxy SOCKS5 em 127.0.0.1:9050 (no Tor Browser, o 9150). A partir daí, funciona como qualquer proxy SOCKS:

rust
use reqwest::{Client, Proxy};

fn tor_client() -> anyhow::Result<Client> {
    // IMPORTANTE: socks5h (com a letra h), não socks5.
    // 'h' = resolução DNS do lado do proxy (dentro do TOR);
    // caso contrário, haverá vazamentos de DNS e os .onion não funcionarão.
    let proxy = Proxy::all("socks5h://127.0.0.1:9050")?;
    let client = Client::builder()
        .proxy(proxy)
        .build()?;
    Ok(client)
}

Verificação de que o tráfego passa pelo 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(())
}

A troca de circuito (IP nova) é feita através da porta de controle do TOR (normalmente a 9051): é preciso enviar o sinal NEWNYM. Dá para fazer manualmente com o protocolo da porta de controle ou com um crate que o encapsule. Depois de um NEWNYM, convém respeitar uma pausa (o TOR limita a frequência de troca a cerca de uma vez a cada 10 segundos).

7.2 TOR embutido: arti

O Arti é a implementação do TOR em Rust puro do próprio Tor Project. Ele permite embutir o TOR diretamente na aplicação, sem daemon externo. A API cliente de alto nível está no crate arti-client (docs.rs).

toml
arti-client = "..."   # confira a versão atual: cargo add arti-client
tor-rtcompat = "..."
rust
use arti_client::{TorClient, TorClientConfig};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = TorClientConfig::default();
    // Subimos o cliente TOR embutido e aguardamos o bootstrap.
    let tor = TorClient::create_bootstrapped(config).await?;

    // Depois é possível abrir streams TCP anônimos (AsyncRead/AsyncWrite)
    // e enviar HTTP sobre eles manualmente ou via hyper.
    let mut stream = tor.connect(("example.com", 80)).await?;
    // ... envio da requisição HTTP pelo stream ...
    Ok(())
}

Existe também um crate «cola», o artiqwest, que roteia as requisições HTTP através do arti com uma API no estilo do reqwest (get/post), incluindo os .onion e os websockets.

Desvantagens do arti: a API ainda não está estabilizada (até a 1.x pode haver breaking changes) e não cobre todas as funções do C-Tor. Em compensação, você não precisa de um processo externo e o deploy é mais simples.

Aviso: os nós de saída do TOR estão saturados, são lentos e com frequência aparecem bloqueados nos sites populares. O TOR é bom para o anonimato e para acessar os .onion, mas ruim como «pool gratuito de proxies rápidos».


8. HTTPS / SSL

A partir do reqwest 0.13, o HTTPS funciona «de fábrica»: o backend padrão é o rustls (Rust puro, sem depender do OpenSSL do sistema). Normalmente não há nada para configurar.

8.1 Escolha do backend TLS

toml
# rustls (padrão) — multiplataforma, não precisa de OpenSSL
reqwest = { version = "0.13" }

# ou o TLS do sistema (schannel no Windows, Secure Transport no macOS, OpenSSL no Linux)
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }

# ou OpenSSL compilado estaticamente (prático para distribuir o binário)
reqwest = { version = "0.13", default-features = false, features = ["native-tls-vendored"] }

Backends TLS: rustls, native-tls, openssl.

8.2 Ignorar os erros de certificado (perigoso!)

Às vezes é preciso scrapear um site com certificado autoassinado ou vencido. Dá para desativar a verificação — mas somente para testes e hosts confiáveis, porque isso elimina a proteção contra ataques MITM:

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

8.3 Certificado raiz próprio / certificado de cliente

rust
use reqwest::{Certificate, Identity};

// Adicionar uma CA corporativa ou autoassinada:
let ca = Certificate::from_pem(&std::fs::read("my-ca.pem")?)?;

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

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

Os cookies são necessários para sessões, autenticação e para contornar algumas proteções. O reqwest sabe armazená-los e anexá-los automaticamente entre requisições.

Ative a feature:

toml
reqwest = { version = "0.13", features = ["cookies"] }
rust
let client = reqwest::Client::builder()
    .cookie_store(true) // ativar o armazenamento automático de cookies
    .build()?;

// 1) fazemos login — o servidor devolverá Set-Cookie e o cliente vai memorizá-los
client.post("https://site.example/login")
    .form(&[("user", "alice"), ("pass", "secret")])
    .send().await?;

// 2) as requisições seguintes sairão automaticamente com esses cookies
let dashboard = client.get("https://site.example/dashboard")
    .send().await?
    .text().await?;

Quando você precisa ler ou definir cookies manualmente, ou transferi-los de uma sessão para outra:

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

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

// Colocar um cookie manualmente, de antemão:
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()) // usamos o nosso jar
    .build()?;

// depois das requisições, é possível ler do jar os cookies acumulados

Se você não quer ativar a gestão automática, pode passar os cookies diretamente como cabeçalho:

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

10. Status da resposta e cabeçalhos

Antes de parsear o HTML, quase sempre convém verificar se a página chegou bem (200), e não como 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(""));

    // Verificações práticas por categoria de status:
    if status.is_success() {            // 2xx
        // lemos os cabeçalhos que interessam
        let headers = resp.headers();

        if let Some(ct) = headers.get(CONTENT_TYPE).and_then(|v| v.to_str().ok()) {
            println!("Content-Type: {ct}");
            // parseamos apenas HTML; as imagens são ignoradas
            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!("Redirecionamento para: {loc}");
        }
    }

    if status == StatusCode::TOO_MANY_REQUESTS {  // 429
        // o servidor pede para esperar
        if let Some(ra) = resp.headers().get(RETRY_AFTER).and_then(|v| v.to_str().ok()) {
            println!("Estamos sendo freados. Retry-After: {ra} s");
        }
    }

    Ok(None)
}

Métodos úteis:

  • resp.status()StatusCode; com .is_success(), .is_client_error(), .is_server_error(), .is_redirection().
  • resp.error_for_status() — converte os 4xx/5xx em Err; prático com ?.
  • resp.headers()HeaderMap, iterável como um map.
  • resp.url() — a URL final após os redirecionamentos.
  • resp.content_length() — o tamanho do corpo, se conhecido.

Por padrão, o reqwest segue os redirecionamentos por conta própria (até 10). O comportamento é ajustado com .redirect(reqwest::redirect::Policy::none()) ou .limited(n).


11. Armazenamento de URLs e filas

Todo rastreador (crawler) é, em essência, um loop: «tirar uma URL da fila → baixar → extrair os links novos → devolvê-los à fila». Aqui são necessárias duas estruturas:

  • a fila (frontier) — o que baixar em seguida;
  • o conjunto de visitadas (visited/seen) — para não baixar a mesma coisa duas vezes.

11.1 Em memória (para tarefas pequenas)

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() }
    }
    /// Adiciona a URL se ela ainda não foi vista.
    fn push(&mut self, url: String) {
        if self.seen.insert(url.clone()) {  // insert devolve false se já existia
            self.queue.push_back(url);
        }
    }
    fn pop(&mut self) -> Option<String> {
        self.queue.pop_front()
    }
}

Para o acesso concorrente a partir de várias tarefas async, a fila é montada sobre canais: tokio::sync::mpsc, flume ou crossbeam-channel. Os workers leem do canal e escrevem nele os links novos.

11.2 Persistência (para rastreamentos grandes e longos)

Com milhões de URLs a memória se esgota, e se o processo cair você perde o progresso. Por isso a fila e as «visitadas» são levadas para um armazenamento externo:

Armazenamento Crate Quando
Redis redis Fila distribuída entre vários workers.
SQLite rusqlite / sqlx Um único processo com persistência simples.
PostgreSQL sqlx Grandes volumes, análises, várias máquinas.
RocksDB / sled rocksdb / sled Armazenamento chave-valor local muito rápido.

Deduplicação em grande escala: guardar todas as URLs em um HashSet sai caro. Emprega-se: - a normalização de URLs (remover o #fragment, ordenar os parâmetros da query, passar o host para minúsculas) com o crate url — caso contrário, uma mesma página entrará com URLs diferentes; - um hash da URL (por exemplo, xxhash-rust / blake3) em vez da string inteira; - o filtro de Bloom (bloomfilter) — uma estrutura probabilística compacta de «talvez vista / com certeza não vista».

Esta seção é apenas um panorama. Na prática, a escolha depende da escala: para alguns milhares de páginas, basta um HashSet em memória; para um crawler industrial, fila no Redis + filtro de Bloom + normalização de URLs.


12. Extra

O que não entrou na lista inicial, mas sem o qual um scraper real quebra ou acaba bloqueado.

12.1 Cortesia, robots.txt e rate limiting

  • robots.txt — o arquivo em que o site indica o que pode ser percorrido e o que não pode. O scraping ético (e, às vezes, o juridicamente prudente) o respeita. Crates: texting_robots, robotstxt.
  • Pausas entre requisições ao mesmo domínio, para não derrubar o site nem ganhar um bloqueio. A variante mais simples é tokio::time::sleep; a profissional, o limitador governor (token bucket):
rust
use std::num::NonZeroU32;
use governor::{Quota, RateLimiter};

// no máximo 5 requisições por segundo
let limiter = RateLimiter::direct(Quota::per_second(NonZeroU32::new(5).unwrap()));

// antes de cada requisição:
limiter.until_ready().await;
// client.get(...).send().await?;

12.2 Novas tentativas e backoff

A rede é instável: timeouts, 503, quedas de conexão. São necessárias novas tentativas com espera exponencial (1 s → 2 s → 4 s...). O mais simples são os 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();
// daqui em diante, client.get(...).send().await — as novas tentativas rodam sozinhas

12.3 Páginas JavaScript (navegadores headless)

reqwest + scraper só enxergam o HTML original. Se o conteúdo é desenhado pelo JavaScript (uma SPA em React/Vue), ele não estará nesse HTML. Opções:

  1. Encontrar a API oculta (veja o §3.2) — quase sempre o melhor caminho: mais rápido, mais confiável, mais leve.
  2. Controlar um navegador real (que de fato renderiza o JS):
Crate Protocolo Observações
chromiumoxide CDP (Chrome DevTools) Async; controla o Chrome diretamente.
thirtyfour WebDriver Compatível com Selenium; API de alto nível prática.
fantoccini WebDriver Mais leve que o thirtyfour.
headless_chrome CDP Wrapper síncrono sobre o CDP.

Um navegador consome dezenas de vezes mais recursos, então use-o apenas onde o JS for imprescindível.

12.4 User-Agent, cabeçalhos e proteções anti-bots

Os sites distinguem bots de pessoas. A camuflagem mínima:

  • um User-Agent verossímil (não reqwest/0.13!);
  • um conjunto realista de cabeçalhos: Accept, Accept-Language, Accept-Encoding, Referer, Sec-Fetch-*;
  • rotação de User-Agent e de proxies;
  • pausas em ritmo humano.

As proteções sérias (Cloudflare, DataDome, PerimeterX) verificam ainda o fingerprint TLS (JA3/JA4) e a ordem dos cabeçalhos HTTP/2. Um reqwest comum entrega uma impressão digital própria «de Rust», diferente da do Chrome. Para contornar isso existem crates que imitam a impressão digital do navegador com base no curl-impersonate, por exemplo o rquest. É uma «corrida armamentista»: não há garantias.

12.5 Tratamento de erros e logging

  • Erros: anyhow para aplicações (um ? prático e com contexto), thiserror para bibliotecas (tipos de erro próprios). Não entre em panic a cada 404: trate-o como um resultado normal.
  • Logging/tracing: tracing + tracing-subscriber (ou log + env_logger). Os logs mostram onde estão os gargalos e os bloqueios.
rust
use anyhow::Context;

let html = client.get(url).send().await
    .with_context(|| format!("não foi possível baixar {url}"))?
    .text().await
    .context("não foi possível ler o corpo")?;

13. Arquitetura do crawler

O esquema de um scraper de nível industrial que reúne tudo o que vimos:

Arquitetura de um crawler industrial em Rust: fila frontier com visited-set, pool de workers tokio com rate limit e rotação de proxies, cliente HTTP reqwest, decodificação e parsing (encoding_rs, scraper, serde_json), armazenamento de resultados e retorno dos links novos ao frontier

Princípios-chave: - um único Client compartilhado (pool de conexões) que é clonado para os workers; - a concorrência é limitada com um semáforo e a velocidade por domínio, com um limitador; - cada chamada de rede vem envolta em retry/backoff; - a fila e o conjunto seen são a única fonte de verdade sobre o progresso.

Se você não quer montar tudo manualmente, existem frameworks de rastreamento prontos para usar, por exemplo o spider.


14. Vantagens e desvantagens

Vantagens da implementação em Rust

  • Desempenho: velocidade próxima à de C/C++. Com grandes volumes, Rust supera várias vezes o Python (requests/BeautifulSoup) e o Go em CPU e memória.
  • Memória: consumo mínimo e sem pausas de GC — importante com milhões de páginas e rastreamentos de longa duração.
  • Multithreading sem medo: o sistema de tipos e o borrow checker caçam as corridas de dados em tempo de compilação. Uma vantagem enorme para um scraper concorrente.
  • Confiabilidade: tratamento explícito de erros (Result) e Option em vez de null — menos quedas em produção.
  • Um único binário estático: fácil de fazer o deploy, sem carregar um interpretador nem dependências.
  • Ecossistema async maduro: tokio + reqwest são production-grade.

Desvantagens

  • Curva de entrada: borrow checker, lifetimes, async — leva mais tempo de aprendizado do que um scraper em Python montado «em uma tarde».
  • Velocidade de desenvolvimento: um protótipo em Python/scrapy é escrito mais rápido. Para uma tarefa pontual, Rust pode ser exagero.
  • Conteúdo dinâmico: há menos soluções prontas «de fábrica» para renderizar JS e contornar proteções anti-bots do que em Python (onde existem Playwright, Scrapy, undetected-chromedriver etc.).
  • Compilação: tempos de build longos, sobretudo com dependências pesadas.
  • Menos frameworks completos: o Python tem um scrapy maduro e completo; em Rust, o habitual é montar o pipeline peça por peça (embora existam o spider e outros).

Conclusão: Rust se justifica quando o scraping é massivo, permanente e sensível a recursos (milhões de páginas, requisitos rígidos de velocidade e memória, um serviço de vida longa). Para um «scrapear 500 páginas» pontual, o Python costuma sair mais rápido em esforço total.


15. Aspectos legais e éticos

Tecnicamente é possível fazer muita coisa, o que não significa que se deva. Em resumo:

  • robots.txt e ToS: respeite o robots.txt e os termos de uso do site.
  • Carga: não derrube o servidor alheio — limite a frequência das requisições e scrapeie em horários de baixo movimento.
  • Dados pessoais: a coleta deles é regulada por lei (a LGPD no Brasil, o GDPR na UE e normas equivalentes em outros países). Aja com cautela.
  • Direitos autorais: o conteúdo pode estar protegido; a cópia e a republicação em massa podem ser ilegais.
  • Identifique-se: é sensato incluir um contato no User-Agent, para que o administrador do site possa escrever para você em vez de bloquear às cegas.

Isto é uma orientação geral, não uma consultoria jurídica: em casos duvidosos, consulte um advogado.


Referência rápida de crates

Tarefa Crate(s)
Cliente HTTP reqwest (async/blocking), ureq (sync)
Parsing de HTML scraper, dom_query, select
JSON / XML serde_json, quick-xml
Codificações legadas encoding_rs, chardetng
Runtime async tokio, futures
Paralelismo de CPU rayon
Rate limiting governor
Novas tentativas reqwest-middleware, reqwest-retry
TOR TOR externo + socks, ou então arti-client / artiqwest
Navegador headless chromiumoxide, thirtyfour, fantoccini
robots.txt texting_robots
Filas/armazenamento redis, rusqlite / sqlx, sled, bloomfilter
Erros/logs anyhow, thiserror, tracing
Framework pronto spider