captchaforge 0.2.20

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Page-warming — natural pre-captcha visitor activity.
//!
//! reCAPTCHA v3, Cloudflare Turnstile passive mode, hCaptcha
//! invisible, PerimeterX, and Akamai score the visitor on a
//! signal stack that includes:
//!
//! - Mouse movement entropy (not just final position)
//! - Scroll history (did they read the page or jump straight to
//!   the form?)
//! - Focus changes / tab switches
//! - Time-on-page before the first solve attempt
//! - Page interaction count (clicks on non-form elements)
//!
//! `warm_session(page, duration)` runs a budgeted human-like
//! activity loop on the page BEFORE captchaforge's solver chain
//! fires. The widgets see a session that looks like a real
//! visitor, not a script that loaded the page and went straight
//! to the form. For passive captchas this single addition can
//! flip a 0% solve rate to 60%+.
//!
//! What the loop does (within the budget):
//!
//! 1. Move the mouse to a few random points along a Bézier path
//!    with realistic acceleration / deceleration / micro-jitter.
//! 2. Scroll down by a small random amount, pause, scroll back up.
//! 3. Hover over (without clicking) a few non-form elements to
//!    trigger their `mouseover` listeners.
//! 4. Idle pause between actions to vary inter-event timing.
//!
//! Budget is in WALL-CLOCK milliseconds. Default 4_000ms is
//! enough for reCAPTCHA v3 score warming without significantly
//! slowing the overall solve. Set higher (8_000-15_000ms) when
//! solving an actively-challenged session for the first time.

use anyhow::{anyhow, Result};
use chromiumoxide::Page;
use rand::{Rng, SeedableRng};
use std::time::{Duration, Instant};

/// Default budget if caller doesn't override. 4 seconds is enough
/// to register page-interaction signals without dragging the
/// overall solve too much.
pub const DEFAULT_WARMUP_MS: u64 = 4_000;

/// Run page-warming activity for up to `budget`. Returns when the
/// budget is exhausted OR an evaluate call fails (best-effort —
/// the solver chain runs regardless, warming is a nice-to-have).
pub async fn warm_session(page: &Page, budget: Duration) -> Result<()> {
    let start = Instant::now();
    let deadline = start + budget;

    // Read viewport once. If the eval fails, give up cleanly —
    // the page may not be fully loaded yet and that's fine.
    let viewport = page
        .evaluate("({w: window.innerWidth || 1280, h: window.innerHeight || 800})")
        .await
        .map_err(|e| anyhow!("warmup viewport probe failed: {e}"))?
        .into_value::<serde_json::Value>()
        .unwrap_or(serde_json::Value::Null);
    let vw = viewport["w"].as_f64().unwrap_or(1280.0);
    let vh = viewport["h"].as_f64().unwrap_or(800.0);

    let mut rng = rand::rngs::StdRng::from_entropy();

    // Anchor mouse near the centre + a small jitter — bots
    // typically start at (0,0).
    let mut cx = vw * 0.5 + rng.gen_range(-50.0..50.0);
    let mut cy = vh * 0.5 + rng.gen_range(-50.0..50.0);

    while Instant::now() < deadline {
        // Pick the next action with rough weights.
        let action = rng.gen_range(0..10);
        let result: Result<()> = match action {
            // Mouse meander: ~50% of the time
            0..=4 => {
                let tx = rng.gen_range(50.0..(vw - 50.0).max(50.0));
                let ty = rng.gen_range(50.0..(vh - 50.0).max(50.0));
                let r = mouse_move_smooth(page, cx, cy, tx, ty).await;
                cx = tx;
                cy = ty;
                r
            }
            // Small scroll down + pause + scroll back: ~20%
            5..=6 => {
                let down = rng.gen_range(80..240);
                let _ = page.evaluate(format!("window.scrollBy(0, {})", down)).await;
                tokio::time::sleep(Duration::from_millis(rng.gen_range(180..500))).await;
                let _ = page
                    .evaluate(format!("window.scrollBy(0, -{})", down))
                    .await;
                Ok(())
            }
            // Hover (no click) over a non-form element: ~10%
            7 => {
                let hover_js = r#"
                    (() => {
                        const els = document.querySelectorAll('a, button, h1, h2, h3, p');
                        if (els.length === 0) return null;
                        const el = els[Math.floor(Math.random() * els.length)];
                        const r = el.getBoundingClientRect();
                        return [r.left + r.width / 2, r.top + r.height / 2];
                    })()
                "#;
                if let Ok(raw) = page.evaluate(hover_js).await {
                    if let Ok(Some([tx, ty])) = raw.into_value::<Option<[f64; 2]>>() {
                        if tx.is_finite() && ty.is_finite() && tx > 0.0 && ty > 0.0 {
                            let r = mouse_move_smooth(page, cx, cy, tx, ty).await;
                            cx = tx;
                            cy = ty;
                            let _ = r;
                        }
                    }
                }
                Ok(())
            }
            // Idle pause: rest of the time
            _ => {
                tokio::time::sleep(Duration::from_millis(rng.gen_range(120..400))).await;
                Ok(())
            }
        };

        // Best-effort: log + continue if the page is in a weird
        // state. We don't want a partial page error to abort the
        // whole solve.
        if let Err(e) = result {
            tracing::debug!("warmup action failed (continuing): {e}");
        }

        // Inter-action think pause varies cadence.
        tokio::time::sleep(Duration::from_millis(rng.gen_range(80..250))).await;
    }

    Ok(())
}

