captchaforge 0.2.39

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
use crate::Page;
/// Automatic CAPTCHA detection (identifies captcha type and sitekey from page HTML).
///
/// Supports:
///   - Cloudflare Turnstile (cf-turnstile, challenges.cloudflare.com)
///   - reCAPTCHA v2/v3 (g-recaptcha, google.com/recaptcha)
///   - hCaptcha (h-captcha, hcaptcha.com)
///   - Generic image CAPTCHAs (img elements in captcha containers)
///   - Generic audio CAPTCHAs
///   - PoW CAPTCHAs (ALTCHA, mCaptcha, Friendly Captcha, Cap.dev)
///   - Slider / swipe / rotate CAPTCHAs
///   - Canvas / SVG / math / icon / color CAPTCHAs
///   - Shadow DOM hidden CAPTCHAs
///   - Multi-step wizard CAPTCHAs
///   - Cloudflare challenge interstitial pages
///
/// Detection is done via JavaScript evaluation on the live page.
/// Results can trigger automatic solving via the solver chain
/// (`captchaforge::solver`).
///
/// Each detector lives in its own module under `detect/`. Add a new
/// captcha type by creating `detect/<name>.rs`, declaring the module
/// here, and registering the detector in
/// [`DetectorRegistry::new`]. The pluggable [`Detector`] trait keeps
/// every detector self-contained, no central enum edit required for
/// simple cases (the type-tagged [`DetectedCaptcha`] enum is its own
/// task).
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};

mod audio_captcha;
pub mod block_response;
mod canvas_captcha;
mod challenge_page;
mod hcaptcha;
mod image_captcha;
mod math_captcha;
mod multi_step;
mod pow_captcha;
mod recaptcha;
pub mod rules;
mod shadow_dom;
mod slider_captcha;
mod turnstile;

pub use audio_captcha::AudioCaptchaDetector;
pub use canvas_captcha::CanvasCaptchaDetector;
pub use challenge_page::ChallengePageDetector;
pub use hcaptcha::HCaptchaDetector;
pub use image_captcha::ImageCaptchaDetector;
pub use math_captcha::MathCaptchaDetector;
pub use multi_step::MultiStepCaptchaDetector;
pub use pow_captcha::PowCaptchaDetector;
pub use recaptcha::RecaptchaDetector;
pub use shadow_dom::ShadowDomDetector;
pub use slider_captcha::SliderCaptchaDetector;
pub use turnstile::TurnstileDetector;

/// Detected CAPTCHA information.
///
/// # Example
///
/// ```
/// use captchaforge::detect::{CaptchaInfo, DetectedCaptcha};
///
/// let info = CaptchaInfo {
///     kind: DetectedCaptcha::Turnstile,
///     site_key: Some("1x00000000000000000000AA".into()),
///     page_url: "https://example.com".into(),
///     container_selector: Some(".cf-turnstile".into()),
/// };
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CaptchaInfo {
    pub kind: DetectedCaptcha,
    pub site_key: Option<String>,
    pub page_url: String,
    /// CSS selector to the captcha container element.
    pub container_selector: Option<String>,
}

/// All recognised CAPTCHA types.
///
/// Marked `#[non_exhaustive]` so adding a variant (e.g. for a new
/// vendor like AWS WAF, DataDome, Geetest, Arkose) is NOT a breaking
/// change for downstream code: `match`es in external crates must
/// already include a wildcard arm, and same-crate matches will
/// surface a compile error here in this file when the new variant
/// lands, which is exactly where it belongs.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DetectedCaptcha {
    Turnstile,
    RecaptchaV2,
    RecaptchaV3,
    // serde's snake_case heuristic mangles `HCaptcha` -> `h_captcha`,
    // which doesn't round-trip with the JS detector (which emits the
    // string `hcaptcha`) and silently scatters PatternStore keys.
    // Rename pinned to match the documented wire format.
    #[serde(rename = "hcaptcha")]
    HCaptcha,
    ImageCaptcha,
    AudioCaptcha,
    PowCaptcha,
    SliderCaptcha,
    CanvasCaptcha,
    ShadowDomCaptcha,
    MultiStepCaptcha,
    /// A captcha vendor that doesn't have a dedicated enum variant 
    /// typically used by TOML-defined rules from the community
    /// extension layer (`detect::rules`). The string is the rule
    /// `name` from the TOML file (e.g. `"aws_waf"`, `"datadome"`).
    Custom(String),
    None,
}

// ---------------------------------------------------------------------------
// Detector trait & registry
// ---------------------------------------------------------------------------

#[async_trait]
pub trait Detector: Send + Sync {
    fn name(&self) -> &'static str;
    fn priority(&self) -> i32;
    async fn detect(&self, page: &Page) -> Result<Option<CaptchaInfo>>;
}

pub struct DetectorRegistry {
    detectors: Vec<Box<dyn Detector>>,
}

