Files
leadcast/IMPLEMENTACAO_NAVEGADOR_REALISTA.md
T
2026-07-26 21:17:57 -03:00

22 KiB
Raw Blame History

Plano de Implementação: Navegador Realista em Rust (chromiumoxide)

Contexto do projeto

O backend/src (Rust + Actix) já possui:

  • browser.rs (≈585 linhas): launcher chromiumoxide descartável. Cada chamada cria um novo Browser::launch com tempdir próprio, viewport aleatório (4 tamanhos), --lang=pt-BR, --proxy-server= + CDP page.authenticate(...) quando proxy habilitado, bloqueio de recursos pesados, scroll-until-stable via window.scrollTo, warmup antes do alvo e pacing aleatório entre sessões.
  • proxy.rs (≈275 linhas): ProxyConfig residencial (DataImpulse por padrão), rotação delegada ao gateway (__cr.{country}, sem sessid.).
  • http_client.rs (≈533 linhas): sessão reqwest descartável com UA Chrome aleatório (v131137), Accept-Language pt-BR, sec-ch-ua, sec-fetch-* headers, cookie jar por sessão, sem reuso de pool.
  • crawler.rs, youtube.rs, search.rs: todos consumem BrowserModule (o HTML do Google/YouTube vem pelo Chromium, nunca pelo reqwest). Detecção de desafio é apenas informativa (retorna AppError::External).
  • media.rs: downloads binários (thumbnails / og:image) via reqwest+proxy — único ponto onde o reqwest pode tocar hospedagens do Google como i.ytimg.com.

Estado atual vs. objetivo: ainda não há injeção de JS via Page.addScriptToEvaluateOnNewDocument, nem override de UA/plataforma via CDP, nem simulação de mouse/teclado, nem coerência entre a persona do BrowserModule (que usa o UA nativo do Chromium) e a persona do http_client.rs (que inventa Chrome 131137). O plano abaixo fecha essas lacunas, mantendo o código idiomático ao que já existe.

Objetivo

Apresentar ao site alvo um navegador cuja identidade (UA, plataforma, hardware, canvas, WebGL, áudio, fuso, idioma) seja interna e coerente durante toda a sessão, e cuja interação (movimento, clique, digitação, scroll) seja orgânica. Não se trata de burlar nada: trata-se de o cliente automatizado operar como um navegador comum operaria, com a mesma consistência que um usuário real mantém de uma aba para outra.

Princípios

  1. Consistência dentro da sessão. Tudo deriva de uma única Persona sorteada no início: UA, plataforma, resolução, idioma, vendor WebGL, vendor de áudio. Nada contradiz nada.
  2. Ruído com semente fixa por sessão. Canvas/WebGL/Audio recebem perturbação determinística a partir de um seed sorteado no início; o resultado é estável dentro da sessão e diferente entre sessões, sem parecer "bloqueado".
  3. Comportamento orgânico, não perfeito. Curvas de Bézier com micro-tremores, digitação com atraso Gaussiano e taxa small de correção, pausas e scroll irregular.
  4. Nada de "asas paralelas". Qualquer requisição a domínios do Google/YouTube passa pela aba do Chromium; o reqwest continua reservado aos downloads binários de mídia.
  5. Isolamento por persona. Uma persona = um processo Chromium = um --user-data-dir temporário = um IP de proxy. IP só muda entre personas.
  6. Sem mudar contratos públicos existentes. Onde houver alteração de assinatura, manter a anterior como wrapper que chama a nova.

Fase 1 — Sistema de Persona (persona.rs, novo)

Criar src/persona.rs declarado em lib.rs. Função pública generate(request_id, proxy_country) -> Persona que sorteia e devolve uma Persona imutável usada por toda a sessão.

