captchaforge 0.2.14

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
/// Human behavior simulation — realistic mouse, keyboard, and scroll patterns.
///
/// Anti-bot detectors analyze:
///   - Mouse trajectory (bots move in straight lines)
///   - Typing cadence (bots type at constant speed)
///   - Scroll patterns (bots scroll uniformly)
///   - Idle time (bots never pause)
///
/// This module generates Bézier-curve mouse paths, variable typing delays,
/// and natural scroll increments. All async fns use SmallRng (Send-safe) so
/// futures remain Send-compatible for multi-threaded Tokio runtimes.
use chromiumoxide::cdp::browser_protocol::input::{
    DispatchKeyEventParams, DispatchKeyEventType, DispatchMouseEventParams, DispatchMouseEventType,
    MouseButton,
};
use chromiumoxide::Page;
use rand::{Rng, SeedableRng};
use std::time::Duration;

/// Move mouse along a Bézier curve from (x0,y0) to (x1,y1).
/// Generates 8-20 intermediate points with realistic timing.
///
/// # Example
///
/// ```rust,no_run
/// use captchaforge::behavior::mouse_move_bezier;
/// # async fn example(page: &chromiumoxide::Page) -> anyhow::Result<()> {
/// mouse_move_bezier(page, 0.0, 0.0, 400.0, 300.0).await?;
/// # Ok(()) }
/// ```
pub async fn mouse_move_bezier(
    page: &Page,
    x0: f64,
    y0: f64,
    x1: f64,
    y1: f64,
) -> anyhow::Result<()> {
    let mut rng = rand::rngs::StdRng::from_entropy();
    let steps = rng.gen_range(8..=20);

    // Two random control points for the cubic Bézier
    let cx1 = x0 + (x1 - x0) * rng.gen_range(0.2..0.5) + rng.gen_range(-30.0..30.0);
    let cy1 = y0 + (y1 - y0) * rng.gen_range(0.1..0.4) + rng.gen_range(-20.0..20.0);
    let cx2 = x0 + (x1 - x0) * rng.gen_range(0.6..0.9) + rng.gen_range(-20.0..20.0);
    let cy2 = y0 + (y1 - y0) * rng.gen_range(0.5..0.8) + rng.gen_range(-15.0..15.0);

    for i in 0..=steps {
        let t = i as f64 / steps as f64;
        let u = 1.0 - t;

        // Cubic Bézier: B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3
        let x = u * u * u * x0 + 3.0 * u * u * t * cx1 + 3.0 * u * t * t * cx2 + t * t * t * x1;
        let y = u * u * u * y0 + 3.0 * u * u * t * cy1 + 3.0 * u * t * t * cy2 + t * t * t * y1;

        let params = DispatchMouseEventParams::builder()
            .r#type(DispatchMouseEventType::MouseMoved)
            .x(x)
            .y(y)
            .build()
            .map_err(anyhow::Error::msg)?;
        page.execute(params).await?;

        // Variable delay: faster in middle, slower at start/end (ease-in-out)
        let ease = (std::f64::consts::PI * t).sin(); // 0 at endpoints, 1 at midpoint
        let base_ms = rng.gen_range(8..25);
        let delay_ms = base_ms + ((1.0 - ease) * 15.0) as u64;
        tokio::time::sleep(Duration::from_millis(delay_ms)).await;
    }

    Ok(())
}

/// Click at position with realistic mouse-down delay.
///
/// # Example
///
/// ```rust,no_run
/// use captchaforge::behavior::click_realistic;
/// # async fn example(page: &chromiumoxide::Page) -> anyhow::Result<()> {
/// click_realistic(page, 200.0, 150.0).await?;
/// # Ok(()) }
/// ```
pub async fn click_realistic(page: &Page, x: f64, y: f64) -> anyhow::Result<()> {
    let mut rng = rand::rngs::StdRng::from_entropy();

    // Mouse down
    let down = DispatchMouseEventParams::builder()
        .r#type(DispatchMouseEventType::MousePressed)
        .x(x)
        .y(y)
        .button(MouseButton::Left)
        .click_count(1)
        .build()
        .map_err(anyhow::Error::msg)?;
    page.execute(down).await?;

    // Realistic hold time: 50-150ms
    tokio::time::sleep(Duration::from_millis(rng.gen_range(50..150))).await;

    // Mouse up
    let up = DispatchMouseEventParams::builder()
        .r#type(DispatchMouseEventType::MouseReleased)
        .x(x)
        .y(y)
        .button(MouseButton::Left)
        .click_count(1)
        .build()
        .map_err(anyhow::Error::msg)?;
    page.execute(up).await?;

    Ok(())
}

