Skip to main content

stygian_browser/
behavior.rs

1//! Human behavior simulation for anti-detection
2//!
3//! This module provides realistic input simulation that mimics genuine human
4//! browsing patterns, making automated sessions harder to distinguish from
5//! real users.
6//!
7//! - [`MouseSimulator`] — Distance-aware Bézier curve mouse trajectories
8//! - [`TypingSimulator`] — Variable-speed typing with natural pauses *(T11)*
9//! - [`InteractionSimulator`] — Random scrolls and micro-movements *(T12)*
10
11// All f64→int and int→f64 casts in this module are bounded by construction
12// (RNG outputs, clamped durations, step counts ≤ 120) so truncation and sign
13// loss cannot occur in practice.  Precision loss from int→f64 is intentional
14// for the splitmix64 RNG and Bézier parameter arithmetic.
15#![allow(
16    clippy::cast_possible_truncation,
17    clippy::cast_sign_loss,
18    clippy::cast_precision_loss
19)]
20
21use chromiumoxide::Page;
22use chromiumoxide::cdp::browser_protocol::input::{DispatchKeyEventParams, DispatchKeyEventType};
23use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
24use tokio::time::sleep;
25use tracing::warn;
26
27use crate::error::{BrowserError, Result};
28
29// ─── RNG helpers (splitmix64, no external dep) ────────────────────────────────
30
31/// One splitmix64 step — deterministic, high-quality 64-bit output.
32const fn splitmix64(state: &mut u64) -> u64 {
33    *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
34    let mut z = *state;
35    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
36    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
37    z ^ (z >> 31)
38}
39
40/// Uniform float in `[0, 1)`.
41fn rand_f64(state: &mut u64) -> f64 {
42    (splitmix64(state) >> 11) as f64 / (1u64 << 53) as f64
43}
44
45/// Uniform float in `[min, max)`.
46fn rand_range(state: &mut u64, min: f64, max: f64) -> f64 {
47    rand_f64(state).mul_add(max - min, min)
48}
49
50/// Approximate Gaussian sample via Box–Muller transform.
51fn rand_normal(state: &mut u64, mean: f64, std_dev: f64) -> f64 {
52    let u1 = rand_f64(state).max(1e-10);
53    let u2 = rand_f64(state);
54    let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
55    std_dev.mul_add(z, mean)
56}
57
58// ─── Bézier helpers ───────────────────────────────────────────────────────────
59
60fn lerp(p0: (f64, f64), p1: (f64, f64), t: f64) -> (f64, f64) {
61    (t.mul_add(p1.0 - p0.0, p0.0), t.mul_add(p1.1 - p0.1, p0.1))
62}
63
64/// Evaluate a cubic Bézier curve at parameter `t ∈ [0, 1]`.
65fn cubic_bezier(
66    p0: (f64, f64),
67    p1: (f64, f64),
68    p2: (f64, f64),
69    p3: (f64, f64),
70    t: f64,
71) -> (f64, f64) {
72    let a = lerp(p0, p1, t);
73    let b = lerp(p1, p2, t);
74    let c = lerp(p2, p3, t);
75    lerp(lerp(a, b, t), lerp(b, c, t), t)
76}
77
78// ─── MouseSimulator ───────────────────────────────────────────────────────────
79
80/// Simulates human-like mouse movement via distance-aware Bézier curve trajectories.
81///
82/// Each call to [`move_to`][MouseSimulator::move_to] computes a cubic Bézier path
83/// between the current cursor position and the target, then replays it as a sequence
84/// of `Input.dispatchMouseEvent` CDP commands with randomised inter-event delays
85/// (10–50 ms per segment).  Movement speed naturally slows for long distances and
86/// accelerates for short ones — matching human motor-control patterns.
87///
88/// # Example
89///
90/// ```no_run
91/// use stygian_browser::behavior::MouseSimulator;
92///
93/// # async fn run(page: &chromiumoxide::Page) -> stygian_browser::Result<()> {
94/// let mut mouse = MouseSimulator::new();
95/// mouse.move_to(page, 640.0, 400.0).await?;
96/// mouse.click(page, 640.0, 400.0).await?;
97/// # Ok(())
98/// # }
99/// ```
100pub struct MouseSimulator {
101    /// Current cursor X in CSS pixels.
102    current_x: f64,
103    /// Current cursor Y in CSS pixels.
104    current_y: f64,
105    /// Splitmix64 RNG state.
106    rng: u64,
107}
108
109impl Default for MouseSimulator {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl MouseSimulator {
116    /// Create a simulator seeded from wall-clock time, positioned at (0, 0).
117    ///
118    /// # Example
119    ///
120    /// ```
121    /// use stygian_browser::behavior::MouseSimulator;
122    /// let mouse = MouseSimulator::new();
123    /// assert_eq!(mouse.position(), (0.0, 0.0));
124    /// ```
125    pub fn new() -> Self {
126        let seed = SystemTime::now()
127            .duration_since(UNIX_EPOCH)
128            .map_or(0x1234_5678_9abc_def0, |d| {
129                d.as_secs() ^ u64::from(d.subsec_nanos())
130            });
131        Self {
132            current_x: 0.0,
133            current_y: 0.0,
134            rng: seed,
135        }
136    }
137
138    /// Create a simulator with a known initial position and deterministic seed.
139    ///
140    /// Useful for unit-testing path generation without CDP.
141    ///
142    /// # Example
143    ///
144    /// ```
145    /// use stygian_browser::behavior::MouseSimulator;
146    /// let mouse = MouseSimulator::with_seed_and_position(42, 100.0, 200.0);
147    /// assert_eq!(mouse.position(), (100.0, 200.0));
148    /// ```
149    pub const fn with_seed_and_position(seed: u64, x: f64, y: f64) -> Self {
150        Self {
151            current_x: x,
152            current_y: y,
153            rng: seed,
154        }
155    }
156
157    /// Returns the current cursor position as `(x, y)`.
158    ///
159    /// # Example
160    ///
161    /// ```
162    /// use stygian_browser::behavior::MouseSimulator;
163    /// let mouse = MouseSimulator::new();
164    /// let (x, y) = mouse.position();
165    /// assert_eq!((x, y), (0.0, 0.0));
166    /// ```
167    pub const fn position(&self) -> (f64, f64) {
168        (self.current_x, self.current_y)
169    }
170
171    /// Compute Bézier waypoints for a move from `(from_x, from_y)` to
172    /// `(to_x, to_y)`.
173    ///
174    /// The number of waypoints scales with Euclidean distance — roughly one
175    /// point every 8 pixels — with a minimum of 12 and maximum of 120 steps.
176    /// Random perpendicular offsets are applied to the two interior control
177    /// points to produce natural curved paths.  Each waypoint receives
178    /// sub-pixel jitter (±0.8 px) for micro-tremor realism.
179    ///
180    /// This method is pure, performs no I/O, and is exposed for testing.
181    ///
182    /// # Example
183    ///
184    /// ```
185    /// use stygian_browser::behavior::MouseSimulator;
186    /// let mut mouse = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
187    /// let path = mouse.compute_path(0.0, 0.0, 200.0, 0.0);
188    /// // always at least 12 steps
189    /// assert!(path.len() >= 13);
190    /// // starts near origin
191    /// assert!((path[0].0).abs() < 5.0);
192    /// // ends near target
193    /// let last = path[path.len() - 1];
194    /// assert!((last.0 - 200.0).abs() < 5.0);
195    /// ```
196    pub fn compute_path(
197        &mut self,
198        from_x: f64,
199        from_y: f64,
200        to_x: f64,
201        to_y: f64,
202    ) -> Vec<(f64, f64)> {
203        let dx = to_x - from_x;
204        let dy = to_y - from_y;
205        let distance = dx.hypot(dy);
206
207        // Scale step count with distance; clamp to [12, 120].
208        let steps = ((distance / 8.0).round() as usize).clamp(12, 120);
209
210        // Perpendicular unit vector for offsetting control points.
211        let (px, py) = if distance > 1.0 {
212            (-dy / distance, dx / distance)
213        } else {
214            (1.0, 0.0)
215        };
216
217        // Larger offsets for longer movements (capped at 200 px).
218        let offset_scale = (distance * 0.35).min(200.0);
219        let cp1_off = rand_normal(&mut self.rng, 0.0, offset_scale * 0.5);
220        let cp2_off = rand_normal(&mut self.rng, 0.0, offset_scale * 0.4);
221
222        // Control points at 1/3 and 2/3 of the straight line, offset perp.
223        let cp1 = (
224            px.mul_add(cp1_off, from_x + dx / 3.0),
225            py.mul_add(cp1_off, from_y + dy / 3.0),
226        );
227        let cp2 = (
228            px.mul_add(cp2_off, from_x + 2.0 * dx / 3.0),
229            py.mul_add(cp2_off, from_y + 2.0 * dy / 3.0),
230        );
231        let p0 = (from_x, from_y);
232        let p3 = (to_x, to_y);
233
234        (0..=steps)
235            .map(|i| {
236                let t = i as f64 / steps as f64;
237                let (bx, by) = cubic_bezier(p0, cp1, cp2, p3, t);
238                // Micro-tremor jitter (± ~0.8 px, normally distributed).
239                let jx = rand_normal(&mut self.rng, 0.0, 0.4);
240                let jy = rand_normal(&mut self.rng, 0.0, 0.4);
241                (bx + jx, by + jy)
242            })
243            .collect()
244    }
245
246    /// Move the cursor to `(to_x, to_y)` using a human-like Bézier trajectory.
247    ///
248    /// Dispatches `Input.dispatchMouseEvent`(`mouseMoved`) for each waypoint
249    /// with randomised 10–50 ms delays.  Updates [`position`][Self::position]
250    /// on success.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`BrowserError::CdpError`] if any CDP event dispatch fails.
255    pub async fn move_to(&mut self, page: &Page, to_x: f64, to_y: f64) -> Result<()> {
256        use chromiumoxide::cdp::browser_protocol::input::{
257            DispatchMouseEventParams, DispatchMouseEventType,
258        };
259
260        let path = self.compute_path(self.current_x, self.current_y, to_x, to_y);
261
262        for &(x, y) in &path {
263            let params = DispatchMouseEventParams::builder()
264                .r#type(DispatchMouseEventType::MouseMoved)
265                .x(x)
266                .y(y)
267                .build()
268                .map_err(BrowserError::ConfigError)?;
269
270            page.execute(params)
271                .await
272                .map_err(|e| BrowserError::CdpError {
273                    operation: "Input.dispatchMouseEvent(mouseMoved)".to_string(),
274                    message: e.to_string(),
275                })?;
276
277            let delay_ms = rand_range(&mut self.rng, 10.0, 50.0) as u64;
278            sleep(Duration::from_millis(delay_ms)).await;
279        }
280
281        self.current_x = to_x;
282        self.current_y = to_y;
283        Ok(())
284    }
285
286    /// Move to `(x, y)` then perform a human-like left-click.
287    ///
288    /// After arriving at the target the simulator pauses (20–80 ms), sends
289    /// `mousePressed`, holds (50–150 ms), then sends `mouseReleased`.
290    ///
291    /// # Errors
292    ///
293    /// Returns [`BrowserError::CdpError`] if any CDP event dispatch fails.
294    pub async fn click(&mut self, page: &Page, x: f64, y: f64) -> Result<()> {
295        use chromiumoxide::cdp::browser_protocol::input::{
296            DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
297        };
298
299        self.move_to(page, x, y).await?;
300
301        // Pre-click pause.
302        let pre_ms = rand_range(&mut self.rng, 20.0, 80.0) as u64;
303        sleep(Duration::from_millis(pre_ms)).await;
304
305        let press = DispatchMouseEventParams::builder()
306            .r#type(DispatchMouseEventType::MousePressed)
307            .x(x)
308            .y(y)
309            .button(MouseButton::Left)
310            .click_count(1i64)
311            .build()
312            .map_err(BrowserError::ConfigError)?;
313
314        page.execute(press)
315            .await
316            .map_err(|e| BrowserError::CdpError {
317                operation: "Input.dispatchMouseEvent(mousePressed)".to_string(),
318                message: e.to_string(),
319            })?;
320
321        // Hold duration (humans don't click at zero duration).
322        let hold_ms = rand_range(&mut self.rng, 50.0, 150.0) as u64;
323        sleep(Duration::from_millis(hold_ms)).await;
324
325        let release = DispatchMouseEventParams::builder()
326            .r#type(DispatchMouseEventType::MouseReleased)
327            .x(x)
328            .y(y)
329            .button(MouseButton::Left)
330            .click_count(1i64)
331            .build()
332            .map_err(BrowserError::ConfigError)?;
333
334        page.execute(release)
335            .await
336            .map_err(|e| BrowserError::CdpError {
337                operation: "Input.dispatchMouseEvent(mouseReleased)".to_string(),
338                message: e.to_string(),
339            })?;
340
341        Ok(())
342    }
343}
344
345// ─── Keyboard helper ─────────────────────────────────────────────────────────
346
347/// Return a plausible adjacent key for typo simulation.
348///
349/// Looks up `ch` in a basic QWERTY row map and returns a neighbouring key.
350/// Non-alphabetic characters fall back to `'x'`.
351fn adjacent_key(ch: char, rng: &mut u64) -> char {
352    const ROWS: [&str; 3] = ["qwertyuiop", "asdfghjkl", "zxcvbnm"];
353    let lc = ch.to_lowercase().next().unwrap_or(ch);
354    for row in ROWS {
355        let chars: Vec<char> = row.chars().collect();
356        if let Some(idx) = chars.iter().position(|&c| c == lc) {
357            let adj = if idx == 0 {
358                chars.get(1).copied().unwrap_or(lc)
359            } else if idx == chars.len() - 1 || rand_f64(rng) < 0.5 {
360                chars.get(idx - 1).copied().unwrap_or(lc)
361            } else {
362                chars.get(idx + 1).copied().unwrap_or(lc)
363            };
364            return if ch.is_uppercase() {
365                adj.to_uppercase().next().unwrap_or(adj)
366            } else {
367                adj
368            };
369        }
370    }
371    'x'
372}
373
374// ─── TypingSimulator ──────────────────────────────────────────────────────────
375
376/// Simulates human-like typing using `Input.dispatchKeyEvent` CDP commands.
377///
378/// Each character is dispatched as a `keyDown` → `char` → `keyUp` sequence.
379/// Capital letters include the Shift modifier mask (`modifiers = 8`).  A
380/// configurable error rate causes occasional typos that are corrected via
381/// Backspace before the intended character is retyped.  Inter-key delays
382/// follow a Gaussian distribution (~80 ms mean, 25 ms σ) clamped to
383/// 30–200 ms.
384///
385/// # Example
386///
387/// ```no_run
388/// # async fn run(page: &chromiumoxide::Page) -> stygian_browser::Result<()> {
389/// use stygian_browser::behavior::TypingSimulator;
390/// let mut typer = TypingSimulator::new();
391/// typer.type_text(page, "Hello, world!").await?;
392/// # Ok(())
393/// # }
394/// ```
395pub struct TypingSimulator {
396    /// Splitmix64 RNG state.
397    rng: u64,
398    /// Per-character typo probability (default: 1.5 %).
399    error_rate: f64,
400}
401
402impl Default for TypingSimulator {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408impl TypingSimulator {
409    /// Create a typing simulator seeded from wall-clock time.
410    ///
411    /// # Example
412    ///
413    /// ```
414    /// use stygian_browser::behavior::TypingSimulator;
415    /// let typer = TypingSimulator::new();
416    /// ```
417    pub fn new() -> Self {
418        let seed = SystemTime::now()
419            .duration_since(UNIX_EPOCH)
420            .map_or(0xdead_beef_cafe_babe, |d| {
421                d.as_secs() ^ u64::from(d.subsec_nanos())
422            });
423        Self {
424            rng: seed,
425            error_rate: 0.015,
426        }
427    }
428
429    /// Create a typing simulator with a fixed seed (useful for testing).
430    ///
431    /// # Example
432    ///
433    /// ```
434    /// use stygian_browser::behavior::TypingSimulator;
435    /// let typer = TypingSimulator::with_seed(42);
436    /// ```
437    pub const fn with_seed(seed: u64) -> Self {
438        Self {
439            rng: seed,
440            error_rate: 0.015,
441        }
442    }
443
444    /// Set the per-character typo probability (clamped to `0.0–1.0`).
445    ///
446    /// Default is `0.015` (1.5 %).
447    ///
448    /// # Example
449    ///
450    /// ```
451    /// use stygian_browser::behavior::TypingSimulator;
452    /// let typer = TypingSimulator::new().with_error_rate(0.0);
453    /// ```
454    #[must_use]
455    pub const fn with_error_rate(mut self, rate: f64) -> Self {
456        self.error_rate = rate.clamp(0.0, 1.0);
457        self
458    }
459
460    /// Sample a realistic inter-keystroke delay (Gaussian, ~80 ms mean).
461    ///
462    /// The returned value is clamped to the range 30–200 ms.
463    ///
464    /// # Example
465    ///
466    /// ```
467    /// use stygian_browser::behavior::TypingSimulator;
468    /// let mut typer = TypingSimulator::with_seed(1);
469    /// let delay = typer.keystroke_delay();
470    /// assert!(delay.as_millis() >= 30 && delay.as_millis() <= 200);
471    /// ```
472    pub fn keystroke_delay(&mut self) -> Duration {
473        let ms = rand_normal(&mut self.rng, 80.0, 25.0).clamp(30.0, 200.0) as u64;
474        Duration::from_millis(ms)
475    }
476
477    /// Dispatch one `Input.dispatchKeyEvent` CDP command.
478    async fn dispatch_key(
479        page: &Page,
480        kind: DispatchKeyEventType,
481        key: &str,
482        text: Option<&str>,
483        modifiers: i64,
484    ) -> Result<()> {
485        let mut b = DispatchKeyEventParams::builder().r#type(kind).key(key);
486        if let Some(t) = text {
487            b = b.text(t);
488        }
489        if modifiers != 0 {
490            b = b.modifiers(modifiers);
491        }
492        let params = b.build().map_err(BrowserError::ConfigError)?;
493        page.execute(params)
494            .await
495            .map_err(|e| BrowserError::CdpError {
496                operation: "Input.dispatchKeyEvent".to_string(),
497                message: e.to_string(),
498            })?;
499        Ok(())
500    }
501
502    /// Press and release a `Backspace` key (for correcting a typo).
503    async fn type_backspace(page: &Page) -> Result<()> {
504        Self::dispatch_key(page, DispatchKeyEventType::RawKeyDown, "Backspace", None, 0).await?;
505        Self::dispatch_key(page, DispatchKeyEventType::KeyUp, "Backspace", None, 0).await?;
506        Ok(())
507    }
508
509    /// Send the full `keyDown` → `char` → `keyUp` sequence for one character.
510    ///
511    /// Capital letters (Unicode uppercase alphabetic) include `modifiers = 8`
512    /// (Shift).
513    async fn type_char(page: &Page, ch: char) -> Result<()> {
514        let text = ch.to_string();
515        let modifiers: i64 = if ch.is_uppercase() && ch.is_alphabetic() {
516            8
517        } else {
518            0
519        };
520        let key = text.as_str();
521        Self::dispatch_key(
522            page,
523            DispatchKeyEventType::KeyDown,
524            key,
525            Some(&text),
526            modifiers,
527        )
528        .await?;
529        Self::dispatch_key(
530            page,
531            DispatchKeyEventType::Char,
532            key,
533            Some(&text),
534            modifiers,
535        )
536        .await?;
537        Self::dispatch_key(page, DispatchKeyEventType::KeyUp, key, None, modifiers).await?;
538        Ok(())
539    }
540
541    /// Type `text` into the focused element with human-like keystrokes.
542    ///
543    /// Each character produces `keyDown` → `char` → `keyUp` events.  With
544    /// probability `error_rate` a wrong adjacent key is typed first, then
545    /// corrected with Backspace.  Word boundaries (space or newline) receive an
546    /// additional 100–400 ms pause to simulate natural word-completion rhythm.
547    ///
548    /// # Errors
549    ///
550    /// Returns [`BrowserError::CdpError`] if any CDP call fails.
551    pub async fn type_text(&mut self, page: &Page, text: &str) -> Result<()> {
552        for ch in text.chars() {
553            // Occasionally make a typo: adjacent key → backspace → correct key.
554            if rand_f64(&mut self.rng) < self.error_rate {
555                let wrong = adjacent_key(ch, &mut self.rng);
556                Self::type_char(page, wrong).await?;
557                let typo_delay = rand_normal(&mut self.rng, 120.0, 30.0).clamp(60.0, 250.0) as u64;
558                sleep(Duration::from_millis(typo_delay)).await;
559                Self::type_backspace(page).await?;
560                let fix_delay = rand_range(&mut self.rng, 40.0, 120.0) as u64;
561                sleep(Duration::from_millis(fix_delay)).await;
562            }
563
564            Self::type_char(page, ch).await?;
565            sleep(self.keystroke_delay()).await;
566
567            // Extra pause after word boundaries.
568            if ch == ' ' || ch == '\n' {
569                let word_pause = rand_range(&mut self.rng, 100.0, 400.0) as u64;
570                sleep(Duration::from_millis(word_pause)).await;
571            }
572        }
573        Ok(())
574    }
575}
576
577// ─── InteractionLevel ─────────────────────────────────────────────────────────
578
579/// Intensity level for [`InteractionSimulator`] random interactions.
580///
581/// # Example
582///
583/// ```
584/// use stygian_browser::behavior::InteractionLevel;
585/// assert_eq!(InteractionLevel::default(), InteractionLevel::None);
586/// ```
587#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
588pub enum InteractionLevel {
589    /// No random interactions are performed.
590    #[default]
591    None,
592    /// Occasional scroll + brief pause (500–1 500 ms).
593    Low,
594    /// Scroll sequence + mouse wiggle + reading pause (1–3 s).
595    Medium,
596    /// Full simulation: scrolling, mouse wiggles, hover, and scroll-back.
597    High,
598}
599
600// ─── InteractionSimulator ─────────────────────────────────────────────────────
601
602/// Simulates random human-like page interactions.
603///
604/// Combines scroll patterns, mouse micro-movements, and reading pauses to
605/// produce convincing human browsing behaviour.  The intensity is controlled
606/// by [`InteractionLevel`].
607///
608/// # Example
609///
610/// ```no_run
611/// # async fn run(page: &chromiumoxide::Page) -> stygian_browser::Result<()> {
612/// use stygian_browser::behavior::{InteractionSimulator, InteractionLevel};
613/// let mut sim = InteractionSimulator::new(InteractionLevel::Medium);
614/// sim.random_interaction(page, 1280.0, 800.0).await?;
615/// # Ok(())
616/// # }
617/// ```
618pub struct InteractionSimulator {
619    rng: u64,
620    mouse: MouseSimulator,
621    level: InteractionLevel,
622}
623
624impl Default for InteractionSimulator {
625    fn default() -> Self {
626        Self::new(InteractionLevel::None)
627    }
628}
629
630impl InteractionSimulator {
631    /// Create a new interaction simulator with the given interaction level.
632    ///
633    /// # Example
634    ///
635    /// ```
636    /// use stygian_browser::behavior::{InteractionSimulator, InteractionLevel};
637    /// let sim = InteractionSimulator::new(InteractionLevel::Low);
638    /// ```
639    pub fn new(level: InteractionLevel) -> Self {
640        let seed = SystemTime::now()
641            .duration_since(UNIX_EPOCH)
642            .map_or(0x0123_4567_89ab_cdef, |d| {
643                d.as_secs() ^ u64::from(d.subsec_nanos())
644            });
645        Self {
646            rng: seed,
647            mouse: MouseSimulator::with_seed_and_position(seed ^ 0xca11_ab1e, 400.0, 300.0),
648            level,
649        }
650    }
651
652    /// Create a simulator with a fixed seed (useful for unit-testing).
653    ///
654    /// # Example
655    ///
656    /// ```
657    /// use stygian_browser::behavior::{InteractionSimulator, InteractionLevel};
658    /// let sim = InteractionSimulator::with_seed(42, InteractionLevel::High);
659    /// ```
660    pub const fn with_seed(seed: u64, level: InteractionLevel) -> Self {
661        Self {
662            rng: seed,
663            mouse: MouseSimulator::with_seed_and_position(seed ^ 0xca11_ab1e, 400.0, 300.0),
664            level,
665        }
666    }
667
668    /// Evaluate a JavaScript expression on `page`.
669    async fn js(page: &Page, expr: String) -> Result<()> {
670        page.evaluate(expr)
671            .await
672            .map_err(|e| BrowserError::CdpError {
673                operation: "Runtime.evaluate".to_string(),
674                message: e.to_string(),
675            })?;
676        Ok(())
677    }
678
679    /// Scroll `delta_y` CSS pixels (positive = down, negative = up).
680    async fn scroll(page: &Page, delta_y: i64) -> Result<()> {
681        Self::js(
682            page,
683            format!("window.scrollBy({{top:{delta_y},behavior:'smooth'}})"),
684        )
685        .await
686    }
687
688    /// Dispatch synthetic key events to `window` so behavioural-biometric SDKs
689    /// (Cloudflare Turnstile Signal Orchestrator, `OpenAI` Sentinel SO) accumulate
690    /// non-zero keystroke telemetry before a protected action fires.
691    ///
692    /// Events are dispatched at window-level (bubbling) since SO listeners are
693    /// installed there.  Arrow/Tab keys are used — they do not activate UI
694    /// elements but are universally listened for by signal trackers.
695    async fn do_keyactivity(&mut self, page: &Page) -> Result<()> {
696        const KEYS: &[&str] = &["ArrowDown", "Tab", "ArrowRight", "ArrowUp"];
697        let count = 3 + rand_range(&mut self.rng, 0.0, 4.0) as u32;
698        let mut successful_pairs = 0u32;
699        for i in 0..count {
700            let key = KEYS
701                .get((i as usize) % KEYS.len())
702                .copied()
703                .unwrap_or("Tab");
704            let down_delay = rand_range(&mut self.rng, 50.0, 120.0) as u64;
705            sleep(Duration::from_millis(down_delay)).await;
706            let keydown_ok = if let Err(e) = Self::js(
707                page,
708                format!(
709                    "window.dispatchEvent(new KeyboardEvent('keydown',\
710                     {{bubbles:true,cancelable:true,key:{key:?},code:{key:?}}}));"
711                ),
712            )
713            .await
714            {
715                warn!(key, "Failed to dispatch keydown event: {e}");
716                false
717            } else {
718                true
719            };
720            let hold_ms = rand_range(&mut self.rng, 20.0, 60.0) as u64;
721            sleep(Duration::from_millis(hold_ms)).await;
722            let keyup_ok = if let Err(e) = Self::js(
723                page,
724                format!(
725                    "window.dispatchEvent(new KeyboardEvent('keyup',\
726                     {{bubbles:true,cancelable:true,key:{key:?},code:{key:?}}}));"
727                ),
728            )
729            .await
730            {
731                warn!(key, "Failed to dispatch keyup event: {e}");
732                false
733            } else {
734                true
735            };
736
737            if keydown_ok && keyup_ok {
738                successful_pairs += 1;
739            }
740        }
741
742        if successful_pairs == 0 {
743            return Err(BrowserError::CdpError {
744                operation: "InteractionSimulator::do_keyactivity".to_string(),
745                message: "all synthetic key event dispatches failed".to_string(),
746            });
747        }
748
749        Ok(())
750    }
751
752    /// Scroll down a random amount, then partially scroll back up.
753    async fn do_scroll(&mut self, page: &Page) -> Result<()> {
754        let down = rand_range(&mut self.rng, 200.0, 600.0) as i64;
755        Self::scroll(page, down).await?;
756        let pause = rand_range(&mut self.rng, 300.0, 1_000.0) as u64;
757        sleep(Duration::from_millis(pause)).await;
758        let up = -(rand_range(&mut self.rng, 50.0, (down as f64) * 0.4) as i64);
759        Self::scroll(page, up).await?;
760        Ok(())
761    }
762
763    /// Move the mouse to a random point within the viewport.
764    async fn do_mouse_wiggle(&mut self, page: &Page, vw: f64, vh: f64) -> Result<()> {
765        let tx = rand_range(&mut self.rng, vw * 0.1, vw * 0.9);
766        let ty = rand_range(&mut self.rng, vh * 0.1, vh * 0.9);
767        self.mouse.move_to(page, tx, ty).await
768    }
769
770    /// Perform a random human-like interaction matching the configured level.
771    ///
772    /// | Level    | Actions                                                   |
773    /// | ---------- | ----------------------------------------------------------- |
774    /// | `None`   | No-op                                                     |
775    /// | `Low`    | One scroll + short pause (500–1 500 ms)                   |
776    /// | `Medium` | Scroll + key activity + mouse wiggle + reading pauses     |
777    /// | `High`   | Medium + extra key activity + extra wiggle + optional up-scroll |
778    ///
779    /// # Parameters
780    ///
781    /// - `page` — The active browser page.
782    /// - `viewport_w` / `viewport_h` — Approximate viewport size in CSS pixels.
783    ///
784    /// # Errors
785    ///
786    /// Returns [`BrowserError::CdpError`] if any CDP call fails.
787    pub async fn random_interaction(
788        &mut self,
789        page: &Page,
790        viewport_w: f64,
791        viewport_h: f64,
792    ) -> Result<()> {
793        match self.level {
794            InteractionLevel::None => {}
795            InteractionLevel::Low => {
796                self.do_scroll(page).await?;
797                let pause = rand_range(&mut self.rng, 500.0, 1_500.0) as u64;
798                sleep(Duration::from_millis(pause)).await;
799            }
800            InteractionLevel::Medium => {
801                self.do_scroll(page).await?;
802                let p1 = rand_range(&mut self.rng, 800.0, 2_000.0) as u64;
803                sleep(Duration::from_millis(p1)).await;
804                // Key events populate behavioural-biometric trackers.
805                self.do_keyactivity(page).await?;
806                let p2 = rand_range(&mut self.rng, 500.0, 1_500.0) as u64;
807                sleep(Duration::from_millis(p2)).await;
808                self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
809                let p3 = rand_range(&mut self.rng, 400.0, 1_500.0) as u64;
810                sleep(Duration::from_millis(p3)).await;
811            }
812            InteractionLevel::High => {
813                self.do_scroll(page).await?;
814                let p1 = rand_range(&mut self.rng, 1_000.0, 5_000.0) as u64;
815                sleep(Duration::from_millis(p1)).await;
816                self.do_keyactivity(page).await?;
817                let p2 = rand_range(&mut self.rng, 400.0, 1_200.0) as u64;
818                sleep(Duration::from_millis(p2)).await;
819                self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
820                let p3 = rand_range(&mut self.rng, 800.0, 3_000.0) as u64;
821                sleep(Duration::from_millis(p3)).await;
822                self.do_keyactivity(page).await?;
823                let p4 = rand_range(&mut self.rng, 300.0, 800.0) as u64;
824                sleep(Duration::from_millis(p4)).await;
825                self.do_mouse_wiggle(page, viewport_w, viewport_h).await?;
826                let p5 = rand_range(&mut self.rng, 500.0, 2_000.0) as u64;
827                sleep(Duration::from_millis(p5)).await;
828                // Occasional scroll-back (40 % chance).
829                if rand_f64(&mut self.rng) < 0.4 {
830                    let up = -(rand_range(&mut self.rng, 50.0, 200.0) as i64);
831                    Self::scroll(page, up).await?;
832                    sleep(Duration::from_millis(500)).await;
833                }
834            }
835        }
836        Ok(())
837    }
838}
839
840// ─── RequestPacer ─────────────────────────────────────────────────────────────
841
842/// Paces programmatic HTTP/CDP requests with human-realistic inter-request delays.
843///
844/// Prevents tight-loop request patterns that are trivially detectable by server-side
845/// rate analysers (Cloudflare, Akamai, `DataDome`, AWS WAF).  Delays follow a truncated
846/// Gaussian distribution centred on `mean_ms` with `std_ms` variance, giving natural
847/// bursty-but-not-mechanical timing.
848///
849/// The first call to [`throttle`][RequestPacer::throttle] always returns immediately
850/// (no prior request to pace against).
851///
852/// # Example
853///
854/// ```no_run
855/// use stygian_browser::behavior::RequestPacer;
856///
857/// # async fn run() {
858/// let mut pacer = RequestPacer::new();
859/// for url in &["https://a.example.com", "https://b.example.com"] {
860///     pacer.throttle().await;
861///     // … make request to url …
862/// }
863/// # }
864/// ```
865pub struct RequestPacer {
866    rng: u64,
867    mean_ms: u64,
868    std_ms: u64,
869    min_ms: u64,
870    max_ms: u64,
871    last_request: Option<Instant>,
872}
873
874impl Default for RequestPacer {
875    fn default() -> Self {
876        Self::new()
877    }
878}
879
880impl RequestPacer {
881    /// Default pacer: mean 1 200 ms, σ = 400 ms, clamped 400–4 000 ms.
882    ///
883    /// # Example
884    ///
885    /// ```
886    /// use stygian_browser::behavior::RequestPacer;
887    /// let _pacer = RequestPacer::new();
888    /// ```
889    pub fn new() -> Self {
890        let seed = SystemTime::now()
891            .duration_since(UNIX_EPOCH)
892            .map_or(0xdead_beef_cafe_1337, |d| {
893                d.as_secs() ^ u64::from(d.subsec_nanos())
894            });
895        Self {
896            rng: seed,
897            mean_ms: 1_200,
898            std_ms: 400,
899            min_ms: 400,
900            max_ms: 4_000,
901            last_request: None,
902        }
903    }
904
905    /// Create with explicit timing parameters (all values in milliseconds).
906    ///
907    /// If `min_ms > max_ms`, bounds are normalized by swapping them.
908    ///
909    /// # Example
910    ///
911    /// ```
912    /// use stygian_browser::behavior::RequestPacer;
913    /// // Aggressive: ~500 ms mean, σ = 150 ms, clamped 200–1 500 ms.
914    /// let _pacer = RequestPacer::with_timing(500, 150, 200, 1_500);
915    /// ```
916    pub fn with_timing(mean_ms: u64, std_ms: u64, min_ms: u64, max_ms: u64) -> Self {
917        let (min_ms, max_ms) = if min_ms <= max_ms {
918            (min_ms, max_ms)
919        } else {
920            (max_ms, min_ms)
921        };
922        let seed = SystemTime::now()
923            .duration_since(UNIX_EPOCH)
924            .map_or(0xdead_beef_cafe_1337, |d| {
925                d.as_secs() ^ u64::from(d.subsec_nanos())
926            });
927        Self {
928            rng: seed,
929            mean_ms,
930            std_ms,
931            min_ms,
932            max_ms,
933            last_request: None,
934        }
935    }
936
937    /// Construct from a target requests-per-second rate.
938    ///
939    /// Mean = `1000 / rps` ms, σ = 25 % of mean, clamped to ±50 % of mean.
940    ///
941    /// `rps` is clamped to a minimum of `0.01` to avoid division by zero and
942    /// extreme near-zero denominators.
943    ///
944    /// # Example
945    ///
946    /// ```
947    /// use stygian_browser::behavior::RequestPacer;
948    /// let _pacer = RequestPacer::with_rate(0.5); // ~1 request every 2 s
949    /// ```
950    pub fn with_rate(requests_per_second: f64) -> Self {
951        let mean_ms = (1_000.0 / requests_per_second.max(0.01)).max(1.0) as u64;
952        let std_ms = mean_ms / 4;
953        let min_ms = mean_ms / 2;
954        let max_ms = mean_ms.saturating_mul(2);
955        let seed = SystemTime::now()
956            .duration_since(UNIX_EPOCH)
957            .map_or(0xdead_beef_cafe_1337, |d| {
958                d.as_secs() ^ u64::from(d.subsec_nanos())
959            });
960        Self {
961            rng: seed,
962            mean_ms,
963            std_ms,
964            min_ms,
965            max_ms,
966            last_request: None,
967        }
968    }
969
970    /// Wait until the appropriate inter-request delay has elapsed, then return.
971    ///
972    /// The first call returns immediately.  Subsequent calls sleep remaining time
973    /// to match the sampled target delay.
974    ///
975    /// # Example
976    ///
977    /// ```no_run
978    /// # async fn run() {
979    /// use stygian_browser::behavior::RequestPacer;
980    /// let mut pacer = RequestPacer::new();
981    /// pacer.throttle().await; // first call: immediate
982    /// pacer.throttle().await; // waits ~1.2 s
983    /// # }
984    /// ```
985    pub async fn throttle(&mut self) {
986        let target_ms = rand_normal(&mut self.rng, self.mean_ms as f64, self.std_ms as f64)
987            .max(self.min_ms as f64)
988            .min(self.max_ms as f64) as u64;
989
990        if let Some(last) = self.last_request {
991            let elapsed_ms = last.elapsed().as_millis() as u64;
992            if elapsed_ms < target_ms {
993                sleep(Duration::from_millis(target_ms - elapsed_ms)).await;
994            }
995        }
996        self.last_request = Some(Instant::now());
997    }
998}
999
1000// ─── Tests ────────────────────────────────────────────────────────────────────
1001
1002#[cfg(test)]
1003mod tests {
1004    use super::*;
1005
1006    #[test]
1007    fn mouse_simulator_starts_at_origin() {
1008        let mouse = MouseSimulator::new();
1009        assert_eq!(mouse.position(), (0.0, 0.0));
1010    }
1011
1012    #[test]
1013    fn mouse_simulator_with_seed_and_position() {
1014        let mouse = MouseSimulator::with_seed_and_position(42, 150.0, 300.0);
1015        assert_eq!(mouse.position(), (150.0, 300.0));
1016    }
1017
1018    #[test]
1019    fn compute_path_minimum_steps_for_zero_distance() {
1020        let mut mouse = MouseSimulator::with_seed_and_position(1, 100.0, 100.0);
1021        let path = mouse.compute_path(100.0, 100.0, 100.0, 100.0);
1022        // 12 steps minimum => 13 points (0..=12)
1023        assert!(path.len() >= 13);
1024    }
1025
1026    #[test]
1027    fn compute_path_scales_with_distance() {
1028        let mut mouse_near = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
1029        let mut mouse_far = MouseSimulator::with_seed_and_position(1, 0.0, 0.0);
1030
1031        let short_path = mouse_near.compute_path(0.0, 0.0, 30.0, 0.0);
1032        let long_path = mouse_far.compute_path(0.0, 0.0, 800.0, 0.0);
1033
1034        // Long distance should produce more waypoints.
1035        assert!(long_path.len() > short_path.len());
1036    }
1037
1038    #[test]
1039    fn compute_path_step_cap_at_120() {
1040        let mut mouse = MouseSimulator::with_seed_and_position(99, 0.0, 0.0);
1041        // distance = 10_000 px → would be 1250 steps without cap.
1042        let path = mouse.compute_path(0.0, 0.0, 10_000.0, 0.0);
1043        // 120 steps => 121 points
1044        assert!(path.len() <= 121);
1045    }
1046
1047    #[test]
1048    fn compute_path_endpoint_near_target() {
1049        let mut mouse = MouseSimulator::with_seed_and_position(7, 0.0, 0.0);
1050        let target_x = 500.0_f64;
1051        let target_y = 300.0_f64;
1052        let path = mouse.compute_path(0.0, 0.0, target_x, target_y);
1053        let last = path.last().copied().unwrap_or_default();
1054        // Jitter is tiny; endpoint should be within 5 px.
1055        assert!(
1056            (last.0 - target_x).abs() < 5.0,
1057            "x off by {}",
1058            (last.0 - target_x).abs()
1059        );
1060        assert!(
1061            (last.1 - target_y).abs() < 5.0,
1062            "y off by {}",
1063            (last.1 - target_y).abs()
1064        );
1065    }
1066
1067    #[test]
1068    fn compute_path_startpoint_near_origin() {
1069        let mut mouse = MouseSimulator::with_seed_and_position(3, 50.0, 80.0);
1070        let path = mouse.compute_path(50.0, 80.0, 400.0, 200.0);
1071        // First point should be close to start.
1072        if let Some(first) = path.first() {
1073            assert!((first.0 - 50.0).abs() < 5.0);
1074            assert!((first.1 - 80.0).abs() < 5.0);
1075        }
1076    }
1077
1078    #[test]
1079    fn compute_path_diagonal_movement() {
1080        let mut mouse = MouseSimulator::with_seed_and_position(17, 0.0, 0.0);
1081        let path = mouse.compute_path(0.0, 0.0, 300.0, 400.0);
1082        // 500 px distance → ~62 raw steps, clamped to max(12,62).min(120) = 62 → 63 pts
1083        assert!(path.len() >= 13);
1084        let last = path.last().copied().unwrap_or_default();
1085        assert!((last.0 - 300.0).abs() < 5.0);
1086        assert!((last.1 - 400.0).abs() < 5.0);
1087    }
1088
1089    #[test]
1090    fn compute_path_deterministic_with_same_seed() {
1091        let mut m1 = MouseSimulator::with_seed_and_position(42, 0.0, 0.0);
1092        let mut m2 = MouseSimulator::with_seed_and_position(42, 0.0, 0.0);
1093        let path1 = m1.compute_path(0.0, 0.0, 200.0, 150.0);
1094        let path2 = m2.compute_path(0.0, 0.0, 200.0, 150.0);
1095        assert_eq!(path1.len(), path2.len());
1096        for (a, b) in path1.iter().zip(path2.iter()) {
1097            assert!((a.0 - b.0).abs() < 1e-9);
1098            assert!((a.1 - b.1).abs() < 1e-9);
1099        }
1100    }
1101
1102    #[test]
1103    fn cubic_bezier_at_t0_is_p0() {
1104        let p0 = (10.0, 20.0);
1105        let p1 = (50.0, 100.0);
1106        let p2 = (150.0, 80.0);
1107        let p3 = (200.0, 30.0);
1108        let result = cubic_bezier(p0, p1, p2, p3, 0.0);
1109        assert!((result.0 - p0.0).abs() < 1e-9);
1110        assert!((result.1 - p0.1).abs() < 1e-9);
1111    }
1112
1113    #[test]
1114    fn cubic_bezier_at_t1_is_p3() {
1115        let p0 = (10.0, 20.0);
1116        let p1 = (50.0, 100.0);
1117        let p2 = (150.0, 80.0);
1118        let p3 = (200.0, 30.0);
1119        let result = cubic_bezier(p0, p1, p2, p3, 1.0);
1120        assert!((result.0 - p3.0).abs() < 1e-9);
1121        assert!((result.1 - p3.1).abs() < 1e-9);
1122    }
1123
1124    #[test]
1125    fn rand_f64_is_in_unit_interval() {
1126        let mut state = 12345u64;
1127        for _ in 0..1000 {
1128            let v = rand_f64(&mut state);
1129            assert!((0.0..1.0).contains(&v), "out of range: {v}");
1130        }
1131    }
1132
1133    #[test]
1134    fn rand_range_stays_in_bounds() {
1135        let mut state = 99999u64;
1136        for _ in 0..1000 {
1137            let v = rand_range(&mut state, 10.0, 50.0);
1138            assert!((10.0..50.0).contains(&v), "out of range: {v}");
1139        }
1140    }
1141
1142    #[test]
1143    fn typing_simulator_keystroke_delay_is_positive() {
1144        let mut ts = TypingSimulator::new();
1145        assert!(ts.keystroke_delay().as_millis() > 0);
1146    }
1147
1148    #[test]
1149    fn typing_simulator_keystroke_delay_in_range() {
1150        let mut ts = TypingSimulator::with_seed(123);
1151        for _ in 0..50 {
1152            let d = ts.keystroke_delay();
1153            assert!(
1154                d.as_millis() >= 30 && d.as_millis() <= 200,
1155                "delay out of range: {}ms",
1156                d.as_millis()
1157            );
1158        }
1159    }
1160
1161    #[test]
1162    fn typing_simulator_error_rate_clamps_to_one() {
1163        let ts = TypingSimulator::new().with_error_rate(2.0);
1164        assert!(
1165            (ts.error_rate - 1.0).abs() < 1e-9,
1166            "rate should clamp to 1.0"
1167        );
1168    }
1169
1170    #[test]
1171    fn typing_simulator_error_rate_clamps_to_zero() {
1172        let ts = TypingSimulator::new().with_error_rate(-0.5);
1173        assert!(ts.error_rate.abs() < 1e-9, "rate should clamp to 0.0");
1174    }
1175
1176    #[test]
1177    fn typing_simulator_deterministic_with_same_seed() {
1178        let mut t1 = TypingSimulator::with_seed(999);
1179        let mut t2 = TypingSimulator::with_seed(999);
1180        assert_eq!(t1.keystroke_delay(), t2.keystroke_delay());
1181    }
1182
1183    #[test]
1184    fn adjacent_key_returns_different_char() {
1185        let mut rng = 42u64;
1186        for &ch in &['a', 'b', 's', 'k', 'z', 'm'] {
1187            let adj = adjacent_key(ch, &mut rng);
1188            assert_ne!(adj, ch, "adjacent_key({ch}) should not return itself");
1189        }
1190    }
1191
1192    #[test]
1193    fn adjacent_key_preserves_case() {
1194        let mut rng = 7u64;
1195        let adj = adjacent_key('A', &mut rng);
1196        assert!(
1197            adj.is_uppercase(),
1198            "adjacent_key('A') should return uppercase"
1199        );
1200    }
1201
1202    #[test]
1203    fn adjacent_key_non_alpha_returns_fallback() {
1204        let mut rng = 1u64;
1205        assert_eq!(adjacent_key('!', &mut rng), 'x');
1206        assert_eq!(adjacent_key('5', &mut rng), 'x');
1207    }
1208
1209    #[test]
1210    fn interaction_level_default_is_none() {
1211        assert_eq!(InteractionLevel::default(), InteractionLevel::None);
1212    }
1213
1214    #[test]
1215    fn interaction_simulator_with_seed_is_deterministic() {
1216        let s1 = InteractionSimulator::with_seed(77, InteractionLevel::Low);
1217        let s2 = InteractionSimulator::with_seed(77, InteractionLevel::Low);
1218        assert_eq!(s1.rng, s2.rng);
1219    }
1220
1221    #[test]
1222    fn interaction_simulator_default_is_none_level() {
1223        let sim = InteractionSimulator::default();
1224        assert_eq!(sim.level, InteractionLevel::None);
1225    }
1226
1227    #[test]
1228    fn request_pacer_new_has_expected_defaults() {
1229        let p = RequestPacer::new();
1230        assert_eq!(p.mean_ms, 1_200);
1231        assert_eq!(p.min_ms, 400);
1232        assert_eq!(p.max_ms, 4_000);
1233        assert!(p.last_request.is_none());
1234    }
1235
1236    #[test]
1237    fn request_pacer_with_timing_stores_params() {
1238        let p = RequestPacer::with_timing(500, 100, 200, 2_000);
1239        assert_eq!(p.mean_ms, 500);
1240        assert_eq!(p.std_ms, 100);
1241        assert_eq!(p.min_ms, 200);
1242        assert_eq!(p.max_ms, 2_000);
1243    }
1244
1245    #[test]
1246    fn request_pacer_with_rate_computes_mean() {
1247        // 0.5 rps → mean = 2 000 ms
1248        let p = RequestPacer::with_rate(0.5);
1249        assert_eq!(p.mean_ms, 2_000);
1250        assert_eq!(p.min_ms, 1_000);
1251        assert_eq!(p.max_ms, 4_000);
1252    }
1253
1254    #[test]
1255    fn request_pacer_with_rate_clamps_extreme() {
1256        // Very high rps yields a very small mean delay; mean_ms should still be at least 1 ms
1257        let p = RequestPacer::with_rate(10_000.0);
1258        assert!(p.mean_ms >= 1);
1259    }
1260
1261    #[test]
1262    fn request_pacer_with_timing_swaps_inverted_bounds() {
1263        let p = RequestPacer::with_timing(500, 100, 2_000, 200);
1264        assert_eq!(p.min_ms, 200);
1265        assert_eq!(p.max_ms, 2_000);
1266    }
1267
1268    #[tokio::test]
1269    async fn request_pacer_throttle_first_immediate_then_waits() {
1270        let mut p = RequestPacer::with_timing(25, 0, 25, 25);
1271
1272        // First call should complete immediately.
1273        p.throttle().await;
1274
1275        // Second call should enforce a measurable delay.
1276        let started = Instant::now();
1277        p.throttle().await;
1278        assert!(
1279            started.elapsed() >= Duration::from_millis(15),
1280            "second throttle should wait before returning"
1281        );
1282    }
1283}