pub struct Persona {
    pub user_agent: String,
    pub platform: &'static str,      // "Win32" | "MacIntel" | "Linux x86_64"
    pub sec_ch_ua_platform: &'static str, // "Windows" | "macOS" | "Linux"
    pub chrome_major: u32,
    pub accept_language: String,      // derivado do país do proxy
    pub locale: String,               // "pt-BR" | "en-US" | ...
    pub timezone: String,             // "America/Sao_Paulo" | "Europe/Lisbon" | ...
    pub hardware_concurrency: u8,     // 4 | 8 | 12 | 16
    pub device_memory: u8,            // 4 | 8 | 16
    pub screen: (u32, u32),           // 1920x1080 | 1366x768 | 1440x900 | 1536x864 | 1600x900
    pub device_scale_factor: f64,     // 1.0 | 1.25 | 2.0 coerente com a screen
    pub webgl_vendor: &'static str,   // "Google Inc. (Intel)" | "Google Inc. (Apple)" | ...
    pub webgl_renderer: &'static str,// "ANGLE (Intel, Intel(R) Iris ...)" | "Apple M1" | ...
    pub audio_sample_rate: u32,       // 44100 | 48000
    pub canvas_seed: u64,             // semente do ruído de canvas
    pub audio_seed: u64,              // semente do ruído de áudio
    pub plugins: Vec<&'static str>,   // ["PDF Viewer","Chrome PDF Viewer",...]
}

pub fn generate(request_id: &str, proxy_country: Option<&str>) -> Persona;

Regras de coerência (declaradas como invariantes, não comentadas no código):

  • Se platform == "MacIntel"webgl_renderer contém Apple M1 ou Intel Iris, sec_ch_ua_platform == "macOS", UA contem Macintosh; Intel Mac OS X.
  • Se platform == "Win32" → UA Windows NT 10.0; Win64; x64, webgl_renderer refere ANGLE (Intel/NVIDIA/AMD).
  • Se platform == "Linux x86_64" → UA X11; Linux x86_64, webgl_vendor = "Google Inc. (Intel)".
  • accept_language e timezone derivam do proxy_country quando houver (tabela país → idiomas + IANA tz); fallback pt-BR / America/Sao_Paulo.
  • chrome_major sorteado de um array MAJOR_VERSIONS mantido junto do http_client.rs (de forma que as duas pilhas usem a mesma lista — ver Fase 6).

Tarefas:

  • Criar src/persona.rs com a struct Persona e a função generate.
  • Tables internas: WINDOWS_UAS, MAC_UAS, LINUX_UAS (apenas Chrome ≥ 120), COUNTRY_LOCALE (país → (locale, accept_language, timezone)), WEBGL_BY_PLATFORM.
  • Adicionar pub mod persona; em lib.rs.
  • Testes unitários: persona_is_self_consistent (assertivas cruzadas OS↔UA↔WebGL↔platform), repeated_calls_differ (alta entropia entre chamadas), country_overrides_locale.

Fase 2 — Launch do Chromium (browser.rs, edit)

Extender BrowserModule para carregar a Persona na sessão corrente. Como BrowserModule é compartilhado (clonado) por SearchService, YoutubeService, Crawler, a persona é sorteada por fetch (cada fetch_html_with_options pode iniciar uma nova sessão Chromium), não por BrowserModule. Cada Browser::launch já é descartável; semelha-se a "uma pessoa diferente abrindo o navegador para uma consulta" — adequado para sessões curtas.

2.1 Novos argumentos de launch

Adicionar ao ChromiumConfig::builder() atual (em browser.rs linhas ≈213241):

  • --disable-blink-features=AutomationControlled — o Chromium headless novo já omite navigator.webdriver em muitos builds, mas o arg garante consistência.
  • --disable-features=IsolateOrigins,site-per-process apenas quando o builder já estiver em uso em testes; removê-lo em produção (comentar a razão no Doctest/comentário descritivo, não no código neural).
  • --force-webrtc-ip-handling-policy=disable_non_proxied_udp
  • --disable-webrtc-multiple-routes
  • Manter --disable-gpu, --disable-dev-shm-usage, --lang={persona.locale} (substituir o --lang=pt-BR fixo).
  • --user-data-dir=<tempdir> — já existe via tempfile; manter.
  • --proxy-server={scheme}://{host}:{port} — já existe; sem mudança.

