captchaforge 0.2.26

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
use anyhow::Result;
use async_trait::async_trait;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};

use crate::captcha_detect::CaptchaInfo;

/// The variety of CAPTCHA encountered.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CaptchaType {
    RecaptchaV2,
    RecaptchaV3,
    #[serde(rename = "hcaptcha")]
    HCaptcha,
    CloudflareTurnstile,
    ImageGrid,
    TextCaptcha,
    AudioCaptcha,
    Slider,
    PowCaptcha,
    CanvasCaptcha,
    ShadowDomCaptcha,
    MultiStepCaptcha,
    Custom(String),
}

impl std::fmt::Display for CaptchaType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CaptchaType::RecaptchaV2 => write!(f, "recaptcha_v2"),
            CaptchaType::RecaptchaV3 => write!(f, "recaptcha_v3"),
            CaptchaType::HCaptcha => write!(f, "hcaptcha"),
            CaptchaType::CloudflareTurnstile => write!(f, "cloudflare_turnstile"),
            CaptchaType::ImageGrid => write!(f, "image_grid"),
            CaptchaType::TextCaptcha => write!(f, "text_captcha"),
            CaptchaType::AudioCaptcha => write!(f, "audio_captcha"),
            CaptchaType::Slider => write!(f, "slider"),
            CaptchaType::PowCaptcha => write!(f, "pow_captcha"),
            CaptchaType::CanvasCaptcha => write!(f, "canvas_captcha"),
            CaptchaType::ShadowDomCaptcha => write!(f, "shadow_dom_captcha"),
            CaptchaType::MultiStepCaptcha => write!(f, "multi_step_captcha"),
            CaptchaType::Custom(s) => write!(f, "custom:{}", s),
        }
    }
}

/// Which solving strategy produced the result.
///
/// `#[non_exhaustive]` so downstream `match` statements stay
/// forward-compatible across additions (e.g. AutoPass landed in
/// 0.2.5 — without `#[non_exhaustive]` that would have been a
/// breaking change to the public enum).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SolveMethod {
    // serde's snake_case heuristic mangles consecutive caps:
    // `VisionLLM` -> `vision_l_l_m`. Pin the wire form so persisted
    // PatternStore entries don't break across solver upgrades.
    #[serde(rename = "vision_llm")]
    VisionLLM,
    AudioBypass,
    BehavioralBypass,
    ThirdPartyService,
    CrowdSourced,
    /// Captcha auto-completed without explicit interaction — the
    /// vendor script populated the response field on its own (e.g.
    /// Cloudflare/hCaptcha/Google test sitekeys, or production
    /// passive challenges that pass on a clean fingerprint). The
    /// chain just polled for the token and read it. Cheapest possible
    /// "solve" — no mouse simulation, no VLM call, no API charge.
    AutoPass,
}

/// The outcome of a solve attempt.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptchaSolveResult {
    /// Token or typed answer produced by the solver.
    pub solution: String,
    /// Confidence in 0.0–1.0 range.
    pub confidence: f32,
    /// Which strategy was used.
    pub method: SolveMethod,
    /// Wall-clock time for the solve in milliseconds.
    pub time_ms: u64,
    /// Whether the solve is considered successful.
    pub success: bool,
    /// Base64 JPEG screenshot taken at the point of fallback / failure,
    /// useful for human-in-the-loop review.
    pub screenshot: Option<String>,
    /// Browser cookies captured at the point of successful solve.
    /// Replay these on subsequent navigations (via
    /// [`crate::cookies::apply_to_page`]) to ride the WAF/vendor's
    /// trusted session and avoid re-triggering the captcha.
    /// Empty for failed solves and for solvers that don't capture
    /// cookies (e.g. the cache short-circuit path returns `vec![]`).
    #[serde(default)]
    pub cookies: Vec<crate::cookies::CapturedCookie>,
    /// Outcome the [`crate::solver::oracle`] derived from page-state
    /// drift before vs after solve. `None` when the chain ran with
    /// `verify_outcome` disabled. `Some(Advanced)` is the only value
    /// that *proves* the page is past the challenge — every other
    /// variant downgrades a green-checkmark token to "claimed but
    /// unverified". When the chain has the oracle on, it overwrites
    /// `success` to `false` for HardBlock / Recycled / SilentFail
    /// so naive callers can't be fooled.
    #[serde(default)]
    pub verified_outcome: Option<crate::solver::oracle::OutcomeClassification>,
}

impl CaptchaSolveResult {
    pub fn failure(method: SolveMethod, time_ms: u64) -> Self {
        Self {
            solution: String::new(),
            confidence: 0.0,
            method,
            time_ms,
            success: false,
            screenshot: None,
            cookies: Vec::new(),
            verified_outcome: None,
        }
    }

    pub fn unsolved(time_ms: u64, screenshot: Option<String>) -> Self {
        Self {
            solution: String::new(),
            confidence: 0.0,
            method: SolveMethod::CrowdSourced,
            time_ms,
            success: false,
            screenshot,
            cookies: Vec::new(),
            verified_outcome: None,
        }
    }
}

