# 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 (v131–137), `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 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 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. ```rust 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 ≈213–241): - `--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=` — 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: ```rust pub async fn fetch_html_with_persona( &self, request_id: &str, url: &str, persona: &Persona, ) -> AppResult; pub async fn fetch_html_with_options_and_persona( &self, request_id: &str, url: &str, options: &BrowserFetchOptions, persona: &Persona, ) -> AppResult; ``` `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 ```rust 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.webdriver`** → `delete` 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.language`** → `persona.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,` 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 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_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 200–800px. - `page.evaluate("window.scrollBy(0, {n})")`. - `tokio::sleep(300 + rng.gen_range(0..1500ms))`. - Em ~30% das rodadas, um pequeno `scrollBy` reverso de 50–120px (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: ```rust 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 `` 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 ``` 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 1. **Fase 1** (`persona.rs`) — sem dependências; testável isoladamente. 2. **Fase 6 parte A** (`http_client.rs` — `from_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 ~3–8 seg por pesquisa) | Flag `config.simulate_interaction: bool` default `true`, downgrade em modo bulk. |