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
//! Tesseract-based OCR fallback solver for canvas / SVG / static-image
//! text CAPTCHAs.
//!
//! Wired into the chain *after* the VLM solver: when Ollama isn't
//! installed (or returns no answer), tesseract picks up canvas / SVG
//! text CAPTCHAs that don't need full vision-language reasoning.
//!
//! Backend probed at construction via [`crate::backends::which`] 
//! solver is inert if `tesseract` isn't on PATH.

use super::*;

use crate::backends::OcrBackend;

/// Fallback OCR solver for visual-text CAPTCHAs. Uses the local
/// `tesseract` binary on PATH; transparent no-op when tesseract is
/// not installed.
pub struct OcrCaptchaSolver {
    pub(crate) backend: Option<OcrBackend>,
    pub(crate) config: SolveConfig,
}

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

impl OcrCaptchaSolver {
    pub fn new() -> Self {
        let backend = crate::backends::which("tesseract").map(|binary| OcrBackend { binary });
        Self {
            backend,
            config: SolveConfig::default(),
        }
    }

    pub fn with_backend(mut self, backend: OcrBackend) -> Self {
        self.backend = Some(backend);
        self
    }

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

    /// Run tesseract over a JPEG/PNG screenshot byte-slice, returning
    /// the OCR output. Pure-helper: pub for callers that already have
    /// a screenshot in hand.
    pub async fn ocr(&self, image_bytes: Vec<u8>) -> Result<String> {
        let backend = self
            .backend
            .as_ref()
            .ok_or_else(|| anyhow::anyhow!("tesseract not detected on PATH"))?;
        let bin = backend.binary.clone();
        let mut tmp = tempfile::Builder::new()
            .prefix("captchaforge-ocr-")
            .suffix(".png")
            .tempfile()?;
        use std::io::Write;
        tmp.write_all(&image_bytes)?;
        tmp.flush()?;
        let in_path = tmp.path().to_path_buf();
        tokio::task::spawn_blocking(move || -> Result<String> {
            let out = std::process::Command::new(&bin)
                .arg(&in_path)
                .arg("-")
                // psm 7: treat the image as a single text line; matches
                // canvas/SVG CAPTCHAs which are typically one short line.
                .args(["--psm", "7"])
                .output()
                .map_err(|e| anyhow::anyhow!("spawning tesseract: {e}"))?;
            if !out.status.success() {
                anyhow::bail!(
                    "tesseract exited {}: {}",
                    out.status,
                    String::from_utf8_lossy(&out.stderr)
                );
            }
            let body = String::from_utf8_lossy(&out.stdout).trim().to_string();
            Ok(body)
        })
        .await
        .map_err(|e| anyhow::anyhow!("tesseract task join: {e}"))?
    }
}

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

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

    fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
        use crate::captcha_detect::DetectedCaptcha;
        if self.backend.is_none() {
            return false;
        }
        matches!(
            kind,
            DetectedCaptcha::CanvasCaptcha
                | DetectedCaptcha::ImageCaptcha
                | DetectedCaptcha::Custom(_)
        )
    }

    async fn solve(
        &self,
        page: &Page,
        _info: &crate::captcha_detect::CaptchaInfo,
    ) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();
        if self.backend.is_none() {
            return Ok(CaptchaSolveResult::failure(
                SolveMethod::VisionLLM,
                t0.elapsed().as_millis() as u64,
            ));
        }
        // Screenshot just the canvas / SVG / image element so tesseract
        // doesn't try to OCR the surrounding chrome (form labels, page
        // headings, etc. (major source of garbled output)).
        let image_b64 = match page
            .evaluate(
                r#"(function(){
                    const el = document.querySelector(
                        'canvas.captcha, canvas#captcha, canvas, svg, img.captcha, .captcha-image img'
                    );
                    if(!el) return '';
                    if(el.tagName === 'CANVAS') return el.toDataURL('image/png').split(',')[1];
                    if(el.tagName === 'IMG'){
                        const c = document.createElement('canvas');
                        c.width = el.naturalWidth || el.width;
                        c.height = el.naturalHeight || el.height;
                        c.getContext('2d').drawImage(el, 0, 0);
                        return c.toDataURL('image/png').split(',')[1];
                    }
                    if(el.tagName === 'svg' || el.tagName === 'SVG'){
                        const xml = new XMLSerializer().serializeToString(el);
                        return btoa(unescape(encodeURIComponent(xml)));
                    }
                    return '';
                })()"#,
            )
            .await?
            .into_value::<String>()
        {
            Ok(s) if !s.is_empty() => s,
            _ => {
                return Ok(CaptchaSolveResult::failure(
                    SolveMethod::VisionLLM,
                    t0.elapsed().as_millis() as u64,
                ));
            }
        };

        use base64::Engine as _;
        let image_bytes = base64::engine::general_purpose::STANDARD
            .decode(image_b64.as_bytes())
            .map_err(|e| anyhow::anyhow!("decoding screenshot b64: {e}"))?;

        let raw = match self.ocr(image_bytes).await {
            Ok(t) => t,
            Err(_) => {
                return Ok(CaptchaSolveResult::failure(
                    SolveMethod::VisionLLM,
                    t0.elapsed().as_millis() as u64,
                ));
            }
        };

        // Tesseract often emits stray whitespace / OCR noise; collapse
        // to alphanumerics for CAPTCHA answer fields.
        let cleaned: String = raw.chars().filter(|c| c.is_alphanumeric()).collect();
        if cleaned.is_empty() {
            return Ok(CaptchaSolveResult::failure(
                SolveMethod::VisionLLM,
                t0.elapsed().as_millis() as u64,
            ));
        }

        if let Ok(input) = page
            .find_element("input[type=text], input[name='captcha'], input[name='answer'], textarea")
            .await
        {
            input.click().await.ok();
            input.type_str(&cleaned).await.ok();
        }

        let cookies = crate::cookies::capture_from_page(page)
            .await
            .unwrap_or_default();
        Ok(CaptchaSolveResult {
            solution: cleaned.clone(),
            confidence: 0.6,
            method: SolveMethod::VisionLLM,
            time_ms: t0.elapsed().as_millis() as u64,
            success: !cleaned.is_empty(),
            screenshot: None,
            cookies,
            verified_outcome: None,
        })
    }
}

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

    #[test]
    fn ocr_solver_inert_when_no_backend() {
        let s = OcrCaptchaSolver {
            backend: None,
            config: SolveConfig::default(),
        };
        use crate::captcha_detect::DetectedCaptcha;
        assert!(!s.supports(&DetectedCaptcha::CanvasCaptcha));
    }

    #[test]
    fn ocr_solver_supports_visual_text_kinds_when_present() {
        let s = OcrCaptchaSolver {
            backend: Some(OcrBackend {
                binary: "/usr/bin/tesseract".into(),
            }),
            config: SolveConfig::default(),
        };
        use crate::captcha_detect::DetectedCaptcha;
        assert!(s.supports(&DetectedCaptcha::CanvasCaptcha));
        assert!(s.supports(&DetectedCaptcha::ImageCaptcha));
        assert!(s.supports(&DetectedCaptcha::Custom("svg_captcha".into())));
        // Audio / token CAPTCHAs are NOT in scope for OCR.
        assert!(!s.supports(&DetectedCaptcha::AudioCaptcha));
        assert!(!s.supports(&DetectedCaptcha::Turnstile));
    }

    #[test]
    fn ocr_solver_method_is_vision_llm() {
        // VisionLLM as the method label so consumers grouping by method
        // don't see a separate "OCR" bucket, both VLM + OCR are
        // visual-text strategies.
        let s = OcrCaptchaSolver::new();
        assert_eq!(s.method(), SolveMethod::VisionLLM);
        assert_eq!(s.name(), "OcrCaptchaSolver");
    }
}