captchaforge 0.2.40

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
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 human trajectory
//!    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.
//!
//! **Every pointer/scroll action is dispatched as a TRUSTED BiDi input event**
//! ([`crate::behavior`] → `runtime_foxdriver`'s `input.performActions`), so
//! `event.isTrusted === true`. This is load-bearing: the passive scorers this
//! module exists to satisfy (PerimeterX, DataDome, Akamai, reCAPTCHA v3) all gate
//! on `isTrusted`, and a synthetic `new MouseEvent(...)` + `dispatchEvent`
//! carries `isTrusted === false`: a burst of which is a *stronger* bot tell than
//! no motion at all. Warming therefore never hand-rolls JS-dispatched events; it
//! routes exclusively through the same trusted human-input primitives the captcha
//! solver uses. (JS `evaluate` is still used for *reads*, viewport size, element
//! geometry, which emit no events.)
//!
//! 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 crate::behavior::{self, ScrollDirection};
use crate::Page;
use anyhow::{anyhow, Result};
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;

/// Convenience wrapper: run [`warm_session`] with the default budget.
///
/// Used by the top-level [`crate::solve_url`] one-call helper after
/// navigation so SDK consumers don't need to pick a duration. Best-effort 
/// returns Ok even if the underlying warm fails partway, so the
/// solver chain still gets a chance to run.
pub async fn apply_default_warmup(page: &Page) -> Result<()> {
    // Best-effort by contract (we still return Ok so the solver chain runs), but
    // Law 10: surface the failure instead of `let _ =` swallowing it. A silently
    // skipped warmup leaves the first solve attempt with no behavioral cover 
    // exactly the degradation the `warm_session` body already warns about per
    // action, made invisible here. Marker `(continuing)` matches the warmup audit.
    if let Err(e) = warm_session(page, Duration::from_millis(DEFAULT_WARMUP_MS)).await {
        tracing::warn!(error = %e, "captchaforge: pre-solve warmup failed (continuing); behavioral cover is weaker for the first solve attempt");
    }
    Ok(())
}

/// 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. TRUSTED BiDi pointer move.
            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 = behavior::mouse_move_human(page, cx, cy, tx, ty).await;
                cx = tx;
                cy = ty;
                r
            }
            // Small scroll down + pause + scroll back: ~20%. TRUSTED BiDi wheel.
            // Best-effort like every arm: a failure is returned (logged by the
            // loop), never propagated out of `warm_session`.
            5..=6 => {
                let down = rng.gen_range(80..240);
                let up = rng.gen_range((down / 2)..=down);
                match behavior::scroll_realistic(page, ScrollDirection::Down, down).await {
                    Ok(()) => {
                        tokio::time::sleep(Duration::from_millis(rng.gen_range(180..500))).await;
                        behavior::scroll_realistic(page, ScrollDirection::Up, up).await
                    }
                    Err(e) => Err(e),
                }
            }
            // Hover (no click) over a non-form element: ~10%. The element
            // geometry is *read* via evaluate (no event emitted); the pointer
            // move to it is a TRUSTED BiDi event.
            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 = behavior::mouse_move_human(page, cx, cy, tx, ty).await;
                            cx = tx;
                            cy = ty;
                            // Dwell so the hovered element's mouseenter/over
                            // listeners fire the way a resting cursor triggers
                            // them (bots warp past without dwelling).
                            tokio::time::sleep(Duration::from_millis(rng.gen_range(200..600)))
                                .await;
                            r
                        } else {
                            Ok(())
                        }
                    } else {
                        Ok(())
                    }
                } else {
                    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, but Law 10: a
        // swallowed warmup failure means the challenge sees LESS human
        // pre-activity than intended (a behavioral-cover degradation), so surface
        // it loudly rather than hiding it at debug.
        if let Err(e) = result {
            tracing::warn!(error = %e, "captchaforge: warmup action failed (continuing); behavioral cover is weaker for this solve");
        }

        // Inter-action think pause varies cadence.
        tokio::time::sleep(Duration::from_millis(rng.gen_range(80..250))).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);
    }
}