captchaforge 0.2.4

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
use super::*;
use rand::{Rng, SeedableRng};
use tracing::{debug, warn};

// ─── BehavioralCaptchaSolver ─────────────────────────────────────────────────

/// Bypasses passive CAPTCHAs through realistic interaction patterns.
///
/// Cloudflare Turnstile: Moves the mouse naturally over the page, waits for
/// the JS fingerprint check, then clicks the checkbox when visible.
///
/// reCAPTCHA v3: Generates natural browsing interactions to build a high
/// "human score" before triggering the protected action.
pub struct BehavioralCaptchaSolver {
    pub(crate) config: SolveConfig,
}

impl Default for BehavioralCaptchaSolver {
    fn default() -> Self {
        Self::new()
    }
}

impl BehavioralCaptchaSolver {
    pub fn new() -> Self {
        Self {
            config: SolveConfig::default(),
        }
    }

    /// Provide a custom solve configuration.
    pub fn with_config(mut self, config: SolveConfig) -> Self {
        self.config = config;
        self
    }

    /// Simulate page-level human interactions to raise the reCAPTCHA v3 score.
    async fn natural_browsing(&self, page: &Page) -> Result<()> {
        let mut rng = rand::rngs::StdRng::from_entropy();

        // Viewport dimensions
        let vp_js = "({ w: window.innerWidth || 1280, h: window.innerHeight || 800 })";
        let vp = page
            .evaluate(vp_js)
            .await?
            .into_value::<serde_json::Value>()
            .unwrap_or(serde_json::Value::Null);
        let vw = vp["w"].as_f64().unwrap_or(1280.0);
        let vh = vp["h"].as_f64().unwrap_or(800.0);

        // 3–5 random mouse meanders
        let meanders = rng.gen_range(3..=5);
        let mut cx = vw * 0.5;
        let mut cy = vh * 0.5;
        for _ in 0..meanders {
            let tx = rng.gen_range(80.0..vw - 80.0_f64);
            let ty = rng.gen_range(80.0..vh - 80.0_f64);
            crate::behavior::mouse_move_bezier(page, cx, cy, tx, ty).await?;
            cx = tx;
            cy = ty;
            crate::behavior::micro_pause().await;
        }

        // 1–2 small scrolls
        let scrolls = rng.gen_range(1..=2);
        for _ in 0..scrolls {
            crate::behavior::scroll_realistic(
                page,
                crate::behavior::ScrollDirection::Down,
                rng.gen_range(80..300),
            )
            .await?;
            crate::behavior::idle_pause().await;
            crate::behavior::scroll_realistic(
                page,
                crate::behavior::ScrollDirection::Up,
                rng.gen_range(40..200),
            )
            .await?;
        }

        Ok(())
    }

    /// Wait for the reCAPTCHA v2 checkbox to appear and click it.
    async fn click_recaptcha_v2(&self, page: &Page) -> Result<()> {
        let mut rng = rand::rngs::StdRng::from_entropy();

        for attempt in 0..self.config.checkbox_max_attempts {
            tokio::time::sleep(Duration::from_millis(self.config.checkbox_poll_interval_ms)).await;

            if let Some((x, y)) = crate::frame::find_element_centre_in_frames(
                page,
                "#recaptcha-anchor, .recaptcha-checkbox",
            )
            .await?
            {
                let ox = x + rng.gen_range(-200.0..200.0_f64);
                let oy = y + rng.gen_range(-100.0..100.0_f64);
                crate::behavior::mouse_move_bezier(page, ox, oy, x, y).await?;
                crate::behavior::click_realistic(page, x, y).await?;
                debug!(attempt, x, y, "reCAPTCHA v2 checkbox clicked");
                return Ok(());
            }

            debug!(attempt, "waiting for reCAPTCHA v2 checkbox…");
        }

        Err(anyhow!(
            "reCAPTCHA v2 checkbox not found after {} attempts",
            self.config.checkbox_max_attempts
        ))
    }