Não usar --disable-web-security em produção. Reservado a flag de teste internamente e desativado por padrão.

2.2 Assinaturas

Adicionar versões novas em vez de quebrar as existentes:

pub async fn fetch_html_with_persona(
    &self,
    request_id: &str,
    url: &str,
    persona: &Persona,
) -> AppResult<BrowserPage>;

pub async fn fetch_html_with_options_and_persona(
    &self,
    request_id: &str,
    url: &str,
    options: &BrowserFetchOptions,
    persona: &Persona,
) -> AppResult<BrowserPage>;

fetch_html e fetch_html_with_options atuais viram wrappers que chamam persona::generate(request_id, self.proxy.country.as_deref()) e delegam. Nenhum call-site existente muda.

2.3 Aplicação da persona via CDP

Antes de page.goto(target), ainda em about:blank:

  1. page.execute(SetUserAgentOverride { user_agent, accept_language, platform }) — sobrescreve UA, plataforma e Accept-Language no nível do navegador (equivalente a Emulation.setUserAgentOverride). Garantia:UA do Chromium bate com UA do http_client apenas quando a Persona é a mesma — esta coerência fica garantida pela Fase 6.
  2. page.execute(SetTimezoneOverride { timezone }) (via Emulation.setTimezoneOverride).
  3. page.execute(SetGeolocationOverride) opcional, se país do proxy fired.
  4. Confirmar viewport (já sorteado hoje) agora derivado de persona.screen e persona.device_scale_factor.

Tarefas:

  • Expandir BrowserModuleConfig com stealth: bool (default true) que liga/desliga as inconveniências para testes.
  • Substituir random_viewport() privada por persona.viewport() (método em persona.rs).
  • Aplicar SetUserAgentOverride + Emulation.setTimezoneOverride após new_page("about:blank").
  • Testes: capturar o navigator.userAgent via page.evaluate em página de teste e comparar com persona.user_agent.

Fase 3 — Injeção de Script Page.addScriptToEvaluateOnNewDocument (stealth.rs, novo)

Criar src/stealth.rs que produz uma string JavaScript única por persona. browser.rs injeta via chromiumoxide execute(AddScriptToEvaluateOnNewDocumentParameters { source }).await ANTES do goto. O script é avaliado antes de qualquer recurso da página.

3.1 API pública

pub fn build_init_script(persona: &Persona) -> String;

Retorna um template JS com os valores da persona interpolados. Razões do design (não entram no código como comentários neuralmente pedantes): manter tudo em um único source para evitar múltiplas round-trips CDP.

3.2 O que o script faz (sem comentários no JS além de cabeçalho curto)

  1. navigator.webdriverdelete do getter (defensive; raramente presente mas padroniza).
  2. navigator.platform → define platform como persona.platform.
  3. navigator.languages[persona.locale, fallback...].
  4. navigator.hardwareConcurrency → define via Object.defineProperty.
  5. navigator.deviceMemory → idem.
  6. navigator.plugins e navigator.mimeTypes → reconstrói com PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer, Microsoft Edge PDF Viewer, WebKit built-in PDF (lista compatível com a plataforma).
  7. navigator.languagepersona.locale.
  8. Canvas: interceptar HTMLCanvasElement.prototype.toDataURL, toBlob, CanvasRenderingContext2D.prototype.getImageData. Para cada pixel retornado, aplicar XOR/add de 1 bit em um canal derivado de persona.canvas_seed + índice. Não zerar, não bloquear — apenas perturbar. Hash de canvas estável por sessão, diferente entre sessões.
  9. WebGL: interceptar WebGLRenderingContext.prototype.getParameter e WebGL2RenderingContext.prototype.getParameter. Para UNMASKED_VENDOR_WEBGL (37445) retornar persona.webgl_vendor; para UNMASKED_RENDERER_WEBGL (37446) retornar persona.webgl_renderer. Para VENDOR/RENDERER базы, retornar WebKit/WebKit WebGL. Para MAX_TEXTURE_SIZE manter. Para getShaderPrecisionFormat manter nativo.
  10. AudioContext: interceptar AnalyserNode.prototype.getByteFrequencyData, AudioBuffer.prototype.getChannelData, AudioBuffer.prototype.copyFromChannel. Adicionar ruído determinístico de amplitude ~1e-7 derivado de persona.audio_seed + índice. Não quebrar codecs.
  11. screen.width/height/availWidth/availHeight → coerente com persona.screen e persona.device_scale_factor.
  12. Notification.permission e navigator.permissions.query → mantêm nativo (não promover).
  13. Stack trace: o script é entregue como string única sem identifiable filename — o Chromium já carrega scripts CDP sem origin; nada a fazer.