/// Timeouts and retry limits for the solving pipeline.
///
/// All durations are in milliseconds for simplicity.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct SolveConfig {
    /// How long to wait for a checkbox/iframe to appear (ms per attempt).
    pub checkbox_poll_interval_ms: u64,
    /// Maximum attempts to find a checkbox before giving up.
    pub checkbox_max_attempts: u32,
    /// How long to wait for a CAPTCHA token to appear after interaction.
    pub token_poll_interval_ms: u64,
    /// Maximum attempts to verify a token was produced.
    pub token_max_attempts: u32,
    /// Delay after clicking the audio challenge button (ms).
    pub audio_button_delay_ms: u64,
    /// Delay after submitting an audio answer (ms).
    pub audio_submit_delay_ms: u64,
    /// HTTP timeout for VLM screenshot queries (ms).
    pub vlm_http_timeout_ms: u64,
    /// HTTP timeout for the reqwest client backing audio/VLM solvers (ms).
    pub client_http_timeout_ms: u64,
}

impl Default for SolveConfig {
    fn default() -> Self {
        Self {
            checkbox_poll_interval_ms: 500,
            checkbox_max_attempts: 15,
            token_poll_interval_ms: 500,
            token_max_attempts: 16,
            audio_button_delay_ms: 2000,
            audio_submit_delay_ms: 2000,
            vlm_http_timeout_ms: 120_000,
            client_http_timeout_ms: 180_000,
        }
    }
}

/// Shared trait for all CAPTCHA solvers.
#[async_trait]
pub trait CaptchaSolver: Send + Sync {
    /// Attempt to solve the CAPTCHA visible on `page`.
    async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> Result<CaptchaSolveResult>;

    /// Human-readable name for logging.
    fn name(&self) -> &'static str;

    /// The [`SolveMethod`] this solver produces on success.
    fn method(&self) -> SolveMethod;

    /// Whether this solver is capable of handling the detected CAPTCHA kind.
    /// The chain uses this to skip solvers that are guaranteed to fail for a
    /// given type, avoiding wasted time and API calls.
    fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool;
}

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

    #[test]
    fn captcha_type_display() {
        assert_eq!(CaptchaType::RecaptchaV2.to_string(), "recaptcha_v2");
        assert_eq!(
            CaptchaType::CloudflareTurnstile.to_string(),
            "cloudflare_turnstile"
        );
        assert_eq!(
            CaptchaType::Custom("banana".to_string()).to_string(),
            "custom:banana"
        );
        assert_eq!(CaptchaType::PowCaptcha.to_string(), "pow_captcha");
        assert_eq!(CaptchaType::CanvasCaptcha.to_string(), "canvas_captcha");
        assert_eq!(
            CaptchaType::ShadowDomCaptcha.to_string(),
            "shadow_dom_captcha"
        );
        assert_eq!(
            CaptchaType::MultiStepCaptcha.to_string(),
            "multi_step_captcha"
        );
    }

    #[test]
    fn captcha_type_serializes() {
        let json = serde_json::to_string(&CaptchaType::HCaptcha).unwrap();
        assert_eq!(json, r#""hcaptcha""#);
    }

    #[test]
    fn captcha_type_roundtrips_all_variants() {
        for variant in [
            CaptchaType::RecaptchaV2,
            CaptchaType::RecaptchaV3,
            CaptchaType::HCaptcha,
            CaptchaType::CloudflareTurnstile,
            CaptchaType::ImageGrid,
            CaptchaType::TextCaptcha,
            CaptchaType::AudioCaptcha,
            CaptchaType::Slider,
            CaptchaType::PowCaptcha,
            CaptchaType::CanvasCaptcha,
            CaptchaType::ShadowDomCaptcha,
            CaptchaType::MultiStepCaptcha,
            CaptchaType::Custom("foo".to_string()),
        ] {
            let json = serde_json::to_string(&variant).unwrap();
            let rt: CaptchaType = serde_json::from_str(&json).unwrap();
            assert_eq!(variant, rt);
        }
    }

    #[test]
    fn solve_method_serializes() {
        let json = serde_json::to_string(&SolveMethod::VisionLLM).unwrap();
        assert_eq!(json, r#""vision_llm""#);
    }

    #[test]
    fn solve_result_failure_constructor() {
        let r = CaptchaSolveResult::failure(SolveMethod::AudioBypass, 500);
        assert!(!r.success);
        assert_eq!(r.time_ms, 500);
        assert_eq!(r.confidence, 0.0);
        assert!(r.solution.is_empty());
    }

    #[test]
    fn solve_result_round_trips_json() {
        let r = CaptchaSolveResult {
            solution: "abc123".to_string(),
            confidence: 0.9,
            method: SolveMethod::BehavioralBypass,
            time_ms: 1234,
            success: true,
            screenshot: None,
            cookies: Vec::new(),
            verified_outcome: None,
        };
        let json = serde_json::to_string(&r).unwrap();
        let r2: CaptchaSolveResult = serde_json::from_str(&json).unwrap();
        assert_eq!(r2.solution, "abc123");
        assert_eq!(r2.time_ms, 1234);
        assert!(r2.success);
    }

    #[test]
    fn unsolved_result_has_no_screenshot() {
        let r = CaptchaSolveResult::unsolved(1234, None);
        assert!(!r.success);
        assert_eq!(r.time_ms, 1234);
        assert!(r.screenshot.is_none());
    }

    #[test]
    fn unsolved_result_can_carry_screenshot() {
        let r = CaptchaSolveResult::unsolved(5678, Some("b64img".into()));
        assert!(!r.success);
        assert_eq!(r.screenshot, Some("b64img".into()));
    }
}