Skip to main content

stealthscraper_rs/
behavior.rs

1#![cfg(feature = "browser")]
2//! Human-interaction emulation: Bézier-curve mouse paths and human-like
3//! keystroke timing, used to make CDP-driven input look organic.
4
5use rand::RngExt;
6use rand_distr::{Distribution, Normal};
7use std::time::Duration;
8
9/// Represents a 2D coordinate for mouse movements and positions.
10#[derive(Debug, Clone)]
11pub struct Point {
12    /// The X coordinate.
13    pub x: f64,
14    /// The Y coordinate.
15    pub y: f64,
16}
17
18/// Generates a human-like mouse path using Bezier curves and varying speed.
19pub fn generate_mouse_path(start: Point, end: Point, num_points: usize) -> Vec<Point> {
20    let mut rng = rand::rng();
21
22    // Generate two control points for the cubic Bezier curve that drift away from the straight line.
23    let dx = end.x - start.x;
24    let dy = end.y - start.y;
25    let dist = (dx * dx + dy * dy).sqrt();
26
27    // Add noise to control points relative to distance
28    let noise_x = dist * 0.2;
29    let noise_y = dist * 0.2;
30
31    let cp1 = Point {
32        x: start.x + dx * 0.33 + (rng.random::<f64>() - 0.5) * noise_x,
33        y: start.y + dy * 0.33 + (rng.random::<f64>() - 0.5) * noise_y,
34    };
35
36    let cp2 = Point {
37        x: start.x + dx * 0.66 + (rng.random::<f64>() - 0.5) * noise_x,
38        y: start.y + dy * 0.66 + (rng.random::<f64>() - 0.5) * noise_y,
39    };
40
41    let mut path = Vec::with_capacity(num_points);
42    for i in 0..num_points {
43        let t = i as f64 / (num_points - 1) as f64;
44        // Ease-out function to simulate slowing down as reaching the target
45        let t_eased = 1.0 - (1.0 - t).powi(3);
46
47        let u = 1.0 - t_eased;
48        let tt = t_eased * t_eased;
49        let uu = u * u;
50        let uuu = uu * u;
51        let ttt = tt * t_eased;
52
53        let x = uuu * start.x + 3.0 * uu * t_eased * cp1.x + 3.0 * u * tt * cp2.x + ttt * end.x;
54        let y = uuu * start.y + 3.0 * uu * t_eased * cp1.y + 3.0 * u * tt * cp2.y + ttt * end.y;
55
56        path.push(Point { x, y });
57    }
58
59    path
60}
61
62/// Simulates human typing delays. Most keys are typed reasonably fast, but sometimes there are micro-pauses.
63pub fn calculate_typing_delay() -> Duration {
64    let mut rng = rand::rng();
65    let normal = Normal::new(50.0, 15.0).unwrap();
66    let val = normal.sample(&mut rng);
67
68    let base_delay = if val < 20.0 { 20 } else { val as u64 };
69
70    // 5% chance of a longer pause (e.g. thinking or reaching for a hard key)
71    if rng.random_bool(0.05) {
72        let pause = rng.random_range(150..400);
73        Duration::from_millis(base_delay + pause)
74    } else {
75        Duration::from_millis(base_delay)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn test_generate_mouse_path() {
85        let start = Point { x: 0.0, y: 0.0 };
86        let end = Point { x: 100.0, y: 100.0 };
87        let path = generate_mouse_path(start, end, 50);
88
89        assert_eq!(path.len(), 50);
90
91        let first = path.first().unwrap();
92        assert!((first.x.abs() < 1.0) && (first.y.abs() < 1.0));
93
94        let last = path.last().unwrap();
95        assert!((last.x - 100.0).abs() < 1.0 && (last.y - 100.0).abs() < 1.0);
96    }
97
98    #[test]
99    fn test_calculate_typing_delay() {
100        let delay = calculate_typing_delay();
101        assert!(delay.as_millis() >= 20); // Minimum delay
102    }
103
104    #[test]
105    fn test_calculate_typing_delay_long_pause_branch() {
106        let mut hit_long_pause = false;
107        // 5% chance means after 200 tries we should almost certainly hit it
108        for _ in 0..200 {
109            if calculate_typing_delay().as_millis() >= 170 {
110                hit_long_pause = true;
111                break;
112            }
113        }
114        assert!(hit_long_pause);
115    }
116}