use crate::platform;
use anyhow::{Context, Result};
use rand::seq::SliceRandom;
use reqwest::{
header::{HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE},
redirect::Policy,
Client,
};
use serde::Deserialize;
use std::time::Duration;
const USER_AGENTS_PADRAO: &[&str] = &[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.3800.97",
"Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
];
#[derive(Debug, Clone, Deserialize)]
struct AgenteTomlExterno {
ua: String,
#[serde(default = "plataforma_any")]
platform: String,
}
fn plataforma_any() -> String {
"any".to_string()
}
#[derive(Debug, Clone, Deserialize)]
struct ArquivoUserAgents {
#[serde(default)]
agents: Vec<AgenteTomlExterno>,
}
pub fn carregar_user_agents(corresponde_plataforma: bool) -> Vec<String> {
let Some(caminho) = platform::caminho_user_agents_toml() else {
tracing::debug!("sem diretório de config — usando UAs embutidos");
return uas_padrao_como_vec();
};
let conteudo = match std::fs::read_to_string(&caminho) {
Ok(c) => c,
Err(erro) => {
tracing::info!(
caminho = %caminho.display(),
?erro,
"user-agents.toml não encontrado — usando UAs embutidos"
);
return uas_padrao_como_vec();
}
};
let arquivo: ArquivoUserAgents = match toml::from_str(&conteudo) {
Ok(a) => a,
Err(erro) => {
tracing::warn!(
caminho = %caminho.display(),
?erro,
"user-agents.toml inválido — usando UAs embutidos"
);
return uas_padrao_como_vec();
}
};
let plataforma_atual = platform::nome_plataforma();
let filtrados: Vec<String> = arquivo
.agents
.into_iter()
.filter(|a| {
if !corresponde_plataforma {
return true;
}
a.platform == "any" || a.platform == plataforma_atual
})
.map(|a| a.ua)
.filter(|ua| !ua.is_empty())
.collect();
if filtrados.is_empty() {
tracing::warn!("user-agents.toml não produziu nenhum UA aplicável — usando defaults");
return uas_padrao_como_vec();
}
tracing::info!(
caminho = %caminho.display(),
total = filtrados.len(),
corresponde_plataforma,
"User-Agents carregados de user-agents.toml externo"
);
filtrados
}
fn uas_padrao_como_vec() -> Vec<String> {
USER_AGENTS_PADRAO.iter().map(|s| s.to_string()).collect()
}
pub fn escolher_user_agent() -> String {
let mut rng = rand::thread_rng();
USER_AGENTS_PADRAO
.choose(&mut rng)
.copied()
.unwrap_or(USER_AGENTS_PADRAO[0])
.to_string()
}
pub fn escolher_user_agent_da_lista(lista: &[String]) -> String {
let mut rng = rand::thread_rng();
lista
.choose(&mut rng)
.cloned()
.unwrap_or_else(escolher_user_agent)
}
pub fn selecionar_user_agent_aleatorio(excluindo: Option<&str>) -> String {
let mut rng = rand::thread_rng();
let candidatos: Vec<&&str> = USER_AGENTS_PADRAO
.iter()
.filter(|ua| match excluindo {
Some(excl) => **ua != excl,
None => true,
})
.collect();
if candidatos.is_empty() {
return escolher_user_agent();
}
candidatos
.choose(&mut rng)
.copied()
.copied()
.unwrap_or(USER_AGENTS_PADRAO[0])
.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfiguracaoProxy {
Nenhum,
Desabilitado,
Url(String),
}
impl ConfiguracaoProxy {
pub fn a_partir_de(proxy: Option<&str>, sem_proxy: bool) -> Self {
if sem_proxy {
return Self::Desabilitado;
}
match proxy {
Some(u) if !u.is_empty() => Self::Url(u.to_string()),
_ => Self::Nenhum,
}
}
pub fn esta_ativo(&self) -> bool {
matches!(self, Self::Url(_))
}
}
pub fn construir_cliente(
user_agent: &str,
timeout_segundos: u64,
idioma: &str,
pais: &str,
) -> Result<Client> {
construir_cliente_com_proxy(
user_agent,
timeout_segundos,
idioma,
pais,
&ConfiguracaoProxy::Nenhum,
)
}
pub fn construir_cliente_com_proxy(
user_agent: &str,
timeout_segundos: u64,
idioma: &str,
pais: &str,
proxy: &ConfiguracaoProxy,
) -> Result<Client> {
let headers = headers_padrao(idioma, pais).context("falha ao montar headers default")?;
let mut builder = Client::builder()
.user_agent(user_agent)
.default_headers(headers)
.cookie_store(true)
.gzip(true)
.brotli(true)
.redirect(Policy::limited(5))
.timeout(Duration::from_secs(timeout_segundos));
match proxy {
ConfiguracaoProxy::Nenhum => {
}
ConfiguracaoProxy::Desabilitado => {
builder = builder.no_proxy();
tracing::info!("proxy explicitamente desabilitado via --no-proxy");
}
ConfiguracaoProxy::Url(url) => {
let parseada = reqwest::Url::parse(url)
.with_context(|| format!("URL de proxy inválida: {url:?}"))?;
let user = parseada.username().to_string();
let senha = parseada
.password()
.map(|s| s.to_string())
.unwrap_or_default();
let mut proxy_rq = reqwest::Proxy::all(url)
.with_context(|| format!("falha ao configurar Proxy::all({url:?})"))?;
if !user.is_empty() {
proxy_rq = proxy_rq.basic_auth(&user, &senha);
}
builder = builder.proxy(proxy_rq);
tracing::info!(
host = parseada.host_str(),
scheme = parseada.scheme(),
"proxy configurado"
);
}
}
let cliente = builder
.build()
.context("falha ao construir reqwest::Client com rustls-tls")?;
Ok(cliente)
}
fn headers_padrao(idioma: &str, pais: &str) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("text/html, */*;q=0.5"));
let pais_upper = pais.to_ascii_uppercase();
let idioma_lower = idioma.to_ascii_lowercase();
let accept_language = format!("{idioma_lower}-{pais_upper}");
let accept_language_value = HeaderValue::from_str(&accept_language)
.context("Accept-Language contém caracteres inválidos")?;
headers.insert(ACCEPT_LANGUAGE, accept_language_value);
Ok(headers)
}
#[cfg(test)]
mod testes {
use super::*;
#[test]
fn escolher_user_agent_retorna_string_nao_vazia() {
let ua = escolher_user_agent();
assert!(!ua.is_empty());
}
#[test]
fn escolher_user_agent_retorna_ua_moderno_do_pool() {
let ua = escolher_user_agent();
assert!(
USER_AGENTS_PADRAO.contains(&ua.as_str()),
"UA selecionado deve estar na lista padrão: {ua}"
);
assert!(
ua.starts_with("Mozilla/5.0 ("),
"UAs padrão v0.3.0 iniciam com 'Mozilla/5.0 (' (browser real): {ua}"
);
}
#[test]
fn pool_padrao_contem_browsers_modernos_em_todas_as_familias() {
let pool = USER_AGENTS_PADRAO;
assert!(
pool.iter().any(|ua| ua.contains("Chrome/")),
"pool deve conter ao menos um Chrome"
);
assert!(
pool.iter().any(|ua| ua.contains("Firefox/")),
"pool deve conter ao menos um Firefox"
);
assert!(
pool.iter().any(|ua| ua.contains("Edg/")),
"pool deve conter ao menos um Edge"
);
assert!(
pool.iter()
.any(|ua| ua.contains("Safari/") && !ua.contains("Chrome/")),
"pool deve conter ao menos um Safari puro"
);
}
#[test]
fn pool_padrao_nao_contem_browsers_de_texto_removidos() {
for ua in USER_AGENTS_PADRAO {
assert!(!ua.contains("Lynx"), "UA banido detectado (Lynx): {ua}");
assert!(!ua.contains("w3m"), "UA banido detectado (w3m): {ua}");
assert!(
!ua.starts_with("Links ("),
"UA banido detectado (Links): {ua}"
);
assert!(!ua.contains("ELinks"), "UA banido detectado (ELinks): {ua}");
assert!(
!ua.starts_with("duckduckgo-search-cli"),
"UA banido detectado (self-cli): {ua}"
);
assert_ne!(
*ua, "Mozilla/5.0",
"UA minimalista 'Mozilla/5.0' deve ter sido removido"
);
}
assert!(!USER_AGENTS_PADRAO.is_empty(), "pool nunca pode ser vazio");
}
#[test]
fn selecionar_user_agent_aleatorio_sem_exclusao_retorna_valido() {
let ua = selecionar_user_agent_aleatorio(None);
assert!(!ua.is_empty());
}
#[test]
fn selecionar_user_agent_aleatorio_evita_excluido_quando_possivel() {
let excluido = USER_AGENTS_PADRAO[0];
for _ in 0..20 {
let ua = selecionar_user_agent_aleatorio(Some(excluido));
assert_ne!(ua, excluido, "rotação deve evitar UA excluído");
assert!(!ua.is_empty());
}
}
#[test]
fn construir_cliente_com_valores_validos_funciona() {
let cliente = construir_cliente("Mozilla/5.0 teste", 15, "pt", "br");
assert!(cliente.is_ok(), "cliente deve ser construído sem erro");
}
#[test]
fn construir_cliente_com_proxy_http_funciona() {
let proxy = ConfiguracaoProxy::Url("http://user:pass@proxy.local:8080".to_string());
let cliente = construir_cliente_com_proxy("Mozilla/5.0", 10, "pt", "br", &proxy);
assert!(cliente.is_ok(), "cliente com proxy HTTP deve construir");
}
#[test]
fn construir_cliente_com_proxy_socks5_funciona() {
let proxy = ConfiguracaoProxy::Url("socks5://127.0.0.1:9050".to_string());
let cliente = construir_cliente_com_proxy("Mozilla/5.0", 10, "pt", "br", &proxy);
assert!(cliente.is_ok(), "cliente com SOCKS5 deve construir");
}
#[test]
fn construir_cliente_com_no_proxy_funciona() {
let proxy = ConfiguracaoProxy::Desabilitado;
let cliente = construir_cliente_com_proxy("Mozilla/5.0", 10, "pt", "br", &proxy);
assert!(cliente.is_ok(), "cliente com no_proxy deve construir");
}
#[test]
fn construir_cliente_com_proxy_url_invalida_falha() {
let proxy = ConfiguracaoProxy::Url("nao eh uma url".to_string());
let cliente = construir_cliente_com_proxy("Mozilla/5.0", 10, "pt", "br", &proxy);
assert!(cliente.is_err(), "URL inválida deve rejeitar");
}
#[test]
fn configuracao_proxy_a_partir_de_flags() {
assert_eq!(
ConfiguracaoProxy::a_partir_de(None, false),
ConfiguracaoProxy::Nenhum
);
assert_eq!(
ConfiguracaoProxy::a_partir_de(None, true),
ConfiguracaoProxy::Desabilitado
);
assert_eq!(
ConfiguracaoProxy::a_partir_de(Some("http://x:9"), false),
ConfiguracaoProxy::Url("http://x:9".to_string())
);
assert_eq!(
ConfiguracaoProxy::a_partir_de(Some("http://x:9"), true),
ConfiguracaoProxy::Desabilitado
);
}
#[test]
fn configuracao_proxy_esta_ativo_so_em_url() {
assert!(!ConfiguracaoProxy::Nenhum.esta_ativo());
assert!(!ConfiguracaoProxy::Desabilitado.esta_ativo());
assert!(ConfiguracaoProxy::Url("http://x".to_string()).esta_ativo());
}
#[test]
fn headers_padrao_inclui_accept_e_idioma() {
let headers = headers_padrao("pt", "br").expect("deve montar headers");
let accept = headers.get(ACCEPT).expect("ACCEPT presente");
assert!(accept.to_str().unwrap().contains("text/html"));
let al = headers
.get(ACCEPT_LANGUAGE)
.expect("ACCEPT_LANGUAGE presente");
assert_eq!(al.to_str().unwrap(), "pt-BR");
}
#[test]
fn headers_padrao_omite_dnt_e_referer() {
let headers = headers_padrao("en", "us").expect("deve montar headers");
assert!(headers.get(reqwest::header::DNT).is_none());
assert!(headers.get(reqwest::header::REFERER).is_none());
}
#[test]
fn carregar_user_agents_retorna_pelo_menos_um_default() {
let lista = carregar_user_agents(false);
assert!(!lista.is_empty(), "lista de UAs nunca deve ser vazia");
for ua in &lista {
assert!(!ua.is_empty());
}
}
#[test]
fn escolher_user_agent_da_lista_retorna_item_da_lista() {
let lista = vec!["A".to_string(), "B".to_string(), "C".to_string()];
for _ in 0..10 {
let escolhido = escolher_user_agent_da_lista(&lista);
assert!(lista.contains(&escolhido));
}
}
}