eoka 0.5.8

Stealth browser automation for Rust. Puppeteer/Playwright alternative with anti-bot bypass.
Documentation

eoka

crates.io docs.rs CI

Stealth browser automation in Rust. Passes bot detection without the bloat.

Requirements

Chrome or Chromium installed. eoka launches and controls it via CDP.

Install

[dependencies]
eoka = "0.5"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

Quick Start

use eoka::{Browser, Result};

#[tokio::main]
async fn main() -> Result<()> {
    let browser = Browser::launch().await?;
    let page = browser.new_page("https://example.com").await?;

    page.human_click("#button").await?;
    page.human_type("#input", "hello").await?;

    let png = page.screenshot().await?;
    std::fs::write("screenshot.png", png)?;

    browser.close().await?;
    Ok(())
}

Login Flow Example

use eoka::{Browser, Result};

#[tokio::main]
async fn main() -> Result<()> {
    let browser = Browser::launch().await?;
    let page = browser.new_page("https://example.com/login").await?;

    page.try_click_by_text("Accept Cookies").await?;
    page.human_click_by_text("Sign In").await?;
    page.wait_for_visible("#email", 10_000).await?;
    page.human_fill("#email", "user@example.com").await?;
    page.human_fill("#password", "secret123").await?;
    page.human_click_by_text("Log In").await?;
    page.wait_for_text("Welcome back", 15_000).await?;

    browser.close().await?;
    Ok(())
}

API

Browser

let browser = Browser::launch().await?;
let browser = Browser::launch_visible().await?;
let browser = Browser::launch_debug().await?;
let browser = Browser::launch_with(|config| {
    config.proxy = Some("http://127.0.0.1:8080".into());
}).await?;
let browser = Browser::launch_with_config(config).await?;
let page = browser.new_page("https://example.com").await?;
let tabs = browser.tabs().await?;
browser.activate_tab(id).await?;
browser.close_tab(id).await?;
browser.close().await?;

Finding Elements

page.find("#button").await?;                    // CSS selector
page.find_all(".item").await?;                  // all matches
page.find_by_text("Sign In").await?;            // by visible text
page.find_any(&["#email", "[name='email']"]).await?; // first match
page.exists("#popup").await;                    // bool
page.text_exists("Error").await;                // bool

Clicking

page.click("#button").await?;                   // instant
page.human_click("#button").await?;             // with mouse movement
page.click_by_text("Submit").await?;
page.human_click_by_text("Submit").await?;
page.try_click("#optional").await?;             // Ok(false) if missing
page.try_click_by_text("Accept").await?;

Typing

page.fill("#email", "user@example.com").await?;     // clear + type
page.human_fill("#email", "user@example.com").await?; // human-like
page.type_into("#search", "query").await?;           // append (no clear)
page.human_type("#search", "query").await?;

Waiting

page.wait_for("#results", 10_000).await?;           // in DOM
page.wait_for_visible("#email", 10_000).await?;     // visible + clickable
page.wait_for_hidden(".loading", 5_000).await?;
page.wait_for_any(&["#ok", ".error"], 10_000).await?;
page.wait_for_text("Success", 10_000).await?;
page.wait_for_url_contains("dashboard", 10_000).await?;
page.wait_for_url_change(10_000).await?;
page.wait_for_network_idle(500, 30_000).await?;     // XHR/fetch idle

Elements

let elem = page.find("#btn").await?;
elem.click().await?;
elem.is_visible().await?;           // Result<bool>
elem.bounding_box().await;          // Option<BoundingBox>
elem.get_attribute("href").await?;  // Option<String>
elem.tag_name().await?;
elem.value().await?;
elem.text().await?;
elem.is_enabled().await?;
elem.is_checked().await?;
elem.css("color").await?;
elem.scroll_into_view().await?;

Keyboard, Hover, Select, Upload

page.press_key("Enter").await?;
page.press_key("Ctrl+A").await?;
page.select_all().await?;
page.copy().await?;
page.paste().await?;

page.hover("#menu").await?;
page.human_hover("#menu").await?;

page.select("#country", "US").await?;
page.select_by_text("#country", "United States").await?;

page.upload_file("input[type='file']", "/path/to/file.pdf").await?;
page.upload_files("input[type='file']", &["/a.pdf", "/b.pdf"]).await?;

JavaScript & Frames

let count: i32 = page.evaluate("document.querySelectorAll('li').length").await?;
page.execute("window.scrollTo(0, 1000)").await?;
let title: String = page.evaluate_in_frame("iframe#widget", "document.title").await?;

Page Info & Debug

page.url().await?;
page.title().await?;
page.content().await?;              // full HTML
page.text().await?;                 // visible text
page.screenshot().await?;           // PNG bytes
page.screenshot_jpeg(80).await?;    // JPEG at quality 80
page.debug_state().await?;          // PageState with element counts
page.debug_screenshot("step1").await?; // timestamped screenshot

