first commit

This commit is contained in:
gustavooth
2026-07-26 21:12:38 -03:00
commit e6badb0d79
133 changed files with 38368 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
# 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.
```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 ≈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:
```rust
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
```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,<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:
```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 `<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` | **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 ~38 seg por pesquisa) | Flag `config.simulate_interaction: bool` default `true`, downgrade em modo bulk. |