captchaforge 0.2.38

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for Firefox + BiDi-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / BiDi fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! CRNN-based text CAPTCHA solver.
//!
//! Solves classic distorted text CAPTCHAs by running local CRNN
//! inference. Requires a trained ONNX model (see
//! `scripts/train_crnn.py`). Falls back to Tesseract OCR if the
//! model is not available.
//!
//! Feature-gated behind `vision`.

use super::*;
#[cfg(feature = "vision")]
use anyhow::Context;
#[cfg(feature = "vision")]
use base64::Engine as _;


pub struct CrnnTextSolver {
    #[allow(dead_code)] // only read when `vision` feature is enabled
    recognizer: Option<tokio::sync::Mutex<CrnnHandle>>,
    config: SolveConfig,
}

#[cfg(feature = "vision")]
struct CrnnHandle(crate::vision::crnn::CrnnRecognizer);

#[cfg(not(feature = "vision"))]
struct CrnnHandle(());

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

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

    pub fn with_config(mut self, config: SolveConfig) -> Self {
        self.config = config;
        self
    }
}

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

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

    fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
        #[cfg(feature = "vision")]
        {
            matches!(
                kind,
                crate::captcha_detect::DetectedCaptcha::ImageCaptcha
                    | crate::captcha_detect::DetectedCaptcha::CanvasCaptcha
                    | crate::captcha_detect::DetectedCaptcha::ShadowDomCaptcha
            )
        }
        #[cfg(not(feature = "vision"))]
        {
            let _ = kind;
            false
        }
    }

    async fn solve(
        &self,
        page: &Page,
        _captcha_info: &CaptchaInfo,
    ) -> Result<CaptchaSolveResult> {
        let _ = page;
        let t0 = Instant::now();

        #[cfg(not(feature = "vision"))]
        {
            return Ok(CaptchaSolveResult::failure(self.method(), t0.elapsed().as_millis() as u64));
        }

        #[cfg(feature = "vision")]
        {
            // Lazy-load recognizer.
            let hub = crate::vision::ModelHub::new();
            let path = hub.resolve(crate::vision::ModelId::CrnnText).await
                .map_err(|e| anyhow!("CRNN model unavailable: {e}"))?;
            let charset = crate::vision::crnn::default_charset();
            let mut recognizer = crate::vision::crnn::CrnnRecognizer::load(&path, charset)
                .map_err(|e| anyhow!("CRNN load failed: {e}"))?;

            // Take screenshot.
            let screenshot_b64 = super::util::screenshot_b64(page).await?;
            let screenshot_bytes = base64::engine::general_purpose::STANDARD
                .decode(screenshot_b64.as_bytes())
                .map_err(|e| anyhow::anyhow!("decode screenshot: {e}"))?;
            let image = image::load_from_memory(&screenshot_bytes)
                .context("load screenshot as image")?;

            // Run CRNN recognition (synchronous — done before any await).
            let (text, confidence) = recognizer.recognize(&image)?;

            if text.is_empty() || confidence < 0.3 {
                return Ok(CaptchaSolveResult::failure(
                    self.method(),
                    t0.elapsed().as_millis() as u64,
                ));
            }

            // Type the recognized text into the input field.
            let type_js = format!(
                r#"
                (() => {{
                    const probes = [
                        'input[type="text"]',
                        '.captcha-input',
                        '#captcha-answer',
                        '#verification_code',
                        'input[name*="captcha"]',
                        'input[name*="verify"]',
                    ];
                    for (const sel of probes) {{
                        const el = document.querySelector(sel);
                        if (el) {{
                            el.focus();
                            el.value = '{}';
                            el.dispatchEvent(new Event('input', {{ bubbles: true }}));
                            el.dispatchEvent(new Event('change', {{ bubbles: true }}));
                            return true;
                        }}
                    }}
                    return false;
                }})()
                "#,
                text.replace('\\', "\\\\").replace('"', "\\\"").replace('\'', "\\'")
            );

            let typed = page.evaluate(type_js).await
                .ok()
                .and_then(|r| r.into_value::<bool>().ok())
                .unwrap_or(false);

            if !typed {
                return Ok(CaptchaSolveResult::failure(
                    self.method(),
                    t0.elapsed().as_millis() as u64,
                ));
            }

            // Click submit / verify.
            let verify_js = r#"
                (() => {
                    const probes = [
                        'button[type="submit"]',
                        'input[type="submit"]',
                        '.verify-button',
                        '#submit',
                        'button',
                    ];
                    for (const sel of probes) {
                        const el = document.querySelector(sel);
                        if (el && el.offsetParent !== null) {
                            el.click();
                            return true;
                        }
                    }
                    return false;
                })()
            "#;
            let _ = page.evaluate(verify_js).await;

            // Poll for token.
            tokio::time::sleep(Duration::from_millis(500)).await;
            let token = poll_for_token(page, self.config).await;
            let elapsed = t0.elapsed().as_millis() as u64;

            Ok(CaptchaSolveResult {
                solution: token.clone(),
                confidence: if token.is_empty() { confidence * 0.5 } else { confidence },
                method: self.method(),
                time_ms: elapsed,
                success: !token.is_empty(),
                screenshot: None,
                cookies: Vec::new(),
                verified_outcome: None,
            })
        }
    }
}

/// Poll the page for a populated CAPTCHA response token.
#[cfg(feature = "vision")]
async fn poll_for_token(page: &Page, config: SolveConfig) -> String {
    let deadline = Instant::now() + Duration::from_millis(config.token_max_attempts as u64 * config.token_poll_interval_ms);
    loop {
        let raw = page
            .evaluate(crate::solver::wait_for_token::TOKEN_PROBE_JS)
            .await;
        if let Ok(Ok(Some(t))) = raw.map(|r| r.into_value::<Option<String>>()) {
            if !t.is_empty() {
                return t;
            }
        }
        if Instant::now() >= deadline {
            return String::new();
        }
        tokio::time::sleep(Duration::from_millis(config.token_poll_interval_ms)).await;
    }
}