Raspado web en Rust con reqwest y scraper: Una guía práctica
Expert in Web Scraping Technologies
Resumen:
- La extracción web en Rust en 2026 se basa en dos crates:
reqwestpara las solicitudes HTTP yscraperpara el análisis de selectores CSS, contokioimpulsando el tiempo de ejecución asíncrono. - El compilador te obliga a manejar cada ruta de fallo, por lo que un scraper en Rust que se compila suele ser un scraper que sobrevive a un marcado malformado y respuestas no-200.
tokio::spawnconvierte un rastreador que recorre página por página en uno concurrente en unas cinco líneas, y esta guía lo ejecuta en cinco páginas para obtener 50 registros.- Ni
reqwestniscraperejecutan JavaScript, por lo que una página renderizada por el cliente devuelve un marcado que se analiza limpiamente en cero registros. - La API de extracción universal Scrapeless renderiza primero la página y devuelve HTML que el mismo código
scrapersin cambios analiza en 10 registros. - Comienza con el plan gratuito de Scrapeless y ejecuta el ejemplo de pivotación contra tu propio objetivo.
Rust aparece en trabajos de scraping por una razón específica: el rastreador suele ser la parte de un pipeline de datos que se ejecuta sin supervisión durante horas contra un marcado que nadie controla. Un binario con control de préstamos y sin pausas del recolector de basura, y manejo explícito de Result en cada límite, es una buena opción para ese trabajo.
Esta guía construye un scraper funcional con las versiones actuales de crates, lo ejecuta y luego muestra exactamente dónde se detiene la pila de Rust puro, con números medidos en lugar de una advertencia.
Lo Que Necesitas
La extracción web con Rust necesita tres crates y una herramienta estable. Las versiones a continuación son las liberas publicadas en el registro de crates de la comunidad de Rust al momento de escribir, y cada ejemplo aquí fue compilado y ejecutado contra exactamente estas:
| Crate | Versión | Trabajo |
|---|---|---|
reqwest |
0.13.4 | Cliente HTTP |
scraper |
0.27.0 | Análisis de HTML y selectores CSS |
tokio |
1.53.0 | Tiempo de ejecución asíncrono |
serde_json |
1 | Manejo de JSON para respuestas de API |
El crate scraper envuelve html5ever, el mismo motor de análisis utilizado en Servo, por lo que sigue la especificación de análisis HTML en lugar de tratar el marcado como texto para expresiones regulares. Eso es importante en páginas reales, donde las etiquetas no cerradas son normales.
Instalación
Crea el proyecto y añade las dependencias:
bash
cargo new rust-scraper
cd rust-scraper
Luego declara el bloque de dependencias en Cargo.toml:
toml
[package]
name = "rust-scraper"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.13.4", features = ["json"] }
scraper = "0.27.0"
tokio = { version = "1.53.0", features = ["macros", "rt-multi-thread"] }
serde_json = "1"
La característica json en reqwest incluye la serialización de solicitudes y respuestas. Las características macros y rt-multi-thread en tokio son lo que hace que el atributo #[tokio::main] funcione.
Obtener y Analizar una Página
Un scraper completo en Rust es más corto de lo que su reputación sugiere. Este obtiene una página renderizada por el servidor, selecciona cada contenedor de citas y extrae dos campos de cada uno:
rust
use scraper::{Html, Selector};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let body = reqwest::get("https://quotes.toscrape.com/")
.await?
.error_for_status()?
.text()
.await?;
let document = Html::parse_document(&body);
let quote_sel = Selector::parse("div.quote").unwrap();
let text_sel = Selector::parse("span.text").unwrap();
let author_sel = Selector::parse("small.author").unwrap();
let mut count = 0;
for quote in document.select("e_sel) {
let text = quote.select(&text_sel).next().map(|e| e.inner_html());
let author = quote.select(&author_sel).next().map(|e| e.inner_html());
if let (Some(t), Some(a)) = (text, author) {
if count == 0 {
println!("primera cita: {} — {}", t, a);
}
count += 1;
}
}
println!("citas analizadas: {}", count);
Ok(())
}
Ejecutarlo imprime:
text
primera cita: “El mundo tal como lo hemos creado es un proceso de nuestro pensamiento. No puede cambiarse sin cambiar nuestro pensamiento.” — Albert Einstein
citas analizadas: 10
Tres detalles en ese código llevan la mayor parte del peso.
error_for_status() convierte una respuesta 4xx o 5xx en un Err en lugar de dejar que analices una página de error como si fuera datos. Sin ello, un cuerpo 403 se convierte en cero coincidencias de selectores y se ve idéntico a un conjunto de resultados vacío. La distinción entre esas clases de estado se define en la especificación de semántica HTTP.
Selector::parse es fallible porque una cadena de selector se compila, no se interpreta en el momento de la coincidencia. Compilar selectores una vez fuera del bucle — en lugar de por cada elemento — es la razón por la que las llamadas anidadas a select permanecen baratas. La sintaxis sigue la especificación de Selectores de Nivel 4 del W3C.
El operador ? que se propaga fuera de main es lo que convierte Box<dyn std::error::Error> en un tipo de retorno práctico. Cada llamada fallida se descompone en el mismo lugar, y el proceso sale con un código distinto de cero en caso de fallo — útil cuando el raspador se ejecuta desde un programador.
Raspar Páginas Concurrentemente
La historia de concurrencia de Rust es el principal argumento para usarlo aquí, y tokio::spawn es donde se muestra. Cada página se convierte en una tarea independiente, todas comparten un cliente con conexión en grupo, y los resultados se recopilan en orden:
rust
use scraper::{Html, Selector};
async fn page_quote_count(client: &reqwest::Client, page: u32) -> Result<usize, reqwest::Error> {
let url = format!("https://quotes.toscrape.com/page/{page}/");
let body = client.get(&url).send().await?.error_for_status()?.text().await?;
let quote_sel = Selector::parse("div.quote").unwrap();
Ok(Html::parse_document(&body).select("e_sel).count())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let tasks: Vec<_> = (1..=5)
.map(|page| {
let client = client.clone();
tokio::spawn(async move { (page, page_quote_count(&client, page).await) })
})
.collect();
let mut total = 0;
for task in tasks {
let (page, result) = task.await?;
let count = result?;
println!("page {page}: {count} quotes");
total += count;
}
println!("total quotes across 5 pages: {total}");
Ok(())
}
La ejecución:
text
page 1: 10 quotes
page 2: 10 quotes
page 3: 10 quotes
page 4: 10 quotes
page 5: 10 quotes
total quotes across 5 pages: 50
reqwest::Client es barato de clonar porque el clon comparte el grupo de conexiones subyacente. Crear un cliente nuevo por tarea abriría un nuevo grupo cada vez y descartaría el beneficio. Mantén el rango de páginas limitado y ajustado a lo que el objetivo puede servir cómodamente — la concurrencia es una herramienta para terminar una carga de trabajo conocida, no para emitir tantas solicitudes simultáneas como permita el tiempo de ejecución.
¿Listo para apuntar esto a un objetivo que renderiza en el servidor? Crea una cuenta gratuita en Scrapeless y mantén el código de análisis que ya tienes.
Donde reqwest y scraper Se Detienen
Ninguna de las bibliotecas ejecuta JavaScript. reqwest devuelve los bytes que envió el servidor, y scraper analiza exactamente esos bytes — así que en una página que construye su contenido en el navegador, los selectores no coinciden con nada.
Esto es medible más que teórico. El sitio utilizado anteriormente publica un gemelo renderizado por el cliente de los mismos datos en /js/. Apuntando la lógica de análisis idéntica a él:
rust
use scraper::{Html, Selector};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let body = reqwest::get("https://quotes.toscrape.com/js/")
.await?
.error_for_status()?
.text()
.await?;
let document = Html::parse_document(&body);
let quote_sel = Selector::parse("div.quote").unwrap();
let count = document.select("e_sel).count();
println!("html bytes: {}", body.len());
println!("quotes parsed: {}", count);
Ok(())
}
text
html bytes: 5808
quotes parsed: 0
La solicitud tuvo éxito. El estado fue 200. El analizador funcionó correctamente en 5,808 bytes de HTML válido que simplemente no contenía elementos de cita — se escriben en el DOM después de que se ejecuta un script. Un raspador que solo verifica fallos HTTP trata esto como una página vacía en lugar de una incapacidad de barra, que es por qué la llamada anterior a error_for_status() es necesaria pero no suficiente.
Renderiza la Página Con la API Universal de Raspado
La API Universal de Raspado de Scrapeless cierra esa brecha al renderizar la página en un navegador en la nube y devolver el HTML resultante, lo que significa que el lado de Rust se mantiene como una simple llamada HTTP. Establece js_render a true y el cuerpo de la respuesta contiene el DOM post-script.
Autentícate con tu clave desde el entorno:
bash
export SCRAPELESS_API_KEY="your_api_key_here"
Luego solo intercambia la capa de obtención — el código del selector a continuación es idéntico al primer ejemplo:
rust
use scraper::{Html, Selector};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let api_key = std::env::var("SCRAPELESS_API_KEY")?;
let payload = json!({
```text
actor: "unlocker.webunlocker",
input: {
url: "https://quotes.toscrape.com/js/",
js_render: true,
headless: true
}
});
let envelope: serde_json::Value = reqwest::Client::new()
.post("https://api.scrapeless.com/api/v2/unlocker/request")
.header("x-api-token", api_key)
.json(&payload)
.send()
.await?
.error_for_status()?
.json()
.await?;
let body = envelope["data"].as_str().unwrap_or_default();
let document = Html::parse_document(body);
let quote_sel = Selector::parse("div.quote").unwrap();
let text_sel = Selector::parse("span.text").unwrap();
let author_sel = Selector::parse("small.author").unwrap();
let mut count = 0;
for quote in document.select("e_sel) {
let text = quote.select(&text_sel).next().map(|e| e.inner_html());
let author = quote.select(&author_sel).next().map(|e| e.inner_html());
if let (Some(t), Some(a)) = (text, author) {
if count == 0 {
println!("primera cita: {} — {}", t, a);
}
count += 1;
}
}
println!("html bytes: {}", body.len());
println!("citas analizadas: {}", count);
Ok(())
}
text
primera cita: “El mundo tal como lo hemos creado es un proceso de nuestro pensamiento. No puede ser cambiado sin cambiar nuestro pensamiento.” — Albert Einstein
html bytes: 8985
citas analizadas: 10
La misma página, los mismos selectores, las mismas versiones de crate. El único cambio es qué capa obtuvo el HTML, y el recuento de registros pasa de 0 a 10 mientras que el tamaño del payload aumenta de 5,808 a 8,985 bytes, siendo la diferencia el marcado de cita que el script escribió en el DOM.
La respuesta llega como un sobre JSON con el documento renderizado en data como una cadena, por eso el ejemplo se analiza primero a serde_json::Value y entrega data a Html::parse_document. Los detalles de las opciones de renderizado se encuentran en la documentación de Scrapeless, y el mismo comportamiento de js_render se cubre con más profundidad en la guía de renderizado JS.
Resolución de problemas
Los selectores no coinciden con nada en una página que puedes ver en un navegador. Imprime primero la longitud de la respuesta, como lo hace el ejemplo de JS anterior. Unas pocas miles de bytes sin coincidencias generalmente significa que el contenido es renderizado por el cliente, no que el selector sea incorrecto. Vista la fuente de la página en lugar del inspector; el inspector muestra el DOM después de que se han ejecutado los scripts, que no es lo que reqwest recibió.
Selector::parse lanza pánico al inicio. La cadena del selector no es CSS válido. Los pseudo-elementos y algunas extensiones estilo jQuery no son parte de la especificación y no compilarán.
La construcción falla en el backend de TLS. reqwest compila una pila TLS; en un contenedor mínimo, el sistema necesita una herramienta C y CMake disponibles, o puedes cambiar al backend rustls pura-Rust a través de banderas de características.
inner_html() devuelve marcado en lugar de texto. Ese método devuelve todo dentro del elemento, incluidas las etiquetas. Usa text() y recopila los fragmentos cuando un campo puede contener elementos anidados.
Antes de apuntar cualquiera de esto a un objetivo en vivo, verifica los términos del sitio y sus directivas de /robots.txt, que siguen el estándar del Protocolo de Exclusión de Robots. Mantén la colección a datos públicos y a un volumen que el objetivo pueda servir cómodamente.
Conclusión
Rust te proporciona un scraper que maneja fallos de manera explícita y paraleliza sin mucha ceremonia: reqwest para el transporte, scraper para los selectores, tokio para la concurrencia, y un compilador que no te dejará ignorar un Result. Esa pila cubre páginas renderizadas por el servidor completamente.
Lo que no hace es ejecutar JavaScript, y el costo de esa brecha es un cero silencioso en lugar de un error. Medirlo es el hábito útil: 10 registros en la página renderizada por el servidor, 0 en el gemelo renderizado por el cliente, 10 nuevamente una vez que algo renderiza la página antes de que Rust la analice.
Comienza con el plan gratuito de Scrapeless para ejecutar el paso de renderizado contra tus propios objetivos, y revisa la actual tarificación de Scrapeless cuando dimensionas un trabajo.
Preguntas frecuentes
P: ¿Cuál crate de Rust es mejor para web scraping?
`reqwest` emparejado con `scraper` cubre la mayor parte del trabajo. `reqwest` maneja HTTP con una API orientada a lo asíncrono, y `scraper` proporciona consultas con selectores CSS sobre un árbol de análisis `html5ever`. Alcance una crate de navegador sin cabeza o una API de renderizado solo cuando la página construye su contenido del lado del cliente.
**P: ¿Es Rust bueno para el web scraping en comparación con Python?**
Rust es una buena opción cuando el scraper se ejecuta durante mucho tiempo, se ejecuta de manera concurrente o se ejecutar sin supervisión, porque no hay pausa del recolector de basura y el compilador obliga a manejar cada camino de error. Python todavía gana en amplitud de ecosistema y velocidad de iteración para extracciones puntuales. Los conceptos de análisis se transfieren directamente entre ellos.
**P: ¿Puede reqwest ejecutar JavaScript?**
No. `reqwest` es un cliente HTTP y devuelve los bytes que envió el servidor. En una página renderizada por el cliente, eso significa HTML válido sin contenido: el ejemplo anterior analiza 5,808 bytes y devuelve cero registros. El renderizado tiene que ocurrir en otro lugar, ya sea en un navegador sin cabeza o a través de una API que devuelve el DOM renderizado.
**P: ¿Necesito async y tokio para un scraper simple?**
No estrictamente; `reqwest` envía un cliente bloqueante detrás de una bandera de características, que está bien para una solicitud secuencial única. El cliente asíncrono vale la pena la dependencia de `tokio` tan pronto como obtenga más de una página, ya que es donde `tokio::spawn` convierte las solicitudes secuenciales en concurrentes.
**P: ¿Cómo puedo mantener un scraper en Rust funcionando cuando el sitio cambia?**
Aserte sobre la estructura en lugar de confiar en ella. Verifique que el selector contenedor coincida con un número plausible de elementos antes de extraer campos, y trate un cero repentino como una falla en lugar de un resultado vacío. Compilar selectores una vez al inicio también pone de manifiesto CSS inválido de inmediato en lugar de en medio del rastreo.
En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.



