Scraping por lenguaje 26 min de lectura

Web scraping en Rust: guía completa de lo simple a lo complejo

Web scraping en Rust: reqwest, scraper y tokio — cómo el tipado estricto y la velocidad de un lenguaje compilado rinden con grandes volúmenes.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 5 abril 2025

Actualizado a 2026. Versiones de los crates: reqwest 0.13, scraper 0.27, tokio 1.x, encoding_rs 0.8. Cambio importante: a partir de reqwest 0.13, el backend TLS por defecto es rustls (Rust puro), y no el OpenSSL/native-tls del sistema.


Índice

  1. Introducción: por qué Rust para el scraping
  2. Cómo descargamos la página (cliente HTTP)
  3. Bibliotecas para el parseo del contenido
  4. Cómo resolver los problemas de codificación
  5. Multihilo y asincronía
  6. Uso de proxies
  7. Scraping a través de TOR
  8. Trabajar con HTTPS / SSL
  9. Trabajar con cookies
  10. Estado de la respuesta y cabeceras
  11. Almacenamiento de URL y colas (una panorámica)
  12. Extra: lo que se suele olvidar
    • Cortesía, robots.txt, rate limiting
    • Reintentos y backoff
    • Páginas JavaScript (navegadores headless)
    • User-Agent y protecciones anti-bots
    • Gestión de errores y logging
  13. Arquitectura de un crawler completo
  14. Principales ventajas e inconvenientes de la implementación en Rust
  15. Aspectos legales y éticos

1. Introducción

El web scraping consiste en obtener automáticamente el HTML/JSON/XML de las páginas y extraer de ellos datos estructurados. Todo scraper se compone de dos grandes partes:

  • la capa de red — descarga la página (cliente HTTP);
  • la capa de parseo — convierte el HTML «en bruto» en los campos que necesita (parser + selectores).

Después se añaden proxies, concurrencia, evasión de protecciones anti-bots, almacenamiento de la cola de enlaces, etc. Rust destaca porque ofrece una velocidad comparable a C y un consumo mínimo de memoria con un paralelismo seguro — justo lo que resulta crítico cuando descarga millones de páginas.

El Cargo.toml inicial, al que iremos añadiendo funcionalidades poco a poco:

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"          # gestión de errores cómoda

Páginas oficiales de los crates básicos: reqwest, tokio, scraper, encoding_rs, anyhow.


2. Cómo descargamos la página

En el ecosistema de Rust hay varios clientes HTTP. Para el scraping, en el 99% de los casos se elige reqwest.

Crate Cuándo usarlo
reqwest La opción principal. Async + blocking, proxies, cookies, TLS: lo trae todo.
ureq Cliente síncrono ligero, sin tokio. Para scripts sencillos.
isahc Cliente async basado en libcurl.
hyper De bajo nivel. Necesario cuando construye su propio cliente/servidor.

Documentación: docs.rs/reqwest.

2.1 La petición más simple (async)

rust
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let body = reqwest::get("https://example.com")
        .await?      // esperamos la respuesta
        .text()      // leemos el cuerpo como cadena
        .await?;
    println!("{body}");
    Ok(())
}

2.2 La forma correcta — un Client reutilizable

reqwest::get crea un cliente nuevo en cada llamada. Eso sale caro: se pierde el pool de conexiones (keep-alive). Cree un solo Client y clónelo — por dentro es un Arc, y el clon es barato.

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

fn build_client() -> anyhow::Result<Client> {
    let client = Client::builder()
        // nos hacemos pasar por un navegador normal
        .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 petición
        .connect_timeout(Duration::from_secs(10))  // timeout de establecimiento de la conexión
        .gzip(true)                                // descompresión automática de gzip
        .brotli(true)                              // descompresión 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", "es-ES,es;q=0.9")
        .send()
        .await?;

    println!("Estado: {}", resp.status());
    let html = resp.text().await?;
    println!("Longitud del HTML: {}", html.len());
    Ok(())
}