impl DetectorRegistry {
    pub fn new() -> Self {
        let mut detectors: Vec<Box<dyn Detector>> = vec![
            Box::new(ChallengePageDetector),
            Box::new(TurnstileDetector),
            Box::new(RecaptchaDetector),
            Box::new(HCaptchaDetector),
            Box::new(ImageCaptchaDetector),
            Box::new(AudioCaptchaDetector),
            Box::new(MathCaptchaDetector),
            Box::new(PowCaptchaDetector),
            Box::new(CanvasCaptchaDetector),
            Box::new(SliderCaptchaDetector),
            Box::new(MultiStepCaptchaDetector),
            Box::new(ShadowDomDetector),
        ];
        detectors.sort_by_key(|d| d.priority());
        Self { detectors }
    }

    pub async fn detect(&self, page: &Page) -> Result<Option<CaptchaInfo>> {
        for detector in &self.detectors {
            if let Some(info) = detector.detect(page).await? {
                return Ok(Some(info));
            }
        }
        Ok(None)
    }
}

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

/// Map a JS detector's JSON return value into a [`CaptchaInfo`].
///
/// Returns `None` when the JS reported `kind: "none"` or an unknown
/// kind string. Per-detector modules call this from their async
/// `detect()` impl.
pub(crate) fn parse_detection_result(val: serde_json::Value) -> Option<CaptchaInfo> {
    let kind_str = val["kind"].as_str().unwrap_or("none");
    if kind_str == "none" {
        return None;
    }
    let kind = match kind_str {
        "turnstile" => DetectedCaptcha::Turnstile,
        "recaptcha_v2" => DetectedCaptcha::RecaptchaV2,
        "recaptcha_v3" => DetectedCaptcha::RecaptchaV3,
        "hcaptcha" => DetectedCaptcha::HCaptcha,
        "image" => DetectedCaptcha::ImageCaptcha,
        "audio" => DetectedCaptcha::AudioCaptcha,
        "pow" => DetectedCaptcha::PowCaptcha,
        "slider" => DetectedCaptcha::SliderCaptcha,
        "canvas" => DetectedCaptcha::CanvasCaptcha,
        "shadow_dom" => DetectedCaptcha::ShadowDomCaptcha,
        "multi_step" => DetectedCaptcha::MultiStepCaptcha,
        // Anything starting with "custom:" is a TOML-rule provider 
        // the suffix is the rule name and we round-trip it as
        // DetectedCaptcha::Custom so downstream tooling can route on
        // the vendor identifier.
        s if s.starts_with("custom:") => DetectedCaptcha::Custom(s[7..].to_owned()),
        _ => DetectedCaptcha::None,
    };
    if kind == DetectedCaptcha::None {
        return None;
    }
    Some(CaptchaInfo {
        kind,
        site_key: val["site_key"].as_str().map(String::from),
        page_url: String::new(),
        container_selector: val["container"].as_str().map(String::from),
    })
}

// ---------------------------------------------------------------------------
// Legacy monolithic detection JS
// ---------------------------------------------------------------------------