Multi-Tab

let page1 = browser.new_page("https://a.com").await?;
let page2 = browser.new_page("https://b.com").await?;
browser.activate_tab(page1.target_id()).await?;
browser.close_tab(page2.target_id()).await?;

Connect to an existing Chrome

Attach to a browser you already launched with --remote-debugging-port instead of spawning one. Defaults to StealthConfig::live(), so the user's tabs are left untouched (no evasion injection).

// Start Chrome yourself: chrome --remote-debugging-port=9222
let browser = Browser::connect_port(9222).await?;           // HTTP discovery
// or, with the ws:// URL from http://localhost:9222/json/version:
let browser = Browser::connect("ws://127.0.0.1:9222/devtools/browser/...").await?;

for tab in browser.tabs().await? {
    println!("{} — {}", tab.title, tab.url);
}
let page = browser.attach_page(&target_id).await?;
browser.disconnect().await?;                                // leaves Chrome running

Navigation

page.goto("https://example.com").await?;
page.goto_with_referrer("https://example.com", "https://google.com").await?;
page.goto_with_headers("https://example.com", headers).await?;
page.reload().await?;
page.back().await?;
page.forward().await?;

Network & Cookies

let cookies = page.cookies().await?;
page.set_cookie("name", "value", Some("example.com"), None).await?;
page.delete_cookie("name", None).await?;
page.clear_all_cookies().await?;

page.enable_request_capture().await?;       // start capturing XHR/fetch
let body = page.get_response_body(id).await?;
page.disable_request_capture().await?;

Configuration & Dialogs

page.set_bypass_csp(true).await?;           // disable CSP
page.set_javascript_enabled(false).await?;  // disable JS entirely (before navigation)
page.set_user_agent("custom UA").await?;
page.ignore_cert_errors(true).await?;
page.accept_dialog(None).await?;            // accept alert/confirm
page.dismiss_dialog().await?;               // dismiss dialog

page.ignore_cert_errors takes effect only for navigations issued after the call — too late for new_page(url)'s own initial navigate. For a bad cert on the very first load (e.g. an .onion service redirecting http:// → untrusted https://), set StealthConfig.ignore_cert_errors = true instead, or use new_blank_page() + ignore_cert_errors(true) + goto().

Retry

page.with_retry(3, 500, || async {
    page.human_click("#flaky").await
}).await?;

Config

Most code does not need to construct a config. Browser::launch() starts with stealth defaults in headless mode. Browser::launch_visible() and Browser::launch_debug() are explicit opt-ins for local workflows that need a window.

For small tweaks, mutate the default config inline:

let browser = Browser::launch_with(|config| {
    config.headless = false;
    config.proxy = Some("http://127.0.0.1:8080".into());
}).await?;

Two server-side reputation signals are handled automatically for spawned stealth browsers (see StealthConfig):

  • strip_x_client_data: true (default) — Chrome's X-Client-Data request header (field-trial variation IDs) is stripped via CDP Fetch interception on every page, and --disable-field-trial-config is passed at launch.
  • geo_align: false — opt-in. Aligns timezone and navigator.languages with the browser's apparent public IP via a one-shot lookup before the first real navigation. Ignored when timezone is set explicitly.

For reusable presets or advanced setup, build a StealthConfig directly:

let config = StealthConfig {
    headless: false,
    patch_binary: true,
    human_mouse: true,
    human_typing: true,
    debug: true,
    ..Default::default()
};

let browser = Browser::launch_with_config(config).await?;

// Presets for advanced config composition
StealthConfig::visible()   // headless: false
StealthConfig::debug()     // headless: false, debug: true

Detection Results

Patches Chrome binary, injects 15 evasion scripts, blocks detectable CDP commands at the transport layer, simulates human input with Bezier curves.

  • Passes: sannysoft, rebrowser bot detector (6/6), areyouheadless, browserleaks
  • Partial: creepjs (33% trust score)

How it Works

~9K lines of Rust (about 1.3K of that is tests). No chromiumoxide, no puppeteer-extra. Hand-written CDP types for the ~30 commands actually needed. Minimal dependency graph.

src/
├── cdp/           # raw websocket transport, command filtering
├── stealth/       # evasions, binary patcher, human simulation
├── browser.rs     # chrome launcher
├── page.rs        # page api
└── session.rs     # cookie export

The key insight: most detection comes from CDP commands leaking (Runtime.enable fires consoleAPICalled events that pages can detect). eoka blocks those at the transport layer and defines navigator properties on the prototype instead of the instance.

Ecosystem

Crate Description
eoka-agent AI agent layer with MCP server
eoka-runner Config-based automation (YAML flows)

License

MIT