1pub mod cli;
36pub mod config_init;
37pub mod content;
38pub mod error;
39pub mod extraction;
40pub mod fetch_conteudo;
41pub mod http;
42pub mod output;
43pub mod parallel;
44pub mod paths;
45pub mod pipeline;
46pub mod platform;
47pub mod search;
48pub mod selectors;
49pub mod signals;
50pub mod types;
51
52#[cfg(feature = "chrome")]
54pub mod browser;
55
56use crate::cli::{
57 ArgumentosCli, ArgumentosInitConfig, ArgumentosRaiz, EndpointCli, FiltroTemporalCli,
58 SafeSearchCli, Subcomando,
59};
60use crate::error::exit_codes;
61use crate::types::{Configuracoes, Endpoint, FiltroTemporal, FormatoSaida, SafeSearch};
62use anyhow::{Context, Result};
63use clap::Parser;
64use tokio_util::sync::CancellationToken;
65use tracing_subscriber::{fmt, EnvFilter};
66
67pub async fn run(cancelamento: CancellationToken) -> i32 {
71 let raiz = ArgumentosRaiz::parse();
73
74 let argumentos = match raiz.subcomando {
76 Some(Subcomando::InitConfig(args)) => {
77 return executar_init_config(args);
78 }
79 Some(Subcomando::Buscar(args)) => *args,
80 None => raiz.buscar,
81 };
82
83 inicializar_logging(argumentos.verboso, argumentos.silencioso);
85
86 platform::iniciar();
88
89 let configuracoes = match montar_configuracoes(&argumentos) {
91 Ok(c) => c,
92 Err(erro) => {
93 tracing::error!(?erro, "Configuração inválida");
94 eprintln!("Erro de configuração: {erro:#}");
95 return exit_codes::CONFIGURACAO_INVALIDA;
96 }
97 };
98
99 let formato = configuracoes.formato;
100 let arquivo_saida = configuracoes.arquivo_saida.clone();
101 let timeout_global = std::time::Duration::from_secs(configuracoes.timeout_global_segundos);
102
103 let cancelamento_interno = cancelamento.clone();
106 let futuro_pipeline = pipeline::executar_pipeline(configuracoes, cancelamento_interno);
107
108 let resultado_pipeline = match tokio::time::timeout(timeout_global, futuro_pipeline).await {
109 Ok(resultado) => resultado,
110 Err(_elapsed) => {
111 cancelamento.cancel();
113 tracing::error!(
114 segundos = timeout_global.as_secs(),
115 "timeout global excedido — execução abortada"
116 );
117 eprintln!(
118 "Erro: timeout global de {}s excedido",
119 timeout_global.as_secs()
120 );
121 return exit_codes::TIMEOUT_GLOBAL;
122 }
123 };
124
125 match resultado_pipeline {
126 Ok(resultado) => {
127 let total = resultado.total_resultados();
128 let codigo_saida = if total == 0 {
129 tracing::warn!("Zero resultados retornados em todas as queries");
130 exit_codes::ZERO_RESULTADOS
131 } else {
132 exit_codes::SUCESSO
133 };
134
135 if let Err(erro) =
136 output::emitir_resultado(&resultado, formato, arquivo_saida.as_deref())
137 {
138 if output::eh_broken_pipe(&erro) {
139 return exit_codes::SUCESSO;
142 }
143 tracing::error!(?erro, "Falha ao emitir resultado");
144 eprintln!("Erro ao escrever output: {erro:#}");
145 return exit_codes::ERRO_GENERICO;
146 }
147
148 codigo_saida
149 }
150 Err(erro) => {
151 tracing::error!(?erro, "Falha na execução do pipeline");
152 eprintln!("Erro: {erro:#}");
153 exit_codes::ERRO_GENERICO
154 }
155 }
156}
157
158fn executar_init_config(args: ArgumentosInitConfig) -> i32 {
163 inicializar_logging(false, false);
165 platform::iniciar();
166
167 let relatorio = match config_init::inicializar_config(args.forcar, args.dry_run) {
168 Ok(r) => r,
169 Err(erro) => {
170 tracing::error!(?erro, "falha ao inicializar config");
171 eprintln!("Erro: {erro:#}");
172 return exit_codes::ERRO_GENERICO;
173 }
174 };
175
176 match serde_json::to_string_pretty(&relatorio) {
177 Ok(json) => {
178 if let Err(erro) = output::imprimir_linha_stdout(&json) {
179 if output::eh_broken_pipe(&erro) {
180 return exit_codes::SUCESSO;
181 }
182 tracing::error!(?erro, "falha ao emitir relatório");
183 return exit_codes::ERRO_GENERICO;
184 }
185 }
186 Err(erro) => {
187 tracing::error!(?erro, "falha ao serializar relatório JSON");
188 return exit_codes::ERRO_GENERICO;
189 }
190 }
191
192 let houve_erro = relatorio
194 .arquivos
195 .iter()
196 .any(|a| matches!(a.acao, crate::config_init::AcaoArquivoConfig::Erro { .. }));
197 if houve_erro {
198 return exit_codes::ERRO_GENERICO;
199 }
200
201 exit_codes::SUCESSO
202}
203
204fn inicializar_logging(verboso: bool, silencioso: bool) {
210 let filtro = if silencioso {
211 EnvFilter::new("error")
212 } else if verboso {
213 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"))
214 } else {
215 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
216 };
217
218 let subscriber = fmt()
220 .with_env_filter(filtro)
221 .with_writer(std::io::stderr)
222 .with_target(false)
223 .compact()
224 .finish();
225
226 let _ = tracing::subscriber::set_global_default(subscriber);
228}
229
230fn montar_configuracoes(argumentos: &ArgumentosCli) -> Result<Configuracoes> {
236 let formato = FormatoSaida::a_partir_de_str(&argumentos.formato)
237 .with_context(|| format!("formato desconhecido: {:?}", argumentos.formato))?;
238
239 argumentos
240 .validar_paralelismo()
241 .map_err(|e| anyhow::anyhow!(e))?;
242 argumentos
243 .validar_paginas()
244 .map_err(|e| anyhow::anyhow!(e))?;
245 argumentos
246 .validar_retries()
247 .map_err(|e| anyhow::anyhow!(e))?;
248 argumentos
249 .validar_max_tamanho_conteudo()
250 .map_err(|e| anyhow::anyhow!(e))?;
251 argumentos
252 .validar_global_timeout()
253 .map_err(|e| anyhow::anyhow!(e))?;
254 argumentos.validar_proxy().map_err(|e| anyhow::anyhow!(e))?;
255 argumentos
256 .validar_limite_por_host()
257 .map_err(|e| anyhow::anyhow!(e))?;
258 argumentos
259 .validar_timeout_segundos()
260 .map_err(|e| anyhow::anyhow!(e))?;
261 if let Some(caminho) = &argumentos.arquivo_saida {
262 crate::paths::validar_caminho_saida(caminho)?;
263 }
264
265 let queries_arquivo = match &argumentos.arquivo_queries {
266 Some(caminho) => pipeline::ler_queries_de_arquivo(caminho)
267 .with_context(|| format!("falha ao processar --queries-file {}", caminho.display()))?,
268 None => Vec::new(),
269 };
270
271 let queries_stdin = if argumentos.queries.is_empty() && argumentos.arquivo_queries.is_none() {
274 pipeline::ler_queries_de_stdin_se_pipe().context("falha ao ler queries de stdin")?
275 } else {
276 Vec::new()
277 };
278
279 let queries = pipeline::combinar_e_deduplicar_queries(
280 argumentos.queries.clone(),
281 queries_arquivo,
282 queries_stdin,
283 );
284
285 if queries.is_empty() {
286 anyhow::bail!(
287 "nenhuma query fornecida (argumentos posicionais, --queries-file ou stdin vazios)"
288 );
289 }
290
291 let primeira = queries[0].clone();
292
293 let lista_uas = http::carregar_user_agents(argumentos.corresponde_plataforma_ua);
295 let perfil_browser = http::escolher_perfil_da_lista(&lista_uas);
296 let user_agent = perfil_browser.user_agent.clone();
297
298 let seletores = selectors::carregar_seletores();
300
301 let num_efetivo = argumentos.num_resultados.unwrap_or(15);
313 let paginas_efetivas = if argumentos.paginas > 1 {
314 argumentos.paginas
315 } else if num_efetivo > 10 {
316 num_efetivo.div_ceil(10).min(5)
317 } else {
318 1
319 };
320
321 Ok(Configuracoes {
322 query: primeira,
323 queries,
324 num_resultados: Some(num_efetivo),
325 formato,
326 timeout_segundos: argumentos.timeout_segundos,
327 idioma: argumentos.idioma.clone(),
328 pais: argumentos.pais.clone(),
329 modo_verboso: argumentos.verboso,
330 modo_silencioso: argumentos.silencioso,
331 user_agent,
332 perfil_browser,
333 paralelismo: argumentos.paralelismo,
334 paginas: paginas_efetivas,
335 retries: argumentos.retries,
336 endpoint: converter_endpoint(argumentos.endpoint),
337 filtro_temporal: argumentos.filtro_temporal.map(converter_filtro_temporal),
338 safe_search: converter_safe_search(argumentos.safe_search),
339 modo_stream: argumentos.modo_stream,
340 arquivo_saida: argumentos.arquivo_saida.clone(),
341 buscar_conteudo: argumentos.buscar_conteudo,
342 max_tamanho_conteudo: argumentos.max_tamanho_conteudo,
343 proxy: argumentos.proxy.clone(),
344 sem_proxy: argumentos.sem_proxy,
345 timeout_global_segundos: argumentos.timeout_global_segundos,
346 corresponde_plataforma_ua: argumentos.corresponde_plataforma_ua,
347 limite_por_host: argumentos.limite_por_host as usize,
348 caminho_chrome: argumentos.caminho_chrome.clone(),
349 seletores,
350 })
351}
352
353fn converter_endpoint(origem: EndpointCli) -> Endpoint {
355 match origem {
356 EndpointCli::Html => Endpoint::Html,
357 EndpointCli::Lite => Endpoint::Lite,
358 }
359}
360
361fn converter_filtro_temporal(origem: FiltroTemporalCli) -> FiltroTemporal {
363 match origem {
364 FiltroTemporalCli::D => FiltroTemporal::Dia,
365 FiltroTemporalCli::W => FiltroTemporal::Semana,
366 FiltroTemporalCli::M => FiltroTemporal::Mes,
367 FiltroTemporalCli::Y => FiltroTemporal::Ano,
368 }
369}
370
371fn converter_safe_search(origem: SafeSearchCli) -> SafeSearch {
373 match origem {
374 SafeSearchCli::Off => SafeSearch::Off,
375 SafeSearchCli::Moderate => SafeSearch::Moderate,
376 SafeSearchCli::On => SafeSearch::Strict,
377 }
378}
379
380#[cfg(test)]
381mod testes {
382 use super::*;
383
384 fn argumentos_base() -> ArgumentosCli {
385 ArgumentosCli {
386 queries: vec!["rust async".to_string()],
387 num_resultados: Some(5),
388 formato: "json".to_string(),
389 arquivo_saida: None,
390 timeout_segundos: 15,
391 idioma: "pt".to_string(),
392 pais: "br".to_string(),
393 paralelismo: 5,
394 arquivo_queries: None,
395 paginas: 1,
396 retries: 2,
397 endpoint: EndpointCli::Html,
398 filtro_temporal: None,
399 safe_search: SafeSearchCli::Moderate,
400 modo_stream: false,
401 verboso: false,
402 silencioso: false,
403 buscar_conteudo: false,
404 max_tamanho_conteudo: crate::cli::MAX_CONTENT_LENGTH_PADRAO,
405 proxy: None,
406 sem_proxy: false,
407 timeout_global_segundos: crate::cli::GLOBAL_TIMEOUT_PADRAO,
408 corresponde_plataforma_ua: false,
409 limite_por_host: crate::cli::PER_HOST_LIMIT_PADRAO,
410 caminho_chrome: None,
411 }
412 }
413
414 #[test]
415 fn montar_configuracoes_com_argumentos_validos() {
416 let argumentos = argumentos_base();
417 let cfg = montar_configuracoes(&argumentos).expect("deve montar configurações");
418 assert_eq!(cfg.query, "rust async");
419 assert_eq!(cfg.queries, vec!["rust async".to_string()]);
420 assert_eq!(cfg.formato, FormatoSaida::Json);
421 assert_eq!(cfg.num_resultados, Some(5));
422 assert_eq!(cfg.paralelismo, 5);
423 assert_eq!(cfg.paginas, 1);
424 assert!(!cfg.modo_stream);
425 }
426
427 #[test]
428 fn montar_configuracoes_rejeita_queries_todas_vazias() {
429 let mut argumentos = argumentos_base();
430 argumentos.queries = vec![" ".to_string(), "".to_string()];
431 let resultado = montar_configuracoes(&argumentos);
432 assert!(resultado.is_err());
433 }
434
435 #[test]
436 fn montar_configuracoes_rejeita_formato_desconhecido() {
437 let mut argumentos = argumentos_base();
438 argumentos.formato = "xml".to_string();
439 assert!(montar_configuracoes(&argumentos).is_err());
440 }
441
442 #[test]
443 fn montar_configuracoes_rejeita_paralelismo_zero() {
444 let mut argumentos = argumentos_base();
445 argumentos.paralelismo = 0;
446 assert!(montar_configuracoes(&argumentos).is_err());
447 }
448
449 #[test]
450 fn montar_configuracoes_rejeita_paralelismo_acima_do_maximo() {
451 let mut argumentos = argumentos_base();
452 argumentos.paralelismo = 50;
453 assert!(montar_configuracoes(&argumentos).is_err());
454 }
455
456 #[test]
457 fn montar_configuracoes_aplica_default_num_15_quando_omitido() {
458 let mut argumentos = argumentos_base();
461 argumentos.num_resultados = None;
462 argumentos.paginas = 1;
463 let cfg = montar_configuracoes(&argumentos).expect("deve montar");
464 assert_eq!(cfg.num_resultados, Some(15), "default 15 quando None");
465 assert_eq!(cfg.paginas, 2, "auto-eleva para ceil(15/10) = 2");
466 }
467
468 #[test]
469 fn montar_configuracoes_respeita_pages_explicito_acima_de_1() {
470 let mut argumentos = argumentos_base();
473 argumentos.num_resultados = Some(20);
474 argumentos.paginas = 3;
475 let cfg = montar_configuracoes(&argumentos).expect("deve montar");
476 assert_eq!(cfg.num_resultados, Some(20));
477 assert_eq!(cfg.paginas, 3, "respeita --pages explícito do usuário");
478 }
479
480 #[test]
481 fn montar_configuracoes_auto_pagina_quando_num_maior_que_10() {
482 let casos = [
484 (11u32, 2u32), (15, 2), (20, 2), (21, 3), (45, 5), (60, 5), ];
491 for (num, paginas_esperadas) in casos {
492 let mut argumentos = argumentos_base();
493 argumentos.num_resultados = Some(num);
494 argumentos.paginas = 1;
495 let cfg = montar_configuracoes(&argumentos)
496 .unwrap_or_else(|e| panic!("deve montar para num={num}: {e}"));
497 assert_eq!(
498 cfg.paginas, paginas_esperadas,
499 "para num={num}, paginas deveria ser {paginas_esperadas}"
500 );
501 }
502 }
503
504 #[test]
505 fn montar_configuracoes_nao_auto_pagina_quando_num_10_ou_menos() {
506 for num in [1u32, 5, 10] {
508 let mut argumentos = argumentos_base();
509 argumentos.num_resultados = Some(num);
510 argumentos.paginas = 1;
511 let cfg = montar_configuracoes(&argumentos).expect("deve montar");
512 assert_eq!(cfg.paginas, 1, "num={num} não deveria auto-paginar");
513 }
514 }
515
516 #[test]
517 fn montar_configuracoes_combina_multiplas_queries_posicionais() {
518 let mut argumentos = argumentos_base();
519 argumentos.queries = vec![
520 "alfa".to_string(),
521 "beta".to_string(),
522 "alfa".to_string(), "gama".to_string(),
524 ];
525 let cfg = montar_configuracoes(&argumentos).expect("deve montar configurações");
526 assert_eq!(cfg.queries, vec!["alfa", "beta", "gama"]);
527 assert_eq!(cfg.query, "alfa");
528 }
529}