captchaforge 0.2.32

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Math equation CAPTCHA detector.
//!
//! Recognises in-page arithmetic prompts ("3 + 7 = ?", "What is 8 −
//! 3?") that ship with WordPress contact forms, generic CMS plugins,
//! and rolled-its-own anti-spam systems. The accompanying
//! [`crate::solver::math_captcha::MathCaptchaSolver`] computes the
//! answer and types it into the input.
//!
//! Detection signature is intentionally narrow: an input named
//! `math_captcha` / `captcha-result`, a `.math-captcha-question`
//! container, or a textual prompt followed by `=` and an input. A
//! plain `<input name="captcha">` is NOT enough — that matches every
//! bot-form on the web, including non-arithmetic CAPTCHAs.

use anyhow::Result;
use async_trait::async_trait;
use chromiumoxide::Page;

use super::{parse_detection_result, CaptchaInfo, Detector};

pub struct MathCaptchaDetector;

impl MathCaptchaDetector {
    /// JS payload evaluated against the live page. Returns
    /// `kind: "custom:wp_math_captcha"` so the chain routes through
    /// [`crate::solver::MathCaptchaSolver`] via the
    /// provider registry.
    pub const JS: &'static str = r#"(function() {
        // Tier 1: explicit input names + dedicated containers.
        const direct = document.querySelector(
            "input[name='math_captcha'], input[id*='math_captcha'], input[name='captcha-result'], .math-captcha-question, .cptch_input"
        );
        if (direct) {
            return { kind: 'custom:wp_math_captcha', site_key: null, container: 'input[name="math_captcha"]' };
        }
        // Tier 2: textual arithmetic prompt + a numeric input nearby.
        const equationRe = /\b\d+\s*[+\-×−x\*]\s*\d+\s*=/;
        const candidates = document.querySelectorAll(
            ".equation, .captcha-equation, [class*='equation'], [class*='math'], [data-captcha-question]"
        );
        for (const c of candidates) {
            const text = (c.textContent || '').trim();
            if (equationRe.test(text)) {
                return { kind: 'custom:wp_math_captcha', site_key: null, container: '.equation' };
            }
        }
        // Tier 3: scan the whole document body for an arithmetic
        // prompt — bounded to the first 4KB so a 50MB SPA doesn't
        // become a regex bomb.
        const body = (document.body && document.body.innerText) || '';
        if (equationRe.test(body.slice(0, 4096))) {
            const numericInput = document.querySelector(
                "input[type='number'], input[type='text'][name*='captcha' i]"
            );
            if (numericInput) {
                return { kind: 'custom:wp_math_captcha', site_key: null, container: 'input[type=number]' };
            }
        }
        return { kind: 'none', site_key: null, container: null };
    })()"#;
}

#[async_trait]
impl Detector for MathCaptchaDetector {
    fn name(&self) -> &'static str {
        "math_captcha"
    }
    fn priority(&self) -> i32 {
        // Run AFTER vendor-specific detectors (Turnstile/recaptcha/etc
        // at 5–25) so a vendor with an arithmetic-looking class doesn't
        // mis-route through the math solver.
        80
    }
    async fn detect(&self, page: &Page) -> Result<Option<CaptchaInfo>> {
        let raw = page.evaluate(Self::JS).await?;
        let val = raw.into_value::<serde_json::Value>()?;
        Ok(parse_detection_result(val))
    }
}

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

    #[test]
    fn js_matches_explicit_math_input_name() {
        assert!(MathCaptchaDetector::JS.contains("input[name='math_captcha']"));
        assert!(MathCaptchaDetector::JS.contains("input[name='captcha-result']"));
        assert!(MathCaptchaDetector::JS.contains(".math-captcha-question"));
        assert!(MathCaptchaDetector::JS.contains(".cptch_input"));
    }

    #[test]
    fn js_returns_custom_kind() {
        // The chain routes Custom("wp_math_captcha") through the
        // ProviderRegistry — change here means a chain-side change too.
        assert!(MathCaptchaDetector::JS.contains("custom:wp_math_captcha"));
    }

    #[test]
    fn js_includes_textual_equation_regex() {
        // Catches `3 + 7 =`, `8 - 3 =`, `4 × 6 =`, `9 x 2 =`.
        assert!(MathCaptchaDetector::JS.contains("equationRe"));
    }

    #[test]
    fn detector_runs_after_vendor_detectors() {
        // Vendor detectors live at priority 5–25; running this at 80
        // ensures Turnstile / reCAPTCHA / hCaptcha win when both
        // signatures are on the page.
        let d = MathCaptchaDetector;
        assert_eq!(d.priority(), 80);
    }

    #[test]
    fn detector_name_stable() {
        // Tests downstream may filter by name; pin it.
        let d = MathCaptchaDetector;
        assert_eq!(d.name(), "math_captcha");
    }
}