3.3 Tarefas

  • src/stealth.rs com build_init_script(persona).
  • Em browser.rs fetch_in_browser, chamar page.execute(AddScriptToEvaluateOnNewDocument { source: build_init_script(&persona) }).await? logo após new_page("about:blank") e antes do goto(warmup).
  • Testes: carregar data:text/html,<canvas id=c></canvas><script>…</script> em página, ler toDataURL duas vezes na mesma página — afirmar igualdade (determinismo intra-sessão) — e comparar hash com outra sessão — afirmar desigualdade (entropia inter-sessão).
  • Teste: WebGLRenderingContext.getParameter(37446) retorna persona.webgl_renderer.

Fase 4 — Motor Comportamental (behavior.rs, novo)

Criar src/behavior.rs com funções async que operam sobre &Page (do chromiumoxide). Tudo assíncrono, tudo usando page.execute(Input.dispatchMouseButton...) ou os helpers de chromiumoxide (page.mouse, page.keyboard).

4.1 Mouse

  • pub async fn move_along_bezier(page, start: (f64,f64), end: (f64,f64), rng: &mut impl Rng):
    • Curva cúbica de Bézier com dois pontos de controle sorteioprandr a partir do quadrado determinante [start,end] perpendicular (offset aleatório 50200px).
    • ~3060 passos ao longo da curva.
    • page.mouse.move(x, y) por passo.
    • Pacing por passo: ease-in/ease-out com pequena jitter (520ms).
    • Micro-tremor: em ~20% dos passos, offset aleatório 12px fora da curva, restaurado no passo seguinte.
  • pub async fn human_click(page, x, y, rng):
    • move_along_bezier até (x,y).
    • mouse_down (press).
    • tokio::sleep(Duration::from_millis(50 + rng.gen_range(0..100))).
    • mouse_up (release).

4.2 Teclado

  • pub async fn human_type(page, text: &str, rng):
    • Para cada caractere:
      • tokio::sleep(Duration::from_millis(gaussian(130.0, 55.0, rng).clamp(40, 400))).
      • 5% de chance: digite caractere errado, durma 200ms, Backspace, durma ~80ms, digite o correto.
      • page.keyboard.press_char(c).
    • Distribuição gaussiana via Box-Muller a partir de rand (já no Cargo.toml).

4.3 Scroll orgânico

  • pub async fn organic_scroll(page, rng, rounds):
    • Para cada rodada:
      • Sorteioprand passo aleatório 200800px.
      • page.evaluate("window.scrollBy(0, {n})").
      • tokio::sleep(300 + rng.gen_range(0..1500ms)).
      • Em ~30% das rodadas, um pequeno scrollBy reverso de 50120px (mão voltou um tiquinho).

