captchaforge 0.2.0

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
/// 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)
///
/// Detection is done via JavaScript evaluation on the live page.
/// Results can trigger automatic solving via captcha.rs (CapSolver) or
/// future local ONNX solver.
use anyhow::Result;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};

/// Detected CAPTCHA information.
#[derive(Debug, Clone, 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>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
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,
    None,
}

/// JavaScript to detect captcha type and extract sitekey.
const DETECT_JS: &str = r#"(function() {
    const result = { kind: 'none', site_key: null, container: null };

    // Cloudflare Turnstile
    const turnstile = document.querySelector('.cf-turnstile, [data-turnstile-sitekey]');
    if (turnstile) {
        result.kind = 'turnstile';
        result.site_key = turnstile.getAttribute('data-sitekey') ||
                          turnstile.getAttribute('data-turnstile-sitekey');
        result.container = '.cf-turnstile';
        return result;
    }
    // Also check for Turnstile script
    const turnstileScript = document.querySelector('script[src*="challenges.cloudflare.com"]');
    if (turnstileScript) {
        const cfEl = document.querySelector('[data-sitekey]');
        if (cfEl) {
            result.kind = 'turnstile';
            result.site_key = cfEl.getAttribute('data-sitekey');
            result.container = '[data-sitekey]';
            return result;
        }
    }

    // reCAPTCHA v2/v3
    const recaptcha = document.querySelector('.g-recaptcha, [data-sitekey]');
    const recaptchaScript = document.querySelector('script[src*="google.com/recaptcha"]');
    if (recaptcha || recaptchaScript) {
        const el = recaptcha || document.querySelector('[data-sitekey]');
        if (el) {
            const size = el.getAttribute('data-size');
            result.kind = size === 'invisible' ? 'recaptcha_v3' : 'recaptcha_v2';
            result.site_key = el.getAttribute('data-sitekey');
            result.container = '.g-recaptcha';
            return result;
        }
        // v3 invisible might not have a visible element
        result.kind = 'recaptcha_v3';
        return result;
    }

    // hCaptcha
    const hcaptcha = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]');
    const hcaptchaScript = document.querySelector('script[src*="hcaptcha.com"]');
    if (hcaptcha || hcaptchaScript) {
        const el = hcaptcha || document.querySelector('[data-sitekey]');
        result.kind = 'hcaptcha';
        if (el) {
            result.site_key = el.getAttribute('data-sitekey');
            result.container = '.h-captcha';
        }
        return result;
    }

    // Generic image CAPTCHA (img inside known captcha container)
    const imgCaptcha = document.querySelector(
        'img[src*="captcha"], img[src*="Captcha"], .captcha img, #captcha img'
    );
    if (imgCaptcha) {
        result.kind = 'image';
        result.container = '.captcha, #captcha';
        return result;
    }

    // Generic audio CAPTCHA
    const audioCaptcha = document.querySelector(
        'audio[src*="captcha"], .audio-captcha, #audio-captcha'
    );
    if (audioCaptcha) {
        result.kind = 'audio';
        result.container = '.audio-captcha, #audio-captcha';
        return result;
    }

    // Cloudflare challenge page (bot detection interstitial)
    if (document.title.includes('Just a moment') ||
        document.title.includes('Attention Required') ||
        document.querySelector('#challenge-running, #challenge-form, .cf-browser-verification')) {
        result.kind = 'turnstile';
        result.container = '#challenge-form';
        return result;
    }

    return result;
})()"#;

/// Detect CAPTCHA type on the current page.
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 raw = page.evaluate(DETECT_JS).await?;
    let val = raw.into_value::<serde_json::Value>()?;

    let kind_str = val["kind"].as_str().unwrap_or("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,
        _ => DetectedCaptcha::None,
    };

    Ok(CaptchaInfo {
        kind,
        site_key: val["site_key"].as_str().map(String::from),
        page_url,
        container_selector: val["container"].as_str().map(String::from),
    })
}

/// 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"),
        _ => None,
    }
}

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

    #[test]
    fn detect_js_checks_turnstile() {
        assert!(DETECT_JS.contains("cf-turnstile"));
        assert!(DETECT_JS.contains("challenges.cloudflare.com"));
    }

    #[test]
    fn detect_js_checks_recaptcha() {
        assert!(DETECT_JS.contains("g-recaptcha"));
        assert!(DETECT_JS.contains("google.com/recaptcha"));
    }

    #[test]
    fn detect_js_checks_hcaptcha() {
        assert!(DETECT_JS.contains("h-captcha"));
        assert!(DETECT_JS.contains("hcaptcha.com"));
    }

    #[test]
    fn detect_js_checks_challenge_page() {
        assert!(DETECT_JS.contains("Just a moment"));
        assert!(DETECT_JS.contains("challenge-running"));
    }

    #[test]
    fn captcha_info_serializes() {
        let info = CaptchaInfo {
            kind: DetectedCaptcha::Turnstile,
            site_key: Some("abc123".into()),
            page_url: "https://example.com".into(),
            container_selector: Some(".cf-turnstile".into()),
        };
        let json = serde_json::to_string(&info).unwrap();
        assert!(json.contains("\"kind\":\"turnstile\""));
        assert!(json.contains("\"site_key\":\"abc123\""));
    }

    #[test]
    fn is_captcha_false_for_none() {
        let info = CaptchaInfo {
            kind: DetectedCaptcha::None,
            site_key: None,
            page_url: String::new(),
            container_selector: None,
        };
        assert!(!is_captcha(&info));
    }

    #[test]
    fn is_captcha_true_for_turnstile() {
        let info = CaptchaInfo {
            kind: DetectedCaptcha::Turnstile,
            site_key: Some("key".into()),
            page_url: String::new(),
            container_selector: None,
        };
        assert!(is_captcha(&info));
    }

    #[test]
    fn solver_kind_str_maps_correctly() {
        assert_eq!(
            solver_kind_str(&DetectedCaptcha::Turnstile),
            Some("turnstile")
        );
        assert_eq!(
            solver_kind_str(&DetectedCaptcha::RecaptchaV2),
            Some("recaptcha_v2")
        );
        assert_eq!(
            solver_kind_str(&DetectedCaptcha::HCaptcha),
            Some("hcaptcha")
        );
        assert_eq!(solver_kind_str(&DetectedCaptcha::None), None);
    }

    #[test]
    fn detect_js_checks_image_captcha() {
        assert!(DETECT_JS.contains("img[src*=\"captcha\"]"));
        assert!(DETECT_JS.contains("'image'"));
    }

    #[test]
    fn detect_js_checks_audio_captcha() {
        assert!(DETECT_JS.contains("audio[src*=\"captcha\"]"));
        assert!(DETECT_JS.contains("'audio'"));
    }
}