    /// Wait for the Turnstile checkbox to appear and click it.
    async fn click_turnstile(&self, page: &Page) -> Result<()> {
        let mut rng = rand::rngs::StdRng::from_entropy();

        // Give Turnstile JS time to inject the iframe.
        for attempt in 0..self.config.checkbox_max_attempts {
            tokio::time::sleep(Duration::from_millis(self.config.checkbox_poll_interval_ms)).await;

            if let Some((x, y)) = crate::frame::find_element_centre_in_frames(
                page,
                "input[type='checkbox'], [data-testid='checkbox']",
            )
            .await?
            {
                // Approach from a random direction.
                let ox = x + rng.gen_range(-200.0..200.0_f64);
                let oy = y + rng.gen_range(-100.0..100.0_f64);
                crate::behavior::mouse_move_bezier(page, ox, oy, x, y).await?;
                crate::behavior::click_realistic(page, x, y).await?;
                debug!(attempt, x, y, "turnstile checkbox clicked");
                return Ok(());
            }

            debug!(attempt, "waiting for Turnstile checkbox…");
        }

        Err(anyhow!(
            "Turnstile checkbox not found after {} attempts",
            self.config.checkbox_max_attempts
        ))
    }
}

#[async_trait]
impl CaptchaSolver for BehavioralCaptchaSolver {
    fn name(&self) -> &'static str {
        "BehavioralCaptchaSolver"
    }

    fn method(&self) -> SolveMethod {
        SolveMethod::BehavioralBypass
    }

    fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
        use crate::captcha_detect::DetectedCaptcha;
        // Behavioral can attempt any captcha that surfaces a clickable
        // checkbox / slider / page button — including TOML-rule
        // vendors like DataDome / PerimeterX whose providers
        // recommend BehavioralBypass first.
        matches!(
            kind,
            DetectedCaptcha::Turnstile
                | DetectedCaptcha::RecaptchaV2
                | DetectedCaptcha::RecaptchaV3
                | DetectedCaptcha::PowCaptcha
                | DetectedCaptcha::SliderCaptcha
                | DetectedCaptcha::MultiStepCaptcha
                | DetectedCaptcha::ShadowDomCaptcha
                | DetectedCaptcha::Custom(_)
        )
    }

    async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
        use crate::captcha_detect::DetectedCaptcha;
        let t0 = Instant::now();

        match &captcha_info.kind {
            DetectedCaptcha::RecaptchaV2 => {
                // Click the checkbox.
                self.click_recaptcha_v2(page).await?;

                // Wait for reCAPTCHA to validate (might just solve it).
                for _ in 0..self.config.token_max_attempts {
                    tokio::time::sleep(Duration::from_millis(self.config.token_poll_interval_ms))
                        .await;
                    let solved_js = r#"
                        !!(document.querySelector('[name="g-recaptcha-response"][value]:not([value=""])') ||
                           document.querySelector('input[name="g-recaptcha-response"][value!=""]'))
                    "#;
                    let solved = page
                        .evaluate(solved_js)
                        .await?
                        .into_value::<bool>()
                        .unwrap_or(false);
                    if solved {
                        let cookies = crate::cookies::capture_from_page(page)
                            .await
                            .unwrap_or_default();
                        return Ok(CaptchaSolveResult {
                            solution: "recaptcha_v2:behavioral".to_string(),
                            confidence: 0.90,
                            method: SolveMethod::BehavioralBypass,
                            time_ms: t0.elapsed().as_millis() as u64,
                            success: true,
                            screenshot: None,
                            cookies,
                        });
                    }

                    // Check if challenge popped up (e.g., image selection).
                    let challenge_visible_js = r#"
                        (function() {
                            const f = document.querySelector('iframe[src*="google.com/recaptcha/api2/bframe"]');
                            if (!f) return false;
                            const r = f.getBoundingClientRect();
                            return r.width > 0 && r.height > 0 && window.getComputedStyle(f).visibility !== 'hidden';
                        })()
                    "#;
                    let challenge_visible = page
                        .evaluate(challenge_visible_js)
                        .await?
                        .into_value::<bool>()
                        .unwrap_or(false);
                    if challenge_visible {
                        debug!("reCAPTCHA v2 challenge popped up; behavioral bypass failed");
                        return Ok(CaptchaSolveResult::failure(
                            SolveMethod::BehavioralBypass,
                            t0.elapsed().as_millis() as u64,
                        ));
                    }

                    // Also verify via frame search in case the token was injected into an iframe.
                    if crate::frame::verify_token_in_frames(page, "g-recaptcha-response").await? {
                        let cookies = crate::cookies::capture_from_page(page)
                            .await
                            .unwrap_or_default();
                        return Ok(CaptchaSolveResult {
                            solution: "recaptcha_v2:behavioral".to_string(),
                            confidence: 0.90,
                            method: SolveMethod::BehavioralBypass,
                            time_ms: t0.elapsed().as_millis() as u64,
                            success: true,
                            screenshot: None,
                            cookies,
                        });
                    }
                }

                Ok(CaptchaSolveResult::failure(
                    SolveMethod::BehavioralBypass,
                    t0.elapsed().as_millis() as u64,
                ))
            }

            DetectedCaptcha::Turnstile => {
                // Some Turnstile configurations (including test keys) pre-populate
                // the token without any interaction.  Check first before spending
                // time on mouse movements.
                if crate::frame::verify_token_in_frames(page, "cf-turnstile-response").await? {
                    let cookies = crate::cookies::capture_from_page(page)
                        .await
                        .unwrap_or_default();
                    return Ok(CaptchaSolveResult {
                        solution: "turnstile:behavioral".to_string(),
                        confidence: 0.95,
                        method: SolveMethod::BehavioralBypass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: true,
                        screenshot: None,
                        cookies,
                    });
                }

                // Warm up behavioral signals, then click.
                self.natural_browsing(page).await?;
                if self.click_turnstile(page).await.is_ok() {
                    // Wait for Turnstile to validate after the click.
                    for _ in 0..self.config.token_max_attempts {
                        tokio::time::sleep(Duration::from_millis(
                            self.config.token_poll_interval_ms,
                        ))
                        .await;
                        if crate::frame::verify_token_in_frames(page, "cf-turnstile-response")
                            .await?
                        {
                            let cookies = crate::cookies::capture_from_page(page)
                                .await
                                .unwrap_or_default();
                            return Ok(CaptchaSolveResult {
                                solution: "turnstile:behavioral".to_string(),
                                confidence: 0.80,
                                method: SolveMethod::BehavioralBypass,
                                time_ms: t0.elapsed().as_millis() as u64,
                                success: true,
                                screenshot: None,
                                cookies,
                            });
                        }
                    }
                }

                Ok(CaptchaSolveResult::failure(
                    SolveMethod::BehavioralBypass,
                    t0.elapsed().as_millis() as u64,
                ))
            }

            DetectedCaptcha::RecaptchaV3 => {
                // Generate natural interactions before triggering the protected action.
                self.natural_browsing(page).await?;
                crate::behavior::idle_pause().await;
                self.natural_browsing(page).await?;

                // reCAPTCHA v3 is invisible; the site decides the score.
                // We can only verify that a token was generated.
                let token_present =
                    crate::frame::verify_token_in_frames(page, "g-recaptcha-response").await?;

                if token_present {
                    let cookies = crate::cookies::capture_from_page(page)
                        .await
                        .unwrap_or_default();
                    Ok(CaptchaSolveResult {
                        solution: "recaptcha_v3:behavioral".to_string(),
                        confidence: 0.70,
                        method: SolveMethod::BehavioralBypass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: true,
                        screenshot: None,
                        cookies,
                    })
                } else {
                    Ok(CaptchaSolveResult::failure(
                        SolveMethod::BehavioralBypass,
                        t0.elapsed().as_millis() as u64,
                    ))
                }
            }

            _ => {
                warn!(kind = ?captcha_info.kind, "BehavioralCaptchaSolver: unsupported type, skipping");
                Ok(CaptchaSolveResult::failure(
                    SolveMethod::BehavioralBypass,
                    t0.elapsed().as_millis() as u64,
                ))
            }
        }
    }
}