/// Type text with human-like cadence: variable inter-key delays, occasional pauses.
/// Average typing speed: ~200ms per character (60 WPM) with variance.
///
/// # Example
///
/// ```rust,no_run
/// use captchaforge::behavior::type_realistic;
/// # async fn example(page: &chromiumoxide::Page) -> anyhow::Result<()> {
/// type_realistic(page, "hello world").await?;
/// # Ok(()) }
/// ```
pub async fn type_realistic(page: &Page, text: &str) -> anyhow::Result<()> {
    let mut rng = rand::rngs::StdRng::from_entropy();

    for (i, ch) in text.chars().enumerate() {
        let key_text = ch.to_string();

        // Key down
        let down = DispatchKeyEventParams::builder()
            .r#type(DispatchKeyEventType::KeyDown)
            .text(&key_text)
            .build()
            .map_err(anyhow::Error::msg)?;
        page.execute(down).await?;

        // Key press delay: 30-80ms
        tokio::time::sleep(Duration::from_millis(rng.gen_range(30..80))).await;

        // Key up
        let up = DispatchKeyEventParams::builder()
            .r#type(DispatchKeyEventType::KeyUp)
            .text(&key_text)
            .build()
            .map_err(anyhow::Error::msg)?;
        page.execute(up).await?;

        // Inter-key delay: 80-250ms with occasional longer pauses
        let base = rng.gen_range(80..250);
        let pause = if i > 0 && i % rng.gen_range(4..10) == 0 {
            rng.gen_range(200..600) // "thinking" pause every few characters
        } else {
            0
        };
        tokio::time::sleep(Duration::from_millis(base + pause)).await;
    }

    Ok(())
}

/// Scroll with human-like pattern: variable speed, direction micro-corrections.
///
/// # Example
///
/// ```rust,no_run
/// use captchaforge::behavior::{scroll_realistic, ScrollDirection};
/// # async fn example(page: &chromiumoxide::Page) -> anyhow::Result<()> {
/// scroll_realistic(page, ScrollDirection::Down, 500).await?;
/// # Ok(()) }
/// ```
pub async fn scroll_realistic(
    page: &Page,
    direction: ScrollDirection,
    amount: u32,
) -> anyhow::Result<()> {
    let mut rng = rand::rngs::StdRng::from_entropy();
    let steps = rng.gen_range(3..=8).min(amount);
    let per_step = (amount as f64 / steps as f64) as i32;

    for _ in 0..steps {
        let delta = match direction {
            ScrollDirection::Down => per_step + rng.gen_range(-20..20),
            ScrollDirection::Up => -(per_step + rng.gen_range(-20..20)),
        };

        let js = format!("window.scrollBy(0, {})", delta);
        page.evaluate(js).await?;

        // Variable scroll delay: 100-400ms
        tokio::time::sleep(Duration::from_millis(rng.gen_range(100..400))).await;
    }

    Ok(())
}

/// Random idle pause — simulates reading/thinking time.
///
/// # Example
///
/// ```rust,no_run
/// use captchaforge::behavior::idle_pause;
///
/// # async fn example() {
/// idle_pause().await;
/// # }
/// ```
pub async fn idle_pause() {
    let ms = rand::rngs::StdRng::from_entropy().gen_range(500..2000);
    tokio::time::sleep(Duration::from_millis(ms)).await;
}