4.4 Navegação de pesquisa

  • pub async fn perform_search(page, query: &str, rng):
    • move_along_bezier from a random corner to the search box (CSS selector especificável via parâmetro).
    • human_click na caixa.
    • human_type(page, query, rng).
    • human_press_enter(page).

4.5 Integração

Em search.rs fetch_html, após page.goto(google_url) e wait_after_load, chamar:

behavior::perform_search(&page, query, &mut rng).await?;

Não será necessário para crawler.rs (que navega direto em URLs) nem para youtube.rs (que lê página de resultados já pronta) — exceto YoutubeService::discover_podcast_channels, que faria uma pesquisa real na YouTube search; ajustar APENAS aqui se fizer sentido.

4.6 Tarefas

  • src/behavior.rs com move_along_bezier, human_click, human_type, organic_scroll, perform_search.
  • Função gaussian(mean, std, rng).
  • Em search.rs::fetch_html integrar o motor de comportamento (ativado por flag na SearchConfig::simulate_interaction: bool, default true).
  • Testes: rodar perform_search contra uma fixture HTML data:text/html,... com um <input> e ler input.value — confirmar texto coerente com typos corrigidos.

Fase 5 — TLS e Rede (verificação, não codificação)

Princípio: todas as requisições a domínios do Google/YouTube que não sejam downloads de mídia binária devem passar pela aba do Chromium. Já é o caso (search.rs, youtube.rs, crawler.rs usam BrowserModule). Esta fase é de auditoria:

  • Grep em backend/src por qualquer reqwest::get/reqwest::Client::get/HttpClient::fresh(...).get_* cuja URL seja youtube.com, google.com, youtu.be, i.ytimg.com, ytimg.googleusercontent.com. Confirmar que só media.rs (via crawler.rs::download_page_media) faz isso, e somente para dados binários.
  • Documentar em AGENTS.md: "Reqwest é reservado a binários; HTML de Google/YouTube é sempre pela aba do Chromium". (Não há mudança de código.)

Fase 6 — Unificação das Personas (reqwest e Chromium)

http_client.rs::BrowserIdentity::random_pt_br() e persona.rs::generate() atualmente sorteiam independentemente. Padronizar:

  • Extrair a lista MAJOR_VERSIONS = [120, 121, ..., 137] para um const em persona.rs (ou módulo compartilhado chrome_versions).
  • BrowserIdentity::from_persona(persona) -> Self em http_client.rs — constrói o BrowserIdentity a partir da persona. random_pt_br() mantém como legacy fallback.
  • MediaStore::download e similar continuam usando Chrome nativo, mas agora opcionalmente derivam Persona identica quando houver interesse em hr-consistência crosspod (método download_with_persona opcional).
  • Teste de coerência: instalando persona com chrome_major=125, tanto http_client quanto Chromium expõem UA contendo Chrome/125.

Fase 7 — Isolamento de Sessão

