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