22 KiB
Plano de Implementação: Navegador Realista em Rust (chromiumoxide)
Contexto do projeto
O backend/src (Rust + Actix) já possui:
browser.rs(≈585 linhas): launcherchromiumoxidedescartável. Cada chamada cria um novoBrowser::launchcomtempdirpróprio, viewport aleatório (4 tamanhos),--lang=pt-BR,--proxy-server=+ CDPpage.authenticate(...)quando proxy habilitado, bloqueio de recursos pesados, scroll-until-stable viawindow.scrollTo, warmup antes do alvo e pacing aleatório entre sessões.proxy.rs(≈275 linhas):ProxyConfigresidencial (DataImpulse por padrão), rotação delegada ao gateway (__cr.{country}, semsessid.).http_client.rs(≈533 linhas): sessãoreqwestdescartável com UA Chrome aleatório (v131–137),Accept-Languagept-BR,sec-ch-ua, sec-fetch-* headers, cookie jar por sessão, sem reuso de pool.crawler.rs,youtube.rs,search.rs: todos consumemBrowserModule(o HTML do Google/YouTube vem pelo Chromium, nunca pelo reqwest). Detecção de desafio é apenas informativa (retornaAppError::External).media.rs: downloads binários (thumbnails / og:image) via reqwest+proxy — único ponto onde o reqwest pode tocar hospedagens do Google comoi.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 131–137). 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
- Consistência dentro da sessão. Tudo deriva de uma única
Personasorteada no início: UA, plataforma, resolução, idioma, vendor WebGL, vendor de áudio. Nada contradiz nada. - 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".
- 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.
- 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.
- Isolamento por persona. Uma persona = um processo Chromium = um
--user-data-dirtemporário = um IP de proxy. IP só muda entre personas. - 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_renderercontémApple M1ouIntel Iris,sec_ch_ua_platform == "macOS", UA contemMacintosh; Intel Mac OS X. - Se
platform == "Win32"→ UAWindows NT 10.0; Win64; x64,webgl_rendererrefereANGLE (Intel/NVIDIA/AMD). - Se
platform == "Linux x86_64"→ UAX11; Linux x86_64,webgl_vendor = "Google Inc. (Intel)". accept_languageetimezonederivam doproxy_countryquando houver (tabela país → idiomas + IANA tz); fallbackpt-BR/America/Sao_Paulo.chrome_majorsorteado de um arrayMAJOR_VERSIONSmantido junto dohttp_client.rs(de forma que as duas pilhas usem a mesma lista — ver Fase 6).
Tarefas:
- Criar
src/persona.rscom a structPersonae a funçãogenerate. - 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;emlib.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 ≈213–241):
--disable-blink-features=AutomationControlled— o Chromium headless novo já omitenavigator.webdriverem muitos builds, mas o arg garante consistência.--disable-features=IsolateOrigins,site-per-processapenas 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-BRfixo). --user-data-dir=<tempdir>— já existe viatempfile; 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:
page.execute(SetUserAgentOverride { user_agent, accept_language, platform })— sobrescreve UA, plataforma e Accept-Language no nível do navegador (equivalente aEmulation.setUserAgentOverride). Garantia:UA do Chromium bate com UA dohttp_clientapenas quando aPersonaé a mesma — esta coerência fica garantida pela Fase 6.page.execute(SetTimezoneOverride { timezone })(viaEmulation.setTimezoneOverride).page.execute(SetGeolocationOverride)opcional, se país do proxy fired.- Confirmar
viewport(já sorteado hoje) agora derivado depersona.screenepersona.device_scale_factor.
Tarefas:
- Expandir
BrowserModuleConfigcomstealth: bool(defaulttrue) que liga/desliga as inconveniências para testes. - Substituir
random_viewport()privada porpersona.viewport()(método empersona.rs). - Aplicar
SetUserAgentOverride+Emulation.setTimezoneOverrideapósnew_page("about:blank"). - Testes: capturar o
navigator.userAgentviapage.evaluateem página de teste e comparar compersona.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)
navigator.webdriver→deletedo getter (defensive; raramente presente mas padroniza).navigator.platform→ defineplatformcomopersona.platform.navigator.languages→[persona.locale, fallback...].navigator.hardwareConcurrency→ define viaObject.defineProperty.navigator.deviceMemory→ idem.navigator.pluginsenavigator.mimeTypes→ reconstrói comPDF Viewer,Chrome PDF Viewer,Chromium PDF Viewer,Microsoft Edge PDF Viewer,WebKit built-in PDF(lista compatível com a plataforma).navigator.language→persona.locale.- Canvas: interceptar
HTMLCanvasElement.prototype.toDataURL,toBlob,CanvasRenderingContext2D.prototype.getImageData. Para cada pixel retornado, aplicar XOR/add de 1 bit em um canal derivado depersona.canvas_seed + índice. Não zerar, não bloquear — apenas perturbar. Hash de canvas estável por sessão, diferente entre sessões. - WebGL: interceptar
WebGLRenderingContext.prototype.getParametereWebGL2RenderingContext.prototype.getParameter. ParaUNMASKED_VENDOR_WEBGL(37445) retornarpersona.webgl_vendor; paraUNMASKED_RENDERER_WEBGL(37446) retornarpersona.webgl_renderer. ParaVENDOR/RENDERERбазы, retornarWebKit/WebKit WebGL. ParaMAX_TEXTURE_SIZEmanter. ParagetShaderPrecisionFormatmanter nativo. - AudioContext: interceptar
AnalyserNode.prototype.getByteFrequencyData,AudioBuffer.prototype.getChannelData,AudioBuffer.prototype.copyFromChannel. Adicionar ruído determinístico de amplitude ~1e-7 derivado depersona.audio_seed+ índice. Não quebrar codecs. screen.width/height/availWidth/availHeight→ coerente compersona.screenepersona.device_scale_factor.Notification.permissionenavigator.permissions.query→ mantêm nativo (não promover).- 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.rscombuild_init_script(persona).- Em
browser.rsfetch_in_browser, chamarpage.execute(AddScriptToEvaluateOnNewDocument { source: build_init_script(&persona) }).await?logo apósnew_page("about:blank")e antes dogoto(warmup). - Testes: carregar
data:text/html,<canvas id=c></canvas><script>…</script>em página, lertoDataURLduas 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)retornapersona.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 50–200px).
- ~30–60 passos ao longo da curva.
page.mouse.move(x, y)por passo.- Pacing por passo: ease-in/ease-out com pequena jitter (5–20ms).
- Micro-tremor: em ~20% dos passos, offset aleatório 1–2px fora da curva, restaurado no passo seguinte.
pub async fn human_click(page, x, y, rng):move_along_bezieraté(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á noCargo.toml).
- Para cada caractere:
4.3 Scroll orgânico
pub async fn organic_scroll(page, rng, rounds):- Para cada rodada:
- Sorteioprand passo aleatório 200–800px.
page.evaluate("window.scrollBy(0, {n})").tokio::sleep(300 + rng.gen_range(0..1500ms)).- Em ~30% das rodadas, um pequeno
scrollByreverso de 50–120px (mão voltou um tiquinho).
- Para cada rodada:
4.4 Navegação de pesquisa
pub async fn perform_search(page, query: &str, rng):move_along_bezierfrom a random corner to the search box (CSS selector especificável via parâmetro).human_clickna 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.rscommove_along_bezier,human_click,human_type,organic_scroll,perform_search.- Função
gaussian(mean, std, rng). - Em
search.rs::fetch_htmlintegrar o motor de comportamento (ativado por flag naSearchConfig::simulate_interaction: bool, defaulttrue). - Testes: rodar
perform_searchcontra uma fixture HTMLdata:text/html,...com um<input>e lerinput.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/srcpor qualquerreqwest::get/reqwest::Client::get/HttpClient::fresh(...).get_*cuja URL sejayoutube.com,google.com,youtu.be,i.ytimg.com,ytimg.googleusercontent.com. Confirmar que sómedia.rs(viacrawler.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 umconstempersona.rs(ou módulo compartilhadochrome_versions). BrowserIdentity::from_persona(persona) -> Selfemhttp_client.rs— constrói oBrowserIdentitya partir da persona.random_pt_br()mantém como legacy fallback.MediaStore::downloade similar continuam usando Chrome nativo, mas agora opcionalmente derivamPersonaidentica quando houver interesse em hr-consistência crosspod (métododownload_with_personaopcional).- Teste de coerência: instalando persona com
chrome_major=125, tantohttp_clientquanto Chromium expõem UA contendoChrome/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-dirpor 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_htmlrecebe um IP novo — coisa quebra a hipótese "IP constante dentro da persona". Para remediar, adicionar àProxyConfigum 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 emproxy.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, fixarsession_idantes deBrowser::launch.
- Teste: dois fetches com a mesma persona usam o mesmo IP egressivo (verificar via
https://api.ipify.orgem 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 |
novo — build_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 |
editar — effective_username_session(session_id) para fixar IP por persona. |
src/http_client.rs |
editar — BrowserIdentity::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 |
editar — pub 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 |
verificar — rand (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
- Fase 1 (
persona.rs) — sem dependências; testável isoladamente. - Fase 6 parte A (
http_client.rs—from_persona) — unifica listas de versão. - Fase 3 (
stealth.rs) — função pura; testável em fixture local. - Fase 2 (
browser.rs) — injetar stealth + override UA/tz. - Fase 4 (
behavior.rs) — funçõeslib; testável emdata:text/html. - Fase 8 (
search.rs/youtube.rs) — integra comportamento ao fluxo. - Fase 7 (
proxy.rs) — fixar IP por persona. - Fase 5 — auditoria (sem código).
Verificação
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo test --workspace(testes unitários depersona,stealth,behaviorcomdata:text/htmlfixtures)cargo test -- --ignoredpara 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 ~3–8 seg por pesquisa) | Flag config.simulate_interaction: bool default true, downgrade em modo bulk. |