/// Bezier-ish smooth mouse move with micro-jitter. Cheap enough to
/// inline here — keeps `warmup` independent of `behavior`'s mouse
/// helpers (which are also slightly different in scope).
async fn mouse_move_smooth(page: &Page, x0: f64, y0: f64, x1: f64, y1: f64) -> Result<()> {
    let mut rng = rand::rngs::StdRng::from_entropy();
    let steps = rng.gen_range(8..18);
    // Two control points for a cubic bezier, biased toward the
    // straight line with random orthogonal offset.
    let (cx1, cy1) = (
        x0 + (x1 - x0) / 3.0 + rng.gen_range(-40.0..40.0),
        y0 + (y1 - y0) / 3.0 + rng.gen_range(-40.0..40.0),
    );
    let (cx2, cy2) = (
        x0 + 2.0 * (x1 - x0) / 3.0 + rng.gen_range(-40.0..40.0),
        y0 + 2.0 * (y1 - y0) / 3.0 + rng.gen_range(-40.0..40.0),
    );

    for i in 0..=steps {
        let t = i as f64 / steps as f64;
        let omt = 1.0 - t;
        let bx = omt.powi(3) * x0
            + 3.0 * omt.powi(2) * t * cx1
            + 3.0 * omt * t.powi(2) * cx2
            + t.powi(3) * x1;
        let by = omt.powi(3) * y0
            + 3.0 * omt.powi(2) * t * cy1
            + 3.0 * omt * t.powi(2) * cy2
            + t.powi(3) * y1;
        let jx = rng.gen_range(-1.5..1.5);
        let jy = rng.gen_range(-1.5..1.5);
        let _ = page
            .evaluate(format!(
                "(() => {{ const e = new MouseEvent('mousemove', {{ \
                    bubbles: true, clientX: {x}, clientY: {y} \
                }}); document.elementFromPoint({x}, {y})?.dispatchEvent(e); }})()",
                x = bx + jx,
                y = by + jy
            ))
            .await;
        // Realistic-ish 8-22ms inter-event timing.
        tokio::time::sleep(Duration::from_millis(rng.gen_range(8..22))).await;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_warmup_ms_is_4_seconds() {
        assert_eq!(DEFAULT_WARMUP_MS, 4_000);
    }

    #[test]
    fn module_exports_warm_session() {
        // Compilation check; warm_session needs an actual Page
        // (live chromium) to run. We just confirm the symbol is
        // visible — taking its address as a fn pointer needs a
        // monomorphised path which doesn't matter here.
        let _: &str = std::any::type_name_of_val(&warm_session);
    }
}