/// Short micro-pause between actions (100-400ms).
///
/// # Example
///
/// ```rust,no_run
/// use captchaforge::behavior::micro_pause;
///
/// # async fn example() {
/// micro_pause().await;
/// # }
/// ```
pub async fn micro_pause() {
    let ms = rand::rngs::StdRng::from_entropy().gen_range(100..400);
    tokio::time::sleep(Duration::from_millis(ms)).await;
}

/// Direction for realistic scroll simulation.
///
/// # Example
///
/// ```
/// use captchaforge::behavior::ScrollDirection;
///
/// let down = ScrollDirection::Down;
/// let up = ScrollDirection::Up;
/// assert_ne!(down, up);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollDirection {
    Up,
    Down,
}

#[cfg(test)]
mod tests {
    #[test]
    fn bezier_control_points_within_range() {
        let (x0, _y0) = (100.0, 200.0);
        let (x1, _y1) = (500.0, 400.0);
        let t0 = 0.0_f64;
        let t1 = 1.0_f64;
        let u0 = 1.0 - t0;
        let u1 = 1.0 - t1;
        let bx0 = u0.powi(3) * x0 + t0.powi(3) * x1;
        let bx1 = u1.powi(3) * x0 + t1.powi(3) * x1;
        assert!((bx0 - x0).abs() < 0.001);
        assert!((bx1 - x1).abs() < 0.001);
    }

    use super::*;

    #[test]
    fn scroll_direction_clone_copy() {
        let d = ScrollDirection::Down;
        let d2 = d;
        assert_eq!(d, d2);
    }

    #[test]
    fn scroll_direction_debug() {
        let d = ScrollDirection::Up;
        assert!(format!("{:?}", d).contains("Up"));
    }

    #[test]
    fn bezier_midpoint_t_0_5() {
        let x0 = 0.0;
        let y0 = 0.0;
        let x1 = 100.0;
        let y1 = 0.0;
        let cx1 = 25.0;
        let cy1 = 50.0;
        let cx2 = 75.0;
        let cy2 = 50.0;
        let t = 0.5;
        let u = 1.0 - t;
        let x = u * u * u * x0 + 3.0 * u * u * t * cx1 + 3.0 * u * t * t * cx2 + t * t * t * x1;
        let y = u * u * u * y0 + 3.0 * u * u * t * cy1 + 3.0 * u * t * t * cy2 + t * t * t * y1;
        assert!((x - 50.0_f64).abs() < 1.0);
        assert!((y - 37.5_f64).abs() < 1.0);
    }

    #[test]
    fn bezier_is_linear_when_control_points_on_line() {
        let x0 = 0.0;
        let x1 = 100.0;
        let cx1 = 33.0;
        let cx2 = 66.0;
        for i in 0..=10 {
            let t = i as f64 / 10.0;
            let u = 1.0 - t;
            let x = u * u * u * x0 + 3.0 * u * u * t * cx1 + 3.0 * u * t * t * cx2 + t * t * t * x1;
            assert!((x - (t * 100.0)).abs() < 1.0, "t={} x={}", t, x);
        }
    }

    #[test]
    fn ease_function_zero_at_endpoints() {
        let ease_0 = (std::f64::consts::PI * 0.0).sin();
        let ease_1 = (std::f64::consts::PI * 1.0).sin();
        assert!((ease_0).abs() < 0.001);
        assert!((ease_1).abs() < 0.001);
    }

    #[test]
    fn ease_function_max_at_midpoint() {
        let ease = (std::f64::consts::PI * 0.5).sin();
        assert!((ease - 1.0).abs() < 0.001);
    }

    #[test]
    fn bezier_formula_degenerate_case_same_point() {
        let x0 = 50.0;
        let x1 = 50.0;
        let cx1 = 50.0;
        let cx2 = 50.0;
        for i in 0..=10 {
            let t = i as f64 / 10.0;
            let u = 1.0 - t;
            let x = u * u * u * x0 + 3.0 * u * u * t * cx1 + 3.0 * u * t * t * cx2 + t * t * t * x1;
            assert!((x - 50.0).abs() < 0.001);
        }
    }
}