Web Crawler em Rust

Construa um web crawler concorrente em Rust com reqwest e tokio, incluindo fronteira de URLs, extração de links, controle de profundidade e crawling educado.

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

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

  1. Respeitar robots.txt: Antes de fazer crawling em um dominio, baixe e analise o arquivo robots.txt para respeitar as regras do site.
  2. Exportar resultados: Salve os resultados em formatos como CSV, JSON ou um sitemap XML, permitindo analise posterior.
  3. Detector de links quebrados: Alem de coletar paginas, identifique links que retornam 404 ou outros erros, gerando um relatorio de links quebrados.
  4. 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.
  5. Rate limiting por dominio: Quando --todos-dominios estiver ativo, mantenha filas separadas por dominio com delays individuais para nao sobrecarregar nenhum servidor.

Veja Tambem