Um web crawler e um dos programas mais fascinantes que existem: ele navega pela internet automaticamente, seguindo links de pagina em pagina. Neste projeto, vamos construir um crawler concorrente em Rust usando reqwest para requisicoes HTTP e tokio para execucao assincrona. Nosso crawler vai respeitar limites de profundidade, evitar visitar a mesma URL duas vezes e fazer crawling educado com delays entre requisicoes.
Este projeto e ideal para aprender programacao assincrona em Rust, concorrencia com tarefas e gerenciamento de estado compartilhado entre multiplas tasks.
O Que Vamos Construir
Um web crawler concorrente com as seguintes funcionalidades:
- Requisicoes HTTP assincronas com
reqwest - Concorrencia controlada com limite de tarefas simultaneas
- Fronteira de URLs com fila de prioridade por profundidade
- Extracao de links do HTML usando regex
- Conjunto de URLs visitadas para evitar duplicatas
- Limite de profundidade configuravel
- Delay entre requisicoes (crawling educado)
- Relatorio final com estatisticas
Estrutura do Projeto
crawler/
├── Cargo.toml
└── src/
├── main.rs
├── crawler.rs
├── extrator.rs
└── config.rs
Configurando o Projeto
cargo new crawler
cd crawler
[package]
name = "crawler"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = { version = "0.12", features = ["rustls-tls"] }
tokio = { version = "1", features = ["full"] }
url = "2"
Passo 1: Configuracao do Crawler
Definimos as opcoes configuraveis do crawler em um modulo separado.
Crie o arquivo src/config.rs:
use std::time::Duration;
/// Configuracao do crawler
#[derive(Debug, Clone)]
pub struct ConfigCrawler {
/// URL inicial para comecar o crawling
pub url_inicial: String,
/// Profundidade maxima de links a seguir
pub profundidade_maxima: u32,
/// Numero maximo de paginas a visitar
pub maximo_paginas: usize,
/// Numero maximo de tarefas concorrentes
pub concorrencia: usize,
/// Delay entre requisicoes ao mesmo dominio
pub delay: Duration,
/// Se deve restringir ao mesmo dominio da URL inicial
pub mesmo_dominio: bool,
/// User-Agent para as requisicoes
pub user_agent: String,
}
impl Default for ConfigCrawler {
fn default() -> Self {
Self {
url_inicial: String::new(),
profundidade_maxima: 3,
maximo_paginas: 50,
concorrencia: 5,
delay: Duration::from_millis(500),
mesmo_dominio: true,
user_agent: "RustCrawler/0.1 (educacional)".to_string(),
}
}
}
impl ConfigCrawler {
pub fn new(url_inicial: &str) -> Self {
Self {
url_inicial: url_inicial.to_string(),
..Default::default()
}
}
}
Passo 2: O Extrator de Links
O extrator analisa o HTML das paginas e encontra todos os links, resolvendo URLs relativos para absolutos.
Crie o arquivo src/extrator.rs:
use url::Url;
/// Resultado da extracao de links de uma pagina
#[derive(Debug)]
pub struct ResultadoExtracao {
pub titulo: Option<String>,
pub links: Vec<String>,
}
/// Extrai links de conteudo HTML
pub fn extrair_links(html: &str, url_base: &str) -> ResultadoExtracao {
let base = match Url::parse(url_base) {
Ok(u) => u,
Err(_) => {
return ResultadoExtracao {
titulo: None,
links: Vec::new(),
}
}
};
let titulo = extrair_titulo(html);
let links = extrair_hrefs(html)
.into_iter()
.filter_map(|href| resolver_url(&base, &href))
.filter(|url| {
// Filtra apenas HTTP/HTTPS
url.starts_with("http://") || url.starts_with("https://")
})
.filter(|url| {
// Remove fragmentos e URLs indesejados
!url.contains('#')
&& !url.ends_with(".pdf")
&& !url.ends_with(".jpg")
&& !url.ends_with(".png")
&& !url.ends_with(".gif")
&& !url.ends_with(".zip")
})
.collect();
ResultadoExtracao { titulo, links }
}
/// Extrai o titulo da pagina (<title>)
fn extrair_titulo(html: &str) -> Option<String> {
let html_lower = html.to_lowercase();
let inicio = html_lower.find("<title>")?;
let fim = html_lower.find("</title>")?;
if inicio < fim {
let titulo = &html[inicio + 7..fim];
Some(titulo.trim().to_string())
} else {
None
}
}
/// Extrai todos os valores de href das tags <a>
fn extrair_hrefs(html: &str) -> Vec<String> {
let mut hrefs = Vec::new();
let mut pos = 0;
let html_bytes = html.as_bytes();
while pos < html_bytes.len() {
// Procura por href=" ou href='
if let Some(offset) = encontrar_href(&html[pos..]) {
let inicio_href = pos + offset;
let (href, fim) = extrair_valor_atributo(&html[inicio_href..]);
if !href.is_empty() {
hrefs.push(href);
}
pos = inicio_href + fim;
} else {
break;
}
}
hrefs
}
/// Encontra a posicao de "href=" no texto
fn encontrar_href(texto: &str) -> Option<usize> {
let texto_lower = texto.to_lowercase();
texto_lower.find("href=").map(|pos| pos + 5)
}
/// Extrai o valor de um atributo HTML (entre aspas)
fn extrair_valor_atributo(texto: &str) -> (String, usize) {
let bytes = texto.as_bytes();
if bytes.is_empty() {
return (String::new(), 0);
}
let (delimitador, inicio) = match bytes[0] {
b'"' => (b'"', 1),
b'\'' => (b'\'', 1),
_ => {
// Sem aspas: vai ate o proximo espaco ou >
let mut fim = 0;
while fim < bytes.len() && bytes[fim] != b' ' && bytes[fim] != b'>' {
fim += 1;
}
let valor = texto[..fim].to_string();
return (valor, fim);
}
};
let mut fim = inicio;
while fim < bytes.len() && bytes[fim] != delimitador {
fim += 1;
}
let valor = texto[inicio..fim].to_string();
(valor, fim + 1)
}
/// Resolve uma URL relativa contra a URL base
fn resolver_url(base: &Url, href: &str) -> Option<String> {
let href = href.trim();
if href.is_empty() || href.starts_with("javascript:") || href.starts_with("mailto:") {
return None;
}
match base.join(href) {
Ok(mut url) => {
url.set_fragment(None);
Some(url.to_string())
}
Err(_) => None,
}
}
#[cfg(test)]
mod testes {
use super::*;
#[test]
fn testar_extrair_titulo() {
let html = "<html><head><title>Minha Pagina</title></head></html>";
assert_eq!(extrair_titulo(html), Some("Minha Pagina".to_string()));
}
#[test]
fn testar_extrair_links() {
let html = r#"<a href="/sobre">Sobre</a> <a href="https://exemplo.com">Ex</a>"#;
let resultado = extrair_links(html, "https://meusite.com/pagina");
assert_eq!(resultado.links.len(), 2);
assert!(resultado.links.contains(&"https://meusite.com/sobre".to_string()));
assert!(resultado.links.contains(&"https://exemplo.com".to_string()));
}
#[test]
fn testar_ignorar_javascript() {
let html = r#"<a href="javascript:void(0)">Nada</a>"#;
let resultado = extrair_links(html, "https://meusite.com");
assert!(resultado.links.is_empty());
}
}
Passo 3: O Motor do Crawler
O motor gerencia a fila de URLs, coordena as tarefas concorrentes e coleta as estatisticas.
Crie o arquivo src/crawler.rs:
use crate::config::ConfigCrawler;
use crate::extrator;
use std::collections::{HashSet, VecDeque};
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
use tokio::time::sleep;
use url::Url;
/// Item na fila de crawling
#[derive(Debug, Clone)]
struct ItemFila {
url: String,
profundidade: u32,
}
/// Informacao coletada de uma pagina
#[derive(Debug)]
pub struct PaginaColetada {
pub url: String,
pub titulo: Option<String>,
pub profundidade: u32,
pub links_encontrados: usize,
pub tamanho_bytes: usize,
}
/// Estatisticas finais do crawling
#[derive(Debug)]
pub struct Estatisticas {
pub paginas_visitadas: usize,
pub total_links: usize,
pub duracao: std::time::Duration,
pub paginas: Vec<PaginaColetada>,
}
/// Estado compartilhado entre as tarefas
struct EstadoCompartilhado {
visitados: HashSet<String>,
fila: VecDeque<ItemFila>,
paginas: Vec<PaginaColetada>,
total_links: usize,
}
/// Executa o crawler com a configuracao fornecida
pub async fn executar(config: ConfigCrawler) -> Estatisticas {
let inicio = Instant::now();
let dominio_base = Url::parse(&config.url_inicial)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()));
let estado = Arc::new(Mutex::new(EstadoCompartilhado {
visitados: HashSet::new(),
fila: VecDeque::new(),
paginas: Vec::new(),
total_links: 0,
}));
// Adiciona a URL inicial a fila
{
let mut e = estado.lock().await;
e.fila.push_back(ItemFila {
url: config.url_inicial.clone(),
profundidade: 0,
});
e.visitados.insert(config.url_inicial.clone());
}
let cliente = reqwest::Client::builder()
.user_agent(&config.user_agent)
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("Falha ao criar cliente HTTP");
let semaforo = Arc::new(tokio::sync::Semaphore::new(config.concorrencia));
let mut tarefas = Vec::new();
loop {
// Verifica se ja atingiu o limite de paginas
let tamanho_atual = {
let e = estado.lock().await;
e.paginas.len()
};
if tamanho_atual >= config.maximo_paginas {
break;
}
// Pega o proximo item da fila
let item = {
let mut e = estado.lock().await;
e.fila.pop_front()
};
let item = match item {
Some(item) => item,
None => {
// Fila vazia: espera as tarefas pendentes
if tarefas.is_empty() {
break;
}
// Espera um pouco e tenta novamente
sleep(std::time::Duration::from_millis(100)).await;
// Coleta tarefas completadas
let mut novas_tarefas = Vec::new();
for tarefa in tarefas {
let handle: tokio::task::JoinHandle<()> = tarefa;
if handle.is_finished() {
let _ = handle.await;
} else {
novas_tarefas.push(handle);
}
}
tarefas = novas_tarefas;
continue;
}
};
let permissao = semaforo.clone().acquire_owned().await.unwrap();
let cliente_clone = cliente.clone();
let estado_clone = estado.clone();
let config_clone = config.clone();
let dominio_clone = dominio_base.clone();
let tarefa = tokio::spawn(async move {
let _permissao = permissao; // mantida ate o fim da tarefa
// Delay educado
sleep(config_clone.delay).await;
// Faz a requisicao
let resultado = processar_pagina(
&cliente_clone,
&item.url,
item.profundidade,
&config_clone,
dominio_clone.as_deref(),
)
.await;
match resultado {
Ok((pagina, novos_links)) => {
let mut e = estado_clone.lock().await;
// So adiciona se ainda nao atingiu o limite
if e.paginas.len() < config_clone.maximo_paginas {
println!(
"[{}/{}] {} - {}",
e.paginas.len() + 1,
config_clone.maximo_paginas,
pagina.titulo.as_deref().unwrap_or("(sem titulo)"),
pagina.url
);
e.total_links += pagina.links_encontrados;
e.paginas.push(pagina);
// Adiciona novos links a fila
for link in novos_links {
if !e.visitados.contains(&link) {
e.visitados.insert(link.clone());
e.fila.push_back(ItemFila {
url: link,
profundidade: item.profundidade + 1,
});
}
}
}
}
Err(erro) => {
eprintln!("[ERRO] {} - {}", item.url, erro);
}
}
});
tarefas.push(tarefa);
}
// Espera todas as tarefas terminarem
for tarefa in tarefas {
let _ = tarefa.await;
}
let e = estado.lock().await;
Estatisticas {
paginas_visitadas: e.paginas.len(),
total_links: e.total_links,
duracao: inicio.elapsed(),
paginas: e.paginas.iter().map(|p| PaginaColetada {
url: p.url.clone(),
titulo: p.titulo.clone(),
profundidade: p.profundidade,
links_encontrados: p.links_encontrados,
tamanho_bytes: p.tamanho_bytes,
}).collect(),
}
}
/// Processa uma unica pagina
async fn processar_pagina(
cliente: &reqwest::Client,
url_str: &str,
profundidade: u32,
config: &ConfigCrawler,
dominio_base: Option<&str>,
) -> Result<(PaginaColetada, Vec<String>), String> {
let resposta = cliente
.get(url_str)
.send()
.await
.map_err(|e| format!("Requisicao falhou: {}", e))?;
if !resposta.status().is_success() {
return Err(format!("Status HTTP: {}", resposta.status()));
}
// Verifica se e HTML
let content_type = resposta
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if !content_type.contains("text/html") {
return Err("Conteudo nao e HTML".to_string());
}
let corpo = resposta
.text()
.await
.map_err(|e| format!("Erro ao ler corpo: {}", e))?;
let tamanho = corpo.len();
let resultado = extrator::extrair_links(&corpo, url_str);
// Filtra links por dominio se necessario
let links_filtrados: Vec<String> = if config.mesmo_dominio {
resultado
.links
.into_iter()
.filter(|link| {
if let (Some(dominio), Ok(url)) = (&dominio_base, Url::parse(link)) {
url.host_str().map_or(false, |h| h == dominio.as_ref())
} else {
false
}
})
.collect()
} else {
resultado.links
};
// Filtra por profundidade
let novos_links = if profundidade < config.profundidade_maxima {
links_filtrados
} else {
Vec::new()
};
let pagina = PaginaColetada {
url: url_str.to_string(),
titulo: resultado.titulo,
profundidade,
links_encontrados: novos_links.len(),
tamanho_bytes: tamanho,
};
Ok((pagina, novos_links))
}
Passo 4: Juntando Tudo no main.rs
mod config;
mod crawler;
mod extrator;
use config::ConfigCrawler;
use std::env;
use std::time::Duration;
#[tokio::main]
async fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
println!("=== Web Crawler em Rust ===");
println!("Uso: {} <url> [opcoes]", args[0]);
println!();
println!("Opcoes:");
println!(" --profundidade N Profundidade maxima (padrao: 3)");
println!(" --paginas N Maximo de paginas (padrao: 50)");
println!(" --concorrencia N Tarefas simultaneas (padrao: 5)");
println!(" --delay MS Delay em ms entre requisicoes (padrao: 500)");
println!(" --todos-dominios Seguir links para outros dominios");
println!();
println!("Exemplo:");
println!(" {} https://www.rust-lang.org --profundidade 2 --paginas 20", args[0]);
return;
}
let mut config = ConfigCrawler::new(&args[1]);
// Processa argumentos opcionais
let mut i = 2;
while i < args.len() {
match args[i].as_str() {
"--profundidade" => {
i += 1;
if let Some(val) = args.get(i) {
config.profundidade_maxima = val.parse().unwrap_or(3);
}
}
"--paginas" => {
i += 1;
if let Some(val) = args.get(i) {
config.maximo_paginas = val.parse().unwrap_or(50);
}
}
"--concorrencia" => {
i += 1;
if let Some(val) = args.get(i) {
config.concorrencia = val.parse().unwrap_or(5);
}
}
"--delay" => {
i += 1;
if let Some(val) = args.get(i) {
let ms: u64 = val.parse().unwrap_or(500);
config.delay = Duration::from_millis(ms);
}
}
"--todos-dominios" => {
config.mesmo_dominio = false;
}
_ => {
eprintln!("Opcao desconhecida: {}", args[i]);
}
}
i += 1;
}
println!("=== Web Crawler em Rust ===");
println!("URL inicial: {}", config.url_inicial);
println!("Profundidade: {}", config.profundidade_maxima);
println!("Max paginas: {}", config.maximo_paginas);
println!("Concorrencia: {}", config.concorrencia);
println!("Delay: {:?}", config.delay);
println!("Mesmo dominio: {}", config.mesmo_dominio);
println!("---");
println!();
let estatisticas = crawler::executar(config).await;
println!();
println!("=== Relatorio Final ===");
println!("Paginas visitadas: {}", estatisticas.paginas_visitadas);
println!("Total de links: {}", estatisticas.total_links);
println!("Duracao: {:.2?}", estatisticas.duracao);
println!();
// Tabela de paginas
println!("{:<5} {:<60} {:<10} {:<8}", "Prof", "URL", "Links", "Bytes");
println!("{}", "-".repeat(85));
for pagina in &estatisticas.paginas {
let url_curta = if pagina.url.len() > 58 {
format!("{}...", &pagina.url[..55])
} else {
pagina.url.clone()
};
println!(
"{:<5} {:<60} {:<10} {:<8}",
pagina.profundidade,
url_curta,
pagina.links_encontrados,
pagina.tamanho_bytes
);
}
}
Como Executar
# Compilar
cargo build --release
# Crawl basico
cargo run -- https://www.rust-lang.org --profundidade 2 --paginas 10
# Exemplo de saida:
=== Web Crawler em Rust ===
URL inicial: https://www.rust-lang.org
Profundidade: 2
Max paginas: 10
Concorrencia: 5
Delay: 500ms
Mesmo dominio: true
---
[1/10] Rust Programming Language - https://www.rust-lang.org/
[2/10] Install Rust - https://www.rust-lang.org/tools/install
[3/10] Learn Rust - https://www.rust-lang.org/learn
...
=== Relatorio Final ===
Paginas visitadas: 10
Total de links: 127
Duracao: 8.32s
# Executar os testes
cargo test
Desafios para Expandir
- Respeitar robots.txt: Antes de fazer crawling em um dominio, baixe e analise o arquivo
robots.txtpara respeitar as regras do site. - Exportar resultados: Salve os resultados em formatos como CSV, JSON ou um sitemap XML, permitindo analise posterior.
- Detector de links quebrados: Alem de coletar paginas, identifique links que retornam 404 ou outros erros, gerando um relatorio de links quebrados.
- Cache de paginas: Armazene as paginas baixadas em disco com um sistema de cache baseado em hash da URL, evitando re-downloads em execucoes subsequentes.
- Rate limiting por dominio: Quando
--todos-dominiosestiver ativo, mantenha filas separadas por dominio com delays individuais para nao sobrecarregar nenhum servidor.
Veja Tambem
- HashSet: Conjuntos — controle de URLs visitadas
- Vec: Vetores Dinamicos — filas e listas de resultados
- Channels para Comunicacao — comunicacao entre tarefas
- Como Fazer Requisicao HTTP — uso de reqwest
- Async/Await em Profundidade — programacao assincrona