/// **Legacy** monolithic detection script.
///
/// This constant combines all individual detector scripts into a single
/// self-contained IIFE for simple use cases that do not need the pluggable
/// registry architecture.
///
/// > **Deprecated:** Prefer [`DetectorRegistry`] for new code. It allows
/// > fine-grained control over detection order, custom detectors, and
/// > selective disabling.
pub const DETECT_JS: &str = r#"(function() {
    // --- Challenge page (priority 5) ---
    {
        if (document.title.includes('Just a moment') ||
            document.title.includes('Attention Required') ||
            document.querySelector('#challenge-running, #challenge-form, .cf-browser-verification')) {
            return { kind: 'turnstile', site_key: null, container: '#challenge-form' };
        }
    }

    // --- Turnstile (priority 10) ---
    {
        const turnstile = document.querySelector('.cf-turnstile, [data-turnstile-sitekey]');
        if (turnstile) {
            return {
                kind: 'turnstile',
                site_key: turnstile.getAttribute('data-sitekey') ||
                          turnstile.getAttribute('data-turnstile-sitekey'),
                container: '.cf-turnstile'
            };
        }
        const turnstileScript = document.querySelector('script[src*="challenges.cloudflare.com"]');
        if (turnstileScript) {
            const mount = document.querySelector('#cf-turnstile, #cf-turnstile-mount, [id*="turnstile"]');
            const cfEl = document.querySelector('[data-sitekey]');
            if (mount || cfEl) {
                return {
                    kind: 'turnstile',
                    site_key: cfEl ? cfEl.getAttribute('data-sitekey') : null,
                    container: mount ? ('#' + mount.id) : (cfEl ? '[data-sitekey]' : null)
                };
            }
            return { kind: 'turnstile', site_key: null, container: null };
        }
    }

    // --- reCAPTCHA (priority 20) ---
    {
        const recaptcha = document.querySelector('.g-recaptcha');
        const recaptchaScript = document.querySelector('script[src*="google.com/recaptcha"]');
        if (recaptcha || recaptchaScript) {
            let el = recaptcha;
            if (!el) {
                const fallback = document.querySelector('[data-sitekey]');
                if (fallback && !fallback.hasAttribute('data-hcaptcha-sitekey') && !fallback.hasAttribute('data-turnstile-sitekey')) {
                    el = fallback;
                }
            }
            if (el) {
                const size = el.getAttribute('data-size');
                return {
                    kind: size === 'invisible' ? 'recaptcha_v3' : 'recaptcha_v2',
                    site_key: el.getAttribute('data-sitekey'),
                    container: '.g-recaptcha'
                };
            }
            return { kind: 'recaptcha_v3', site_key: null, container: null };
        }
    }

    // --- hCaptcha (priority 30) ---
    {
        const hcaptcha = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]');
        const hcaptchaScript = document.querySelector('script[src*="hcaptcha.com"]');
        if (hcaptcha || hcaptchaScript) {
            let el = hcaptcha;
            if (!el) {
                const fallback = document.querySelector('[data-sitekey]');
                if (fallback && !fallback.hasAttribute('data-turnstile-sitekey') && !fallback.matches('.g-recaptcha')) {
                    el = fallback;
                }
            }
            const result = { kind: 'hcaptcha', site_key: null, container: '.h-captcha' };
            if (el) {
                result.site_key = el.getAttribute('data-sitekey') ||
                                  el.getAttribute('data-hcaptcha-sitekey');
            }
            return result;
        }
    }

    // --- Image CAPTCHA (priority 40) ---
    {
        const imgCaptcha = document.querySelector(
            'img[src*="captcha"], img[src*="Captcha"], .captcha img, #captcha img'
        );
        if (imgCaptcha) {
            return { kind: 'image', site_key: null, container: '.captcha, #captcha' };
        }
    }

    // --- Audio CAPTCHA (priority 50) ---
    {
        const audioCaptcha = document.querySelector(
            'audio[src*="captcha"], .audio-captcha, #audio-captcha'
        );
        if (audioCaptcha) {
            return { kind: 'audio', site_key: null, container: '.audio-captcha, #audio-captcha' };
        }
    }

    return { kind: 'none', site_key: null, container: null };
})()"#;

// ---------------------------------------------------------------------------
// Top-level convenience API
// ---------------------------------------------------------------------------

/// Detect CAPTCHA type on the current page using the default [`DetectorRegistry`].
pub async fn detect(page: &Page) -> Result<CaptchaInfo> {
    let page_url = page
        .evaluate("window.location.href")
        .await?
        .into_value::<String>()
        .unwrap_or_else(|_| String::new());

    let registry = DetectorRegistry::new();
    if let Some(mut info) = registry.detect(page).await? {
        info.page_url = page_url;
        return Ok(info);
    }

    Ok(CaptchaInfo {
        kind: DetectedCaptcha::None,
        site_key: None,
        page_url,
        container_selector: None,
    })
}

/// Check if a captcha was detected (convenience).
pub fn is_captcha(info: &CaptchaInfo) -> bool {
    info.kind != DetectedCaptcha::None
}

/// Convert DetectedCaptcha to a string kind for the solver layer.
/// Returns None for types that can't be solved externally.
pub fn solver_kind_str(detected: &DetectedCaptcha) -> Option<&'static str> {
    match detected {
        DetectedCaptcha::Turnstile => Some("turnstile"),
        DetectedCaptcha::RecaptchaV2 => Some("recaptcha_v2"),
        DetectedCaptcha::RecaptchaV3 => Some("recaptcha_v3"),
        DetectedCaptcha::HCaptcha => Some("hcaptcha"),
        DetectedCaptcha::ImageCaptcha => Some("image_captcha"),
        DetectedCaptcha::AudioCaptcha => Some("audio_captcha"),
        DetectedCaptcha::PowCaptcha => Some("pow_captcha"),
        DetectedCaptcha::SliderCaptcha => Some("slider_captcha"),
        DetectedCaptcha::CanvasCaptcha => Some("canvas_captcha"),
        DetectedCaptcha::ShadowDomCaptcha => Some("shadow_dom_captcha"),
        DetectedCaptcha::MultiStepCaptcha => Some("multi_step_captcha"),
        // Custom providers don't have a built-in solver mapping 
        // they route through the chain via the recommended_solver_methods
        // declared in their CaptchaProvider impl (or TOML rule entry).
        DetectedCaptcha::Custom(_) => None,
        DetectedCaptcha::None => None,
    }
}

#[cfg(test)]
#[path = "detect/tests.rs"]
mod tests;