pub mod cli;
pub mod config_init;
pub mod content;
pub mod error;
pub mod extraction;
pub mod fetch_conteudo;
pub mod http;
pub mod output;
pub mod parallel;
pub mod pipeline;
pub mod platform;
pub mod search;
pub mod selectors;
pub mod types;
#[cfg(feature = "chrome")]
pub mod browser;
use crate::cli::{
ArgumentosCli, ArgumentosInitConfig, ArgumentosRaiz, EndpointCli, FiltroTemporalCli,
SafeSearchCli, Subcomando,
};
use crate::error::exit_codes;
use crate::types::{Configuracoes, Endpoint, FiltroTemporal, FormatoSaida, SafeSearch};
use anyhow::{Context, Result};
use clap::Parser;
use tokio_util::sync::CancellationToken;
use tracing_subscriber::{fmt, EnvFilter};
pub async fn run(cancelamento: CancellationToken) -> i32 {
let raiz = ArgumentosRaiz::parse();
let argumentos = match raiz.subcomando {
Some(Subcomando::InitConfig(args)) => {
return executar_init_config(args);
}
Some(Subcomando::Buscar(args)) => *args,
None => raiz.buscar,
};
inicializar_logging(argumentos.verboso, argumentos.silencioso);
platform::iniciar();
let configuracoes = match montar_configuracoes(&argumentos) {
Ok(c) => c,
Err(erro) => {
tracing::error!(?erro, "Configuração inválida");
eprintln!("Erro de configuração: {erro:#}");
return exit_codes::CONFIGURACAO_INVALIDA;
}
};
let formato = configuracoes.formato;
let arquivo_saida = configuracoes.arquivo_saida.clone();
let timeout_global = std::time::Duration::from_secs(configuracoes.timeout_global_segundos);
let cancelamento_interno = cancelamento.clone();
let futuro_pipeline = pipeline::executar_pipeline(configuracoes, cancelamento_interno);
let resultado_pipeline = match tokio::time::timeout(timeout_global, futuro_pipeline).await {
Ok(resultado) => resultado,
Err(_elapsed) => {
cancelamento.cancel();
tracing::error!(
segundos = timeout_global.as_secs(),
"timeout global excedido — execução abortada"
);
eprintln!(
"Erro: timeout global de {}s excedido",
timeout_global.as_secs()
);
return exit_codes::TIMEOUT_GLOBAL;
}
};
match resultado_pipeline {
Ok(resultado) => {
let total = resultado.total_resultados();
let codigo_saida = if total == 0 {
tracing::warn!("Zero resultados retornados em todas as queries");
exit_codes::ZERO_RESULTADOS
} else {
exit_codes::SUCESSO
};
if let Err(erro) =
output::emitir_resultado(&resultado, formato, arquivo_saida.as_deref())
{
tracing::error!(?erro, "Falha ao emitir resultado");
eprintln!("Erro ao escrever output: {erro:#}");
return exit_codes::ERRO_GENERICO;
}
codigo_saida
}
Err(erro) => {
tracing::error!(?erro, "Falha na execução do pipeline");
eprintln!("Erro: {erro:#}");
exit_codes::ERRO_GENERICO
}
}
}
fn executar_init_config(args: ArgumentosInitConfig) -> i32 {
inicializar_logging(false, false);
platform::iniciar();
let relatorio = match config_init::inicializar_config(args.forcar, args.dry_run) {
Ok(r) => r,
Err(erro) => {
tracing::error!(?erro, "falha ao inicializar config");
eprintln!("Erro: {erro:#}");
return exit_codes::ERRO_GENERICO;
}
};
match serde_json::to_string_pretty(&relatorio) {
Ok(json) => {
if let Err(erro) = output::imprimir_linha_stdout(&json) {
tracing::error!(?erro, "falha ao emitir relatório");
return exit_codes::ERRO_GENERICO;
}
}
Err(erro) => {
tracing::error!(?erro, "falha ao serializar relatório JSON");
return exit_codes::ERRO_GENERICO;
}
}
let houve_erro = relatorio
.arquivos
.iter()
.any(|a| matches!(a.acao, crate::config_init::AcaoArquivoConfig::Erro { .. }));
if houve_erro {
return exit_codes::ERRO_GENERICO;
}
exit_codes::SUCESSO
}
fn inicializar_logging(verboso: bool, silencioso: bool) {
let filtro = if silencioso {
EnvFilter::new("error")
} else if verboso {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"))
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
};
let subscriber = fmt()
.with_env_filter(filtro)
.with_writer(std::io::stderr)
.with_target(false)
.compact()
.finish();
let _ = tracing::subscriber::set_global_default(subscriber);
}
fn montar_configuracoes(argumentos: &ArgumentosCli) -> Result<Configuracoes> {
let formato = FormatoSaida::a_partir_de_str(&argumentos.formato)
.with_context(|| format!("formato desconhecido: {:?}", argumentos.formato))?;
argumentos
.validar_paralelismo()
.map_err(|e| anyhow::anyhow!(e))?;
argumentos
.validar_paginas()
.map_err(|e| anyhow::anyhow!(e))?;
argumentos
.validar_retries()
.map_err(|e| anyhow::anyhow!(e))?;
argumentos
.validar_max_tamanho_conteudo()
.map_err(|e| anyhow::anyhow!(e))?;
argumentos
.validar_global_timeout()
.map_err(|e| anyhow::anyhow!(e))?;
argumentos.validar_proxy().map_err(|e| anyhow::anyhow!(e))?;
argumentos
.validar_limite_por_host()
.map_err(|e| anyhow::anyhow!(e))?;
let queries_arquivo = match &argumentos.arquivo_queries {
Some(caminho) => pipeline::ler_queries_de_arquivo(caminho)
.with_context(|| format!("falha ao processar --queries-file {}", caminho.display()))?,
None => Vec::new(),
};
let queries_stdin = if argumentos.queries.is_empty() && argumentos.arquivo_queries.is_none() {
pipeline::ler_queries_de_stdin_se_pipe().context("falha ao ler queries de stdin")?
} else {
Vec::new()
};
let queries = pipeline::combinar_e_deduplicar_queries(
argumentos.queries.clone(),
queries_arquivo,
queries_stdin,
);
if queries.is_empty() {
anyhow::bail!(
"nenhuma query fornecida (argumentos posicionais, --queries-file ou stdin vazios)"
);
}
let primeira = queries[0].clone();
let lista_uas = http::carregar_user_agents(argumentos.corresponde_plataforma_ua);
let user_agent = http::escolher_user_agent_da_lista(&lista_uas);
let seletores = selectors::carregar_seletores();
Ok(Configuracoes {
query: primeira,
queries,
num_resultados: argumentos.num_resultados,
formato,
timeout_segundos: argumentos.timeout_segundos,
idioma: argumentos.idioma.clone(),
pais: argumentos.pais.clone(),
modo_verboso: argumentos.verboso,
modo_silencioso: argumentos.silencioso,
user_agent,
paralelismo: argumentos.paralelismo,
paginas: argumentos.paginas,
retries: argumentos.retries,
endpoint: converter_endpoint(argumentos.endpoint),
filtro_temporal: argumentos.filtro_temporal.map(converter_filtro_temporal),
safe_search: converter_safe_search(argumentos.safe_search),
modo_stream: argumentos.modo_stream,
arquivo_saida: argumentos.arquivo_saida.clone(),
buscar_conteudo: argumentos.buscar_conteudo,
max_tamanho_conteudo: argumentos.max_tamanho_conteudo,
proxy: argumentos.proxy.clone(),
sem_proxy: argumentos.sem_proxy,
timeout_global_segundos: argumentos.timeout_global_segundos,
corresponde_plataforma_ua: argumentos.corresponde_plataforma_ua,
limite_por_host: argumentos.limite_por_host as usize,
caminho_chrome: argumentos.caminho_chrome.clone(),
seletores,
})
}
fn converter_endpoint(origem: EndpointCli) -> Endpoint {
match origem {
EndpointCli::Html => Endpoint::Html,
EndpointCli::Lite => Endpoint::Lite,
}
}
fn converter_filtro_temporal(origem: FiltroTemporalCli) -> FiltroTemporal {
match origem {
FiltroTemporalCli::D => FiltroTemporal::Dia,
FiltroTemporalCli::W => FiltroTemporal::Semana,
FiltroTemporalCli::M => FiltroTemporal::Mes,
FiltroTemporalCli::Y => FiltroTemporal::Ano,
}
}
fn converter_safe_search(origem: SafeSearchCli) -> SafeSearch {
match origem {
SafeSearchCli::Off => SafeSearch::Off,
SafeSearchCli::Moderate => SafeSearch::Moderate,
SafeSearchCli::On => SafeSearch::Strict,
}
}
#[cfg(test)]
mod testes {
use super::*;
fn argumentos_base() -> ArgumentosCli {
ArgumentosCli {
queries: vec!["rust async".to_string()],
num_resultados: Some(5),
formato: "json".to_string(),
arquivo_saida: None,
timeout_segundos: 15,
idioma: "pt".to_string(),
pais: "br".to_string(),
paralelismo: 5,
arquivo_queries: None,
paginas: 1,
retries: 2,
endpoint: EndpointCli::Html,
filtro_temporal: None,
safe_search: SafeSearchCli::Moderate,
modo_stream: false,
verboso: false,
silencioso: false,
buscar_conteudo: false,
max_tamanho_conteudo: crate::cli::MAX_CONTENT_LENGTH_PADRAO,
proxy: None,
sem_proxy: false,
timeout_global_segundos: crate::cli::GLOBAL_TIMEOUT_PADRAO,
corresponde_plataforma_ua: false,
limite_por_host: crate::cli::PER_HOST_LIMIT_PADRAO,
caminho_chrome: None,
}
}
#[test]
fn montar_configuracoes_com_argumentos_validos() {
let argumentos = argumentos_base();
let cfg = montar_configuracoes(&argumentos).expect("deve montar configurações");
assert_eq!(cfg.query, "rust async");
assert_eq!(cfg.queries, vec!["rust async".to_string()]);
assert_eq!(cfg.formato, FormatoSaida::Json);
assert_eq!(cfg.num_resultados, Some(5));
assert_eq!(cfg.paralelismo, 5);
assert_eq!(cfg.paginas, 1);
assert!(!cfg.modo_stream);
}
#[test]
fn montar_configuracoes_rejeita_queries_todas_vazias() {
let mut argumentos = argumentos_base();
argumentos.queries = vec![" ".to_string(), "".to_string()];
let resultado = montar_configuracoes(&argumentos);
assert!(resultado.is_err());
}
#[test]
fn montar_configuracoes_rejeita_formato_desconhecido() {
let mut argumentos = argumentos_base();
argumentos.formato = "xml".to_string();
assert!(montar_configuracoes(&argumentos).is_err());
}
#[test]
fn montar_configuracoes_rejeita_paralelismo_zero() {
let mut argumentos = argumentos_base();
argumentos.paralelismo = 0;
assert!(montar_configuracoes(&argumentos).is_err());
}
#[test]
fn montar_configuracoes_rejeita_paralelismo_acima_do_maximo() {
let mut argumentos = argumentos_base();
argumentos.paralelismo = 50;
assert!(montar_configuracoes(&argumentos).is_err());
}
#[test]
fn montar_configuracoes_combina_multiplas_queries_posicionais() {
let mut argumentos = argumentos_base();
argumentos.queries = vec![
"alfa".to_string(),
"beta".to_string(),
"alfa".to_string(), "gama".to_string(),
];
let cfg = montar_configuracoes(&argumentos).expect("deve montar configurações");
assert_eq!(cfg.queries, vec!["alfa", "beta", "gama"]);
assert_eq!(cfg.query, "alfa");
}
}