Actualizado a 2026. Versiones de los crates:
reqwest 0.13,scraper 0.27,tokio 1.x,encoding_rs 0.8. Cambio importante: a partir dereqwest 0.13, el backend TLS por defecto es rustls (Rust puro), y no el OpenSSL/native-tls del sistema.
Índice
- Introducción: por qué Rust para el scraping
- Cómo descargamos la página (cliente HTTP)
- Bibliotecas para el parseo del contenido
- Cómo resolver los problemas de codificación
- Multihilo y asincronía
- Uso de proxies
- Scraping a través de TOR
- Trabajar con HTTPS / SSL
- Trabajar con cookies
- Estado de la respuesta y cabeceras
- Almacenamiento de URL y colas (una panorámica)
- 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
- Arquitectura de un crawler completo
- Principales ventajas e inconvenientes de la implementación en Rust
- 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:
[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ómodaPá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)
#[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.
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:
reqwest = { version = "0.13", features = ["blocking"] }fn main() -> anyhow::Result<()> {
let body = reqwest::blocking::get("https://example.com")?.text()?;
println!("{body}");
Ok(())
}No llame al cliente
blockingdentro 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.
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.
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).
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:
- Primero, mire el
charsetde la cabeceraContent-Type. - Si no está, busque
<meta charset=...>/<meta http-equiv="Content-Type">en los primeros kilobytes del HTML. - Si tampoco aparece ahí, intente adivinarla estadísticamente (crate
chardetng).
use encoding_rs::Encoding;
use reqwest::header::CONTENT_TYPE;
async fn get_text_smart(client: &reqwest::Client, url: &str) -> anyhow::Result<String> {
let resp = client.get(url).send().await?;
// 1) 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")dereqwestusa 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
rayono atokio::task::spawn_blockingpara 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).
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:
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):
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:
reqwest = { version = "0.13", features = ["socks"] }6.1 Un proxy por cliente
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:
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:
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:
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:
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).
arti-client = "..." # compruebe la versión actual: cargo add arti-client
tor-rtcompat = "..."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
# 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:
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) // ⚠ inseguro
.build()?;8.3 Certificado raíz propio / certificado de cliente
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()?;9. Cookies
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:
reqwest = { version = "0.13", features = ["cookies"] }9.1 Cookie store automático
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?;9.2 Un cookie jar propio (acceso a los valores / reutilización)
Cuando necesite leer o fijar cookies a mano, o trasladarlas de una sesión a otra:
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 acumuladas9.3 Cookies a mano en la cabecera
Si no quiere activar la gestión automática, puede pasar las cookies directamente como cabecera:
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.
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 enErr; 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,
reqwestsigue 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)
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
HashSeten 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 limitadorgovernor(token bucket):
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:
reqwest-middleware = "0.5"
reqwest-retry = "0.9"use reqwest_middleware::ClientBuilder;
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
let client = ClientBuilder::new(reqwest::Client::new())
.with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build();
// a partir de aquí, client.get(...).send().await — los reintentos se ejecutan solos12.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:
- Encontrar la API oculta (vea el §3.2) — casi siempre el mejor camino: más rápido, más fiable, más ligero.
- 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-Agentverosímil (¡noreqwest/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:
anyhowpara aplicaciones (un?cómodo y con contexto),thiserrorpara bibliotecas (tipos de error propios). No haga panic con cada 404: trátela como un resultado normal. - Logging/tracing:
tracing+tracing-subscriber(olog+env_logger). Los logs muestran dónde están los atascos y los bloqueos.
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:
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) yOptionen lugar denull— 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+reqwestson 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/
scrapyse 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
scrapyhecho y derecho; en Rust, lo habitual es montar el pipeline pieza a pieza (aunque existenspidery 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.txty 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 |