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
//! 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 {
    // Lazily holds the loaded recognizer so the ~39 MB ONNX model + ort session
    // are built ONCE and reused across solves (not reloaded per call). Populated
    // on first `solve()` via `get_or_try_init`. Dead only without `vision`, where
    // `solve()` returns before ever touching it.
    #[cfg_attr(not(feature = "vision"), allow(dead_code))]
    recognizer: tokio::sync::OnceCell<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: tokio::sync::OnceCell::new(),
            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 the recognizer ONCE and reuse it across solves: loading
            // the ~39 MB ONNX model and building the ort session per call is a
            // cold-load-per-solve perf bug. `get_or_try_init` populates the cell
            // on first use; on failure (model absent) it surfaces the error
            // loudly and leaves the cell empty, so a later solve retries cleanly
            // rather than caching the failure (Law 10 (no silent dead state)).
            let handle = self
                .recognizer
                .get_or_try_init(|| async {
                    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 recognizer = crate::vision::crnn::CrnnRecognizer::load(&path, charset)
                        .map_err(|e| anyhow!("CRNN load failed: {e}"))?;
                    Ok::<_, anyhow::Error>(tokio::sync::Mutex::new(CrnnHandle(recognizer)))
                })
                .await?;

            // 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, guard dropped before any
            // await, so the mutex is never held across a suspension point).
            let (text, confidence) = {
                let mut guard = handle.lock().await;
                guard.0.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,
                ));
            }

            // Locate (do NOT click) the submit/verify button and hand its centre back
            // for a TRUSTED Rust-side click: a synthetic el.click() is
            // event.isTrusted === false, which a form that scores its submit rejects.
            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) {
                            const r = el.getBoundingClientRect();
                            if (r.width >= 1 && r.height >= 1) return [r.left + r.width / 2, r.top + r.height / 2];
                        }
                    }
                    return null;
                })()
            "#;
            // Law 10: surface a missing button / failed click instead of swallowing it.
            // The token poll below still gates success (so this never overclaims), but a
            // silent miss means the recognised text was never submitted and the solve
            // just times out with no clue why.
            let verify_centre = page
                .evaluate(verify_js)
                .await
                .ok()
                .and_then(|v| v.into_value::<Option<(f64, f64)>>().ok())
                .flatten();
            match verify_centre {
                Some((x, y)) => {
                    if let Err(e) = crate::behavior::click_realistic(page, x, y).await {
                        tracing::warn!("CRNN-text verify/submit trusted click failed ({e}); recognised text may not have been submitted");
                    }
                }
                None => tracing::warn!(
                    "CRNN-text: no visible submit/verify button found; recognised text may not have been submitted"
                ),
            }

            // 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;
    }
}