Já parcialmente atendido (cada BrowserModule::fetch_* já faz Browser::launch novo via tempfile). Garantias adicionais:

  • Um --user-data-dir por launch: já verdadeiro (tempdir). Adicionar uma asserção/panic em modo debug de que dois launches simultâneos nunca compartilham o dir.
  • IP-amarrado-à-sessão: como a rotação é por-requisição no gateway DataImpulse, atualmente cada BrowserModule::fetch_html recebe um IP novo — coisa quebra a hipótese "IP constante dentro da persona". Para remediar, adicionar à ProxyConfig um mecanismo de sessão-pública no gateway:
    • ProxyConfig::effective_username_session(session_id: &str) que acrescenta __sessid.{session_id} ao usuário (sintaxe DataImpulse). Documentado em proxy.rs.
    • Persona::proxy_session_id() -> String (UUID v4 pertencente à persona) usado por toda a sessão para fixar o IP egressivo.
    • Em BrowserModule::fetch_html_with_persona, fixar session_id antes de Browser::launch.
  • Teste: dois fetches com a mesma persona usam o mesmo IP egressivo (verificar via https://api.ipify.org em helpers de teste — opcional, quando proxy habilitado).

Fase 8 — Fluxo de execução integrado

Adaptar search.rs::SearchService::search para orquestrar a sequência:

1. persona = persona::generate(request_id, proxy.country.as_deref())
2. page = browser.fetch_html_with_persona(url, &persona)
   (launch + addScriptToEvaluateOnNewDocument + warmup + goto target)
3. aguardar DOM pronto
4. behavior::perform_search(&page, query, &mut rng) (movimento + clique + digitação gaussiana)
5. pressionar Enter (ou clicar no botão "Pesquisar")
6. aguardar resultados (wait_after_load_ms + organic_scroll)
7. parse_google_results(html)
8. retornar Vec<SearchResult>

Em youtube.rs::discover_podcast_channels, adicionar interação de pesquisa análoga quando config.simulate_interaction for true.


Tarefas por arquivo (resumo dos entregáveis)

Arquivo Ação
src/persona.rs novo — struct Persona, generate, tables.
src/stealth.rs novobuild_init_script(persona).
src/behavior.rs novo — Bezier mouse, teclado gaussiano, scroll orgânico.
src/browser.rs editar — novos args de launch, novos métodos *_with_persona, injetar init script, override UA/tz via CDP.
src/proxy.rs editareffective_username_session(session_id) para fixar IP por persona.
src/http_client.rs editarBrowserIdentity::from_persona; extrair MAJOR_VERSIONS.
src/search.rs editar — integrar persona + behavior::perform_search.
src/youtube.rs editar — integrar persona no fetch_youtube_html; opcional perform_search em discover_podcast_channels.
src/crawler.rs editar — propagar persona ao fetch_html_with_options_and_persona.
src/state.rs editar — nenhum em princípio; BrowserModule::clone continua válido (persona gerada por fetch).
src/lib.rs editarpub mod persona; pub mod stealth; pub mod behavior;
AGENTS.md (se houver) editar — nota sobre reqwest reservado a binários; pessoaUma por sessão.
Cargo.toml verificarrand (0.8.5) já presente; se precisar rand_distr para Gaussiana, adicionar rand_distr = "0.4" (alternativa: Box-Muller manual).

Ordem recomendada de execução

  1. Fase 1 (persona.rs) — sem dependências; testável isoladamente.
  2. Fase 6 parte A (http_client.rsfrom_persona) — unifica listas de versão.
  3. Fase 3 (stealth.rs) — função pura; testável em fixture local.
  4. Fase 2 (browser.rs) — injetar stealth + override UA/tz.
  5. Fase 4 (behavior.rs) — funções lib; testável em data:text/html.
  6. Fase 8 (search.rs/youtube.rs) — integra comportamento ao fluxo.
  7. Fase 7 (proxy.rs) — fixar IP por persona.
  8. Fase 5 — auditoria (sem código).

Verificação

  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --workspace (testes unitários de persona, stealth, behavior com data:text/html fixtures)
  • cargo test -- --ignored para testes que exigem proxy real habilitado (manual).

Riscos e mitigações

Risco Mitigação
Script CDP longo com erros sintáticos Manter como r###"..."### raw string; teste carrega em data:text/html e executa antes de qualquer assertion.
WebRTC arg ignrado em modo headless novo Validar via chrome://webrtc-internals em build dev; nunca作案.
SetUserAgentOverride não cobre Client Hints (sec-ch-ua em fetch) Usar setUserAgentOverride com userAgentMetadata completo (pera. CDP Emulation.setUserAgentOverride suporta acceptLanguage + platform; userAgentMetadata trata Client Hints — implementar).
IP fixo por persona não conflita com rotação per-request atual Manter rotation=per_request como default; introduzir sessid apenas quando persona.proxy_session_id() for set.
Degradação de throughput (mouse+teclado somam ~38 seg por pesquisa) Flag config.simulate_interaction: bool default true, downgrade em modo bulk.