Skip to main content

eoka/
lib.rs

1//! # Eoka
2//!
3//! Stealth browser automation for Rust — a Puppeteer/Playwright alternative that bypasses bot detection.
4//!
5//! Eoka is a minimal, fast headless Chrome library built from scratch. Unlike Selenium or
6//! chromiumoxide, it uses a custom CDP implementation with stealth filtering to avoid detection
7//! by Cloudflare, DataDome, PerimeterX, and other anti-bot systems.
8//!
9//! ## Features
10//!
11//! - **Stealth by Default** - Binary patching, 15 JS evasions, human-like mouse/typing
12//! - **Puppeteer-like API** - `click()`, `type()`, `wait_for()`, `screenshot()`, etc.
13//! - **Direct CDP Transport** - No separate WebDriver server or browser-driver dependency
14//! - **AI-Agent Ready** - PageState introspection, element indexing, text extraction
15//! - **Fast** - Lazy evasion scripts, mmap patching, stack-allocated paths
16//!
17//! ## Quick Start
18//!
19//! ```rust,no_run
20//! use eoka::{ActionOptions, Browser, Expectation, LocatorSpec};
21//!
22//! #[tokio::main]
23//! async fn main() -> eoka::Result<()> {
24//!     // Launch browser (patches Chrome, applies evasions)
25//!     let browser = Browser::launch().await?;
26//!
27//!     // Create page and navigate
28//!     let page = browser.new_page("data:text/html,<input id=name><button onclick=\"document.body.dataset.done=1\">Save</button>").await?;
29//!
30//!     // Human-like interactions
31//!     page.fill("#name", "hello world").await?;
32//!     let receipt = page.locator("button").click(ActionOptions::expecting(
33//!         Expectation::Visible { target: LocatorSpec::css("body[data-done]") },
34//!     )).await?;
35//!     assert_eq!(receipt.outcome, eoka::ActionOutcome::VerifiedComplete);
36//!
37//!     // Screenshot
38//!     let png = page.screenshot().await?;
39//!     std::fs::write("screenshot.png", png)?;
40//!
41//!     browser.close().await?;
42//!     Ok(())
43//! }
44//! ```
45//!
46//! ## Configuration
47//!
48//! ```rust,no_run
49//! use eoka::Browser;
50//!
51//! # #[tokio::main]
52//! # async fn main() -> eoka::Result<()> {
53//! // The default launch path is stealthy and headless.
54//! let browser = Browser::launch().await?;
55//! browser.close().await?;
56//!
57//! // For one-off tweaks, mutate the default stealth config inline.
58//! let browser = Browser::launch_with(|config| {
59//!     config.proxy = Some("http://127.0.0.1:8080".into());
60//! }).await?;
61//! browser.close().await?;
62//! # Ok(())
63//! # }
64//! ```
65
66#![deny(missing_docs)]
67
68pub mod action;
69pub mod bounded_fetch;
70pub mod browser;
71pub mod cdp;
72pub mod download;
73pub mod element;
74pub use action::{
75    Action, ActionOptions, ActionOutcome, ActionReceipt, DispatchObserver, DispatchState,
76    Expectation, Locator, LocatorQuery, LocatorSpec,
77};
78pub mod error;
79pub mod fetch;
80mod keyboard;
81pub mod network;
82pub mod page;
83mod recording;
84pub mod session;
85pub mod stealth;
86
87// Static assertions: ensure core types are Send + Sync for use across async tasks
88#[allow(dead_code)]
89const _: () = {
90    fn assert_send_sync<T: Send + Sync>() {}
91    fn _assertions() {
92        assert_send_sync::<browser::Browser>();
93        assert_send_sync::<page::Page>();
94    }
95};
96
97// Re-exports
98pub use bounded_fetch::{BoundedFetchRequest, BoundedFetchResponse, MAX_TEXT_FETCH_BYTES};
99pub use browser::{Browser, TabInfo};
100pub use download::{
101    DownloadBeginning, DownloadEvent, DownloadProgress, DownloadWatch, NativeDownloadState,
102};
103pub use element::{BoundingBox, Element};
104pub use error::{Error, Result};
105pub use fetch::{BrowserFetchOutcome, BrowserFetchRequest, BrowserFetchResponse};
106pub use network::{NetworkEvent, NetworkWatcher};
107pub use page::{
108    CaptureBodyError, CapturedFrameResponse, FrameResponseCapture, FrameResponseCaptureOptions,
109    FrameResponseCaptureReport,
110};
111pub use page::{
112    CapturedRequest, FrameInfo, MouseButton, Page, PageState, ResponseBody, ShadowClickReceipt,
113    TextMatch,
114};
115pub use page::{ScreenshotClip, ScreenshotFormat, ScreenshotOptions};
116pub use recording::{NativeRecording, NativeRecordingOptions, RecordingChunk, RecordingError};
117pub use session::{BrowserSession, BrowserState, SessionCookie};
118pub use stealth::{Fingerprint, HumanSpeed};
119
120/// Configuration for stealth features
121#[derive(Debug, Clone)]
122pub struct StealthConfig {
123    /// Spoof WebGL renderer/vendor
124    pub webgl_spoof: bool,
125    /// Spoof canvas fingerprint
126    pub canvas_spoof: bool,
127    /// Spoof audio fingerprint
128    pub audio_spoof: bool,
129    /// Use human-like mouse movements
130    pub human_mouse: bool,
131    /// Use human-like typing
132    pub human_typing: bool,
133    /// Custom user agent (None = random realistic)
134    pub user_agent: Option<String>,
135    /// Headless mode
136    pub headless: bool,
137    /// Absolute path to Chrome/Chromium. Invalid explicit paths never fall back.
138    pub chrome_path: Option<String>,
139    /// Absolute parent directory for a unique private browser runtime.
140    /// The parent is preserved; only Eoka's owned child is removed.
141    pub runtime_dir: Option<String>,
142    /// Enable Chrome's sandbox. Defaults to false for existing callers.
143    /// Unsupported sandbox environments fail without an unsandboxed retry.
144    pub sandbox: bool,
145    /// Patch Chrome binary to bypass detection
146    pub patch_binary: bool,
147    /// Viewport width in CSS pixels.
148    pub viewport_width: u32,
149    /// Viewport height in CSS pixels.
150    pub viewport_height: u32,
151    /// Physical pixels per CSS pixel. Must be finite and positive.
152    pub device_scale_factor: f64,
153    /// Debug mode - log actions and save screenshots on error
154    pub debug: bool,
155    /// Directory for debug screenshots (defaults to current directory)
156    pub debug_dir: Option<String>,
157    /// Proxy server URL (e.g. "http://host:port")
158    pub proxy: Option<String>,
159    /// Durable Chrome user-data directory. When set, Eoka preserves the
160    /// profile and its full fingerprint identity across browser launches.
161    ///
162    /// A running instance may be borrowed only with the legacy default launch
163    /// policy. Explicit executable/runtime paths or sandboxing reject reuse,
164    /// since those launch requirements cannot be applied to another process.
165    pub user_data_dir: Option<String>,
166    /// Named profile within `user_data_dir` (Chrome's `--profile-directory`,
167    /// e.g. "Profile 1"). Only meaningful when `user_data_dir` is set;
168    /// defaults to Chrome's implicit "Default" profile when `None`.
169    ///
170    /// Chrome's singleton lock (and thus Eoka's reuse detection above) is
171    /// scoped to the whole `user_data_dir`, not the individual profile —
172    /// launching a second `profile_dir` while a Chrome is already running
173    /// on the same `user_data_dir` attaches to that same running instance
174    /// rather than opening a separate one, matching Chrome's own behavior.
175    pub profile_dir: Option<String>,
176    /// Proxy username for authenticated proxies
177    pub proxy_username: Option<String>,
178    /// Proxy password for authenticated proxies
179    pub proxy_password: Option<String>,
180    /// CDP command, automatic-reply write, and WebSocket connection timeout in
181    /// seconds (default: 30, increase for slow proxies).
182    pub cdp_timeout: u64,
183    /// IANA timezone (default: random from common US/EU timezones).
184    /// Set to a specific value like "America/New_York" to control the timezone.
185    pub timezone: Option<String>,
186    /// Extra Chrome command-line arguments appended after standard stealth args.
187    /// E.g. vec!["--use-fake-ui-for-media-stream".into()] to auto-grant camera.
188    pub extra_args: Vec<String>,
189    /// Enable legacy, invasive CDP-marker hiding that proxies `window.document`
190    /// and patches Object reflection APIs. It can break modern SPAs, so it is
191    /// disabled by default. Prefer the normal WebDriver and fingerprint evasions.
192    pub aggressive_cdp_evasion: bool,
193    /// Treat this as a session attached to a user-owned browser. When true,
194    /// `Browser::new_page`/`new_blank_page`/`attach_page` skip injecting the
195    /// evasion script. Defaults to false (set automatically by `Browser::connect*`).
196    pub live_session: bool,
197    /// Drop "detectable" CDP commands like `Runtime.enable` silently.
198    /// Defaults to true. Set to false in connect mode to get full DevTools-like control.
199    pub filter_cdp: bool,
200    /// Ignore TLS certificate errors on every page from creation, before the
201    /// first navigation. `Page::ignore_cert_errors` is applied too late for
202    /// `new_page`'s own initial navigate — this covers self-signed/expired
203    /// certs (e.g. .onion services) hit on the very first load. Defaults to
204    /// false.
205    pub ignore_cert_errors: bool,
206    /// Strip Chromium's `X-Client-Data` request header via CDP Fetch
207    /// interception. Chrome sends this header with field-trial variation IDs on
208    /// requests to Google-owned properties; a fresh automation profile carries
209    /// a distinctive (often empty) variation set that server-side bot scoring
210    /// can read before any JavaScript runs. Also launches Chrome with
211    /// `--disable-field-trial-config`. Defaults to true for spawned stealth
212    /// browsers; ignored (never applied) for attached live sessions.
213    pub strip_x_client_data: bool,
214    /// Resolve timezone and interface language from the browser's apparent
215    /// public IP via a one-shot navigation to Cloudflare's trace endpoint
216    /// before any real page is created. Keeps `Date`/`Intl` and the injected
217    /// `navigator.languages` consistent with IP geolocation instead of a
218    /// random timezone. Ignored when `timezone` is set explicitly and when
219    /// attaching to an existing browser. Defaults to false.
220    pub geo_align: bool,
221}
222
223impl Default for StealthConfig {
224    fn default() -> Self {
225        Self {
226            webgl_spoof: true,
227            canvas_spoof: true,
228            audio_spoof: true,
229            human_mouse: true,
230            human_typing: true,
231            user_agent: None,
232            headless: true,
233            chrome_path: None,
234            runtime_dir: None,
235            sandbox: false,
236            patch_binary: true,
237            viewport_width: 1920,
238            viewport_height: 1080,
239            device_scale_factor: 1.0,
240            debug: false,
241            debug_dir: None,
242            proxy: None,
243            user_data_dir: None,
244            profile_dir: None,
245            proxy_username: None,
246            proxy_password: None,
247            cdp_timeout: 30,
248            timezone: None, // Random from common timezones
249            extra_args: Vec::new(),
250            aggressive_cdp_evasion: false,
251            live_session: false,
252            filter_cdp: true,
253            ignore_cert_errors: false,
254            strip_x_client_data: true,
255            geo_align: false,
256        }
257    }
258}
259
260impl StealthConfig {
261    /// Create a minimal config (no spoofing, no patching)
262    pub fn minimal() -> Self {
263        Self {
264            webgl_spoof: false,
265            canvas_spoof: false,
266            audio_spoof: false,
267            human_mouse: false,
268            human_typing: false,
269            headless: false,
270            patch_binary: false,
271            ..Default::default()
272        }
273    }
274
275    /// Create a visible (non-headless) config
276    pub fn visible() -> Self {
277        Self {
278            headless: false,
279            ..Default::default()
280        }
281    }
282
283    /// Create a debug config (visible, with logging)
284    pub fn debug() -> Self {
285        Self {
286            headless: false,
287            debug: true,
288            ..Default::default()
289        }
290    }
291
292    /// Config tuned for attaching to a user-owned browser via `Browser::connect*`.
293    ///
294    /// Disables anything that touches the user's Chrome state:
295    /// - `live_session` — no evasion script injection on attached tabs
296    /// - `filter_cdp = false` — full DevTools-equivalent CDP access
297    /// - `patch_binary = false` — we don't manage the binary
298    /// - all spoofing off — the user's browser already has its own fingerprint
299    pub fn live() -> Self {
300        Self {
301            live_session: true,
302            filter_cdp: false,
303            patch_binary: false,
304            webgl_spoof: false,
305            canvas_spoof: false,
306            audio_spoof: false,
307            human_mouse: true,
308            human_typing: true,
309            ..Default::default()
310        }
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::StealthConfig;
317
318    #[test]
319    fn default_config_is_headless() {
320        assert!(StealthConfig::default().headless);
321    }
322
323    #[test]
324    fn visible_configs_are_explicitly_not_headless() {
325        assert!(!StealthConfig::visible().headless);
326        assert!(!StealthConfig::debug().headless);
327    }
328}