2.3 La variante síncrona (sin tokio)

Si no quiere arrastrar un runtime async para un script pequeño, existe 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(())
}

No llame al cliente blocking dentro de un runtime async: provocará un panic. Elija una de las dos vías.


3. Bibliotecas para el parseo

Una vez descargado el HTML, hay que analizarlo. La regla de oro: no parsee HTML con expresiones regulares. El HTML no es un lenguaje regular; ese enfoque se romperá con la primera comilla sin escapar. Las regex solo tienen sentido para extraer detalles menores de un texto ya localizado.

Crate Enfoque Notas
scraper Selectores CSS El más popular. Envoltorio sobre html5ever, de Servo.
dom_query Selectores CSS + manipulación Alternativa reciente; sabe modificar el DOM.
select DSL propio de predicados Más veterano, pero funcional.
html5ever tokenizador de bajo nivel Parser de calidad de navegador. Se usa dentro de scraper.
lol_html rewriter en streaming De Cloudflare. Para documentos muy grandes, «al vuelo».
quick-xml XML / RSS / sitemap Parser XML rápido en streaming.
serde_json JSON Para respuestas de API y JSON incrustado.

3.1 scraper + selectores CSS

Documentación y ejemplos: docs.rs/scraper.

rust
use scraper::{Html, Selector};

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

    // Conviene compilar los selectores una sola vez (fuera del bucle).
    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();

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

        // la fecha, del atributo datetime
        let date = item
            .select(&date_sel)
            .next()
            .and_then(|e| e.value().attr("datetime"))
            .unwrap_or("");

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

Trucos útiles de scraper:

  • element.text().collect::<String>() — reúne todo el texto interior (incluido el anidado).
  • element.value().attr("href") — toma un atributo.
  • element.html() / element.inner_html() — devuelve el HTML original.
  • Los selectores admiten [attr="value"], :nth-child, >, (descendientes), etc.

3.2 JSON desde una API

A menudo es más sencillo tomar los datos no del HTML, sino de la API JSON oculta que consulta la propia página. Abra DevTools → pestaña Network → localice la petición que devuelve JSON. Es más fiable que cualquier parseo del marcado. La deserialización se hace 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>>()   // deserialización directa a structs
        .await?;
    Ok(products)
}

3.3 Sitemap y RSS con quick-xml

Los mapas del sitio (sitemap.xml) son la mejor manera de conocer todas las URL de un sitio sin recorrer sus enlaces. Se parsean como XML normal con quick-xml (o con un crate especializado como sitemap).


4. Codificaciones y caracteres especiales

Un dolor clásico al scrapear la parte más veterana de la web hispanohablante: muchos sitios antiguos sirven el contenido en Windows-1252 o ISO-8859-1 (Latin-1), y no en UTF-8.

Por qué se rompe

El método resp.text() determina la codificación a partir de la cabecera Content-Type: text/html; charset=.... Si la cabecera no indica el charset y este solo aparece en el HTML (<meta charset="windows-1252">), reqwest asume por defecto que es UTF-8 — y usted recibe «mojibake»: Espa�a en lugar de España (o, en el caso inverso, café en lugar de café).

La solución: leer los bytes y decodificar por su cuenta

Tome los bytes «en bruto» con .bytes() y decodifíquelos con la codificación correcta mediante encoding_rs (el mismo motor que usa 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?;

    // Decodificamos como Windows-1252.
    let (text, _enc, had_errors) = WINDOWS_1252.decode(&bytes);
    if had_errors {
        eprintln!("Atención: hubo errores durante la decodificación");
    }
    Ok(text.into_owned())
}

Detección automática de la codificación

Mejor que fijar la codificación a fuego es detectarla. El algoritmo:

  1. Primero, mire el charset de la cabecera Content-Type.
  2. Si no está, busque <meta charset=...> / <meta http-equiv="Content-Type"> en los primeros kilobytes del HTML.
  3. Si tampoco aparece ahí, intente adivinarla estadísticamente (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) intentamos tomar el charset de la cabecera
    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) si no está en la cabecera, lo buscamos en <meta> (simplificado: primeros 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) elegimos la codificación (UTF-8 por defecto)
    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: el método resp.text_with_charset("windows-1252") de reqwest usa la codificación indicada como reserva cuando el charset no llega en la cabecera. Es más simple, pero no cubre el caso «la cabecera dice UTF-8 y en realidad es 1252».


5. Multihilo y asincronía

El scraping casi siempre es I/O-bound: el procesador está ocioso mientras los paquetes viajan por la red. Por eso en Rust aquí no ganan los «hilos», sino la asincronía sobre tokio: miles de peticiones simultáneas en uno o dos hilos del sistema operativo.

Distinga dos tareas:

  • Descargar (I/O-bound) → async/tokio, muchas conexiones simultáneas.
  • Parsear (CPU-bound: html5ever castiga la CPU) → con grandes volúmenes, muévalo a rayon o a tokio::task::spawn_blocking para no bloquear el runtime async.

5.1 Concurrencia con límite — buffer_unordered

La forma más idiomática: convertimos el flujo de URL en un flujo de futures, y buffer_unordered(N) ejecuta como máximo N a la vez (del crate futures).

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

async fn crawl_many(client: &reqwest::Client, urls: Vec<String>) {
    let concurrency = 20; // no más de 20 peticiones a la vez

    let results = stream::iter(urls)
        .map(|url| {
            let client = client.clone(); // el clon es 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!("Error {url}: {e}");
                        (url, 0, 0)
                    }
                }
            }
        })
        .buffer_unordered(concurrency)
        .collect::<Vec<_>>()
        .await;

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

5.2 Límite con Semaphore

Cuando las tareas se lanzan con tokio::spawn, el límite se sostiene con un 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)); // máximo 20 «en vuelo»
    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 un hueco libre
            let _ = client.get(&url).send().await;
            // el permiso se libera al salir del ámbito
        }));
    }

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

5.3 Parseo CPU-bound con rayon

Si ya tiene miles de HTML descargados y necesita parsearlos rápido, ese es trabajo para todos los 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

Los proxies sirven para (a) no chocar con un bloqueo por IP durante el scraping masivo y (b) sortear restricciones geográficas. reqwest admite proxies HTTP, HTTPS y SOCKS5.

Para SOCKS, active la feature:

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

6.1 Un 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"); // si hace falta autenticación

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

Proxy::http(...), Proxy::https(...) y Proxy::all(...) fijan el proxy para los esquemas correspondientes. SOCKS5:

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

6.2 Rotación de un pool de proxies

Cada Client queda ligado a un solo proxy. Para rotar, lo más cómodo es mantener un cliente por proxy y elegirlos por turno rotatorio:

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

    /// Devuelve el siguiente cliente del ciclo (round-robin).
    fn next(&self) -> &Client {
        let i = self.idx.fetch_add(1, Ordering::Relaxed) % self.clients.len();
        &self.clients[i]
    }
}

Los proxies residenciales/móviles con rotación automática del lado del proveedor suelen dar una única dirección «pasarela»; en ese caso no necesita rotar nada por su parte: basta un solo cliente.


7. Scraping a través de TOR

TOR ofrece anonimato y una «rotación» de IP gratuita (nuevo circuito → nuevo nodo de salida). Hay dos caminos.

7.1 El camino simple: TOR externo + SOCKS5

Arranque el TOR del sistema (el demonio tor o Tor Browser), que levanta un proxy SOCKS5 en 127.0.0.1:9050 (en Tor Browser, el 9150). A partir de ahí, funciona como cualquier proxy SOCKS:

rust
use reqwest::{Client, Proxy};

fn tor_client() -> anyhow::Result<Client> {
    // IMPORTANTE: socks5h (con la letra h), no socks5.
    // 'h' = resolución DNS del lado del proxy (dentro de TOR);
    // de lo contrario habrá fugas DNS y los .onion no funcionarán.
    let proxy = Proxy::all("socks5h://127.0.0.1:9050")?;
    let client = Client::builder()
        .proxy(proxy)
        .build()?;
    Ok(client)
}

Comprobación de que el tráfico pasa por 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(())
}

El cambio de circuito (IP nueva) se hace a través del control-port de TOR (normalmente el 9051): hay que enviar la señal NEWNYM. Se puede hacer a mano con el protocolo del control-port o con un crate que lo envuelva. Tras un NEWNYM conviene guardar una pausa (TOR limita la frecuencia de cambio a ~una vez cada 10 segundos).

7.2 TOR incrustado: arti

Arti es la implementación de TOR en Rust puro del propio Tor Project. Permite incrustar TOR directamente en la aplicación, sin demonio externo. La API cliente de alto nivel está en el crate arti-client (docs.rs).

toml
arti-client = "..."   # compruebe la versión actual: cargo add arti-client
tor-rtcompat = "..."
rust
use arti_client::{TorClient, TorClientConfig};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = TorClientConfig::default();
    // Levantamos el cliente TOR incrustado y esperamos el bootstrap.
    let tor = TorClient::create_bootstrapped(config).await?;

    // Después se pueden abrir streams TCP anónimos (AsyncRead/AsyncWrite)
    // y enviar HTTP sobre ellos a mano o mediante hyper.
    let mut stream = tor.connect(("example.com", 80)).await?;
    // ... envío de la petición HTTP por el stream ...
    Ok(())
}

Existe también un crate «pegamento», artiqwest, que enruta las peticiones HTTP a través de arti con una API al estilo de reqwest (get/post), incluidos los .onion y los websockets.

Inconvenientes de arti: la API aún no está estabilizada (hasta la 1.x puede haber breaking changes) y no cubre todas las funciones de C-Tor. A cambio, no necesita un proceso externo y el despliegue es más sencillo.

Aviso: los nodos de salida de TOR están saturados, son lentos y a menudo figuran bloqueados en los sitios populares. TOR es bueno para el anonimato y para acceder a los .onion, pero malo como «pool gratuito de proxies rápidos».


8. HTTPS / SSL

Desde reqwest 0.13, HTTPS funciona «de serie»: el backend por defecto es rustls (Rust puro, sin necesidad del OpenSSL del sistema). Normalmente no hay nada que configurar.

8.1 Elección del backend TLS

toml
# rustls (por defecto) — multiplataforma, no necesita OpenSSL
reqwest = { version = "0.13" }

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

# o OpenSSL compilado estáticamente (cómodo para distribuir el binario)
reqwest = { version = "0.13", default-features = false, features = ["native-tls-vendored"] }

Backends TLS: rustls, native-tls, openssl.

8.2 Ignorar los errores de certificado (¡peligroso!)

A veces hay que scrapear un sitio con un certificado autofirmado o caducado. Se puede desactivar la verificación — pero solo para pruebas y hosts de confianza, porque elimina la protección contra ataques MITM:

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

8.3 Certificado raíz propio / certificado de cliente

rust
use reqwest::{Certificate, Identity};

// Añadir una CA corporativa o autofirmada:
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()?;

Las cookies hacen falta para las sesiones, la autenticación y para sortear algunas protecciones. reqwest sabe almacenarlas y adjuntarlas automáticamente entre peticiones.

Active la feature:

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

// 1) iniciamos sesión — el servidor devolverá Set-Cookie y el cliente las recordará
client.post("https://site.example/login")
    .form(&[("user", "alice"), ("pass", "secret")])
    .send().await?;

// 2) las peticiones siguientes saldrán automáticamente con esas cookies
let dashboard = client.get("https://site.example/dashboard")
    .send().await?
    .text().await?;

Cuando necesite leer o fijar cookies a mano, o trasladarlas de una sesión a otra:

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

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

// Colocar una cookie a mano, de antemano:
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 nuestro jar
    .build()?;

// tras las peticiones, se pueden leer del jar las cookies acumuladas

Si no quiere activar la gestión automática, puede pasar las cookies directamente como cabecera:

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

10. Estado de la respuesta y cabeceras

Antes de parsear el HTML casi siempre conviene comprobar que la página llegó bien (200), y no 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(""));

    // Comprobaciones cómodas por categoría de estado:
    if status.is_success() {            // 2xx
        // leemos las cabeceras que interesan
        let headers = resp.headers();

        if let Some(ct) = headers.get(CONTENT_TYPE).and_then(|v| v.to_str().ok()) {
            println!("Content-Type: {ct}");
            // parseamos solo HTML; las imágenes se omiten
            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!("Redirección a: {loc}");
        }
    }

    if status == StatusCode::TOO_MANY_REQUESTS {  // 429
        // el servidor pide esperar
        if let Some(ra) = resp.headers().get(RETRY_AFTER).and_then(|v| v.to_str().ok()) {
            println!("Nos están frenando. Retry-After: {ra} s");
        }
    }

    Ok(None)
}

Métodos útiles:

  • resp.status()StatusCode; con .is_success(), .is_client_error(), .is_server_error(), .is_redirection().
  • resp.error_for_status() — convierte los 4xx/5xx en Err; cómodo con ?.
  • resp.headers()HeaderMap, iterable como un map.
  • resp.url() — la URL final tras las redirecciones.
  • resp.content_length() — la longitud del cuerpo, si se conoce.

Por defecto, reqwest sigue las redirecciones por su cuenta (hasta 10). El comportamiento se ajusta con .redirect(reqwest::redirect::Policy::none()) o .limited(n).


11. Almacenamiento de URL y colas

Todo rastreador (crawler) es, en esencia, un bucle: «sacar una URL de la cola → descargar → extraer los enlaces nuevos → devolverlos a la cola». Aquí hacen falta dos estructuras:

  • la cola (frontier) — qué descargar a continuación;
  • el conjunto de visitadas (visited/seen) — para no descargar lo mismo dos veces.

11.1 En memoria (para tareas pequeñas)

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() }
    }
    /// Añade la URL si aún no se ha visto.
    fn push(&mut self, url: String) {
        if self.seen.insert(url.clone()) {  // insert devuelve false si ya estaba
            self.queue.push_back(url);
        }
    }
    fn pop(&mut self) -> Option<String> {
        self.queue.pop_front()
    }
}

Para el acceso concurrente desde varias tareas async, la cola se monta sobre canales: tokio::sync::mpsc, flume o crossbeam-channel. Los workers leen del canal y escriben en él los enlaces nuevos.

11.2 Persistencia (para rastreos grandes y largos)

Con millones de URL la memoria se agota, y si el proceso se cae perderá el progreso. Por eso la cola y las «visitadas» se llevan a un almacenamiento externo:

Almacenamiento Crate Cuándo
Redis redis Cola distribuida entre varios workers.
SQLite rusqlite / sqlx Un solo proceso con persistencia sencilla.
PostgreSQL sqlx Grandes volúmenes, analítica, varias máquinas.
RocksDB / sled rocksdb / sled Almacén clave-valor local muy rápido.

Deduplicación a gran escala: guardar todas las URL en un HashSet sale caro. Se emplean: - la normalización de URL (quitar el #fragment, ordenar los parámetros de la consulta, pasar el host a minúsculas) con el crate url — de lo contrario, una misma página entrará con URL distintas; - un hash de la URL (por ejemplo, xxhash-rust / blake3) en lugar de la cadena entera; - el filtro de Bloom (bloomfilter) — una estructura probabilística compacta de «quizá vista / seguro que no vista».

Esta sección es solo una panorámica. En la práctica, la elección depende de la escala: para un par de miles de páginas basta un HashSet en memoria; para un crawler industrial, cola en Redis + filtro de Bloom + normalización de URL.


12. Extra

Lo que no entró en la lista inicial, pero sin lo cual un scraper real se rompe o acaba bloqueado.

12.1 Cortesía, robots.txt y rate limiting

  • robots.txt — el archivo donde el sitio indica qué se puede recorrer y qué no. El scraping ético (y, a veces, el jurídicamente prudente) lo respeta. Crates: texting_robots, robotstxt.
  • Pausas entre peticiones al mismo dominio, para no tumbar el sitio ni ganarse un bloqueo. La variante más simple es tokio::time::sleep; la profesional, el limitador governor (token bucket):
rust
use std::num::NonZeroU32;
use governor::{Quota, RateLimiter};

// no más de 5 peticiones por segundo
let limiter = RateLimiter::direct(Quota::per_second(NonZeroU32::new(5).unwrap()));

// antes de cada petición:
limiter.until_ready().await;
// client.get(...).send().await?;

12.2 Reintentos y backoff

La red es inestable: timeouts, 503, cortes de conexión. Hacen falta reintentos con espera exponencial (1 s → 2 s → 4 s...). Lo más sencillo son los 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();
// a partir de aquí, client.get(...).send().await — los reintentos se ejecutan solos

12.3 Páginas JavaScript (navegadores headless)

reqwest + scraper solo ven el HTML original. Si el contenido lo pinta JavaScript (una SPA en React/Vue), no estará en ese HTML. Opciones:

  1. Encontrar la API oculta (vea el §3.2) — casi siempre el mejor camino: más rápido, más fiable, más ligero.
  2. Controlar un navegador real (que sí renderiza el JS):
Crate Protocolo Notas
chromiumoxide CDP (Chrome DevTools) Async; controla Chrome directamente.
thirtyfour WebDriver Compatible con Selenium; API de alto nivel cómoda.
fantoccini WebDriver Más ligero que thirtyfour.
headless_chrome CDP Envoltorio síncrono sobre CDP.

Un navegador consume decenas de veces más recursos, así que úselo solo donde el JS resulte imprescindible.

12.4 User-Agent, cabeceras y protecciones anti-bots

Los sitios distinguen a los bots de las personas. El camuflaje mínimo:

  • un User-Agent verosímil (¡no reqwest/0.13!);
  • un juego realista de cabeceras: Accept, Accept-Language, Accept-Encoding, Referer, Sec-Fetch-*;
  • rotación de User-Agent y de proxies;
  • pausas de ritmo humano.

Las protecciones serias (Cloudflare, DataDome, PerimeterX) verifican además el fingerprint TLS (JA3/JA4) y el orden de las cabeceras HTTP/2. Un reqwest normal entrega una huella propia «de Rust», distinta de la de Chrome. Para sortearlo existen crates que imitan la huella del navegador sobre la base de curl-impersonate, por ejemplo rquest. Es una «carrera armamentística»: no hay garantías.

12.5 Gestión de errores y logging

  • Errores: anyhow para aplicaciones (un ? cómodo y con contexto), thiserror para bibliotecas (tipos de error propios). No haga panic con cada 404: trátela como un resultado normal.
  • Logging/tracing: tracing + tracing-subscriber (o log + env_logger). Los logs muestran dónde están los atascos y los bloqueos.
rust
use anyhow::Context;

let html = client.get(url).send().await
    .with_context(|| format!("no se pudo descargar {url}"))?
    .text().await
    .context("no se pudo leer el cuerpo")?;

13. Arquitectura del crawler

El esquema de un scraper de nivel industrial que reúne todo lo anterior:

Arquitectura de un crawler industrial en Rust: cola frontier con visited-set, pool de workers tokio con rate limit y rotación de proxies, cliente HTTP reqwest, decodificación y parseo (encoding_rs, scraper, serde_json), almacén de resultados y retorno de los enlaces nuevos al frontier

Principios clave: - un único Client compartido (pool de conexiones) que se clona hacia los workers; - la concurrencia se limita con un semáforo y la velocidad por dominio, con un limitador; - cada llamada de red va envuelta en retry/backoff; - la cola y el conjunto seen son la única fuente de verdad sobre el progreso.

Si no quiere ensamblarlo todo a mano, existen frameworks de rastreo listos para usar, por ejemplo spider.


14. Ventajas e inconvenientes

Ventajas de la implementación en Rust

  • Rendimiento: velocidad cercana a C/C++. Con grandes volúmenes, Rust supera varias veces a Python (requests/BeautifulSoup) y a Go en CPU y memoria.
  • Memoria: consumo mínimo y sin pausas de GC — importante con millones de páginas y rastreos de larga duración.
  • Multihilo sin miedo: el sistema de tipos y el borrow-checker cazan las carreras de datos en tiempo de compilación. Una ventaja enorme para un scraper concurrente.
  • Fiabilidad: gestión explícita de errores (Result) y Option en lugar de null — menos caídas en producción.
  • Un único binario estático: fácil de desplegar, sin arrastrar un intérprete ni dependencias.
  • Ecosistema async maduro: tokio + reqwest son production-grade.

Inconvenientes

  • Curva de entrada: borrow-checker, lifetimes, async — lleva más tiempo de aprendizaje que un scraper en Python montado «en una tarde».
  • Velocidad de desarrollo: un prototipo en Python/scrapy se escribe antes. Para una tarea puntual, Rust puede ser excesivo.
  • Contenido dinámico: hay menos soluciones listas «de serie» para renderizar JS y sortear protecciones anti-bots que en Python (donde existen Playwright, Scrapy, undetected-chromedriver, etc.).
  • Compilación: tiempos de build largos, sobre todo con dependencias pesadas.
  • Menos frameworks completos: Python tiene un scrapy hecho y derecho; en Rust, lo habitual es montar el pipeline pieza a pieza (aunque existen spider y otros).

Conclusión: Rust se justifica cuando el scraping es masivo, permanente y sensible a los recursos (millones de páginas, requisitos estrictos de velocidad y memoria, un servicio de larga vida). Para un «scrapear 500 páginas» puntual, Python suele salir más rápido en esfuerzo total.


15. Aspectos legales y éticos

Técnicamente se puede hacer mucho, lo que no significa que se deba. En resumen:

  • robots.txt y ToS: respete el robots.txt y las condiciones de uso del sitio.
  • Carga: no tumbe el servidor ajeno — limite la frecuencia de las peticiones y scrapee en horas valle.
  • Datos personales: su recopilación está regulada por ley (el RGPD/GDPR en la UE y normas equivalentes en otros países). Actúe con cautela.
  • Derechos de autor: el contenido puede estar protegido; la copia y republicación masivas pueden ser ilegales.
  • Identifíquese: es sensato incluir un contacto en el User-Agent, para que el administrador del sitio pueda escribirle en lugar de bloquear a ciegas.

Esto es una orientación general, no asesoramiento jurídico: en los casos dudosos, consulte con un abogado.


Referencia rápida de crates

Tarea Crate(s)
Cliente HTTP reqwest (async/blocking), ureq (sync)
Parseo de HTML scraper, dom_query, select
JSON / XML serde_json, quick-xml
Codificaciones legacy encoding_rs, chardetng
Runtime async tokio, futures
Paralelismo de CPU rayon
Rate limiting governor
Reintentos reqwest-middleware, reqwest-retry
TOR TOR externo + socks, o bien arti-client / artiqwest
Navegador headless chromiumoxide, thirtyfour, fantoccini
robots.txt texting_robots
Colas/almacenamiento redis, rusqlite / sqlx, sled, bloomfilter
Errores/logs anyhow, thiserror, tracing
Framework listo spider