captchaforge 0.2.40

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
//! Math-captcha solver.
//!
//! Many low-end captcha widgets (especially WordPress plugins, basic
//! contact forms, and homegrown anti-spam layers) ask the user to
//! solve a simple arithmetic question:
//!
//! - "What is 3 + 5?"
//! - "7 × 2 = ?"
//! - "Type the result of 12 - 4"
//! - "What is two plus four?"  (number words)
//!
//! The challenge is plain text in the DOM and the answer goes in a
//! visible `<input>`. captchaforge can solve these without VLM, OCR,
//! or third-party calls, just parse, evaluate, type, submit. Free,
//! ~50ms wall-clock.
//!
//! Supported expression shapes:
//!
//! - `N OP N` where OP ∈ `+ - * × x ÷ /` and N is a digit string or
//!   the spelled-out word for 0–20.
//! - Order doesn't matter: "What is 3 + 5", "3 plus 5 equals what",
//!   "Solve: 3 + 5 =" all parse to the same expression.
//! - Both decimal and word number representations.
//!
//! Out of scope (returns failure):
//!
//! - Multi-operator expressions ("3 + 5 - 2"), would need a full
//!   parser; rare in math captchas.
//! - Algebraic ("if x + 3 = 7, what is x?") (different solver).
//! - Word problems ("Tom has 3 apples..."). VLM territory.

use super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::Instant;

/// JS that scans the page for a likely math-captcha question + input
/// and returns both. Single round-trip.
pub(crate) const PROBE_JS: &str = r#"
(() => {
    const QUESTION_SELECTORS = [
        "[id*='math-captcha'] label",
        "[class*='math-captcha'] label",
        "[id*='math_captcha'] label",
        "[class*='math_captcha'] label",
        ".captcha-question",
        ".captcha-prompt",
        "label[for*='captcha']",
        "label[for*='math']",
        "[data-captcha-question]",
        ".g-recaptcha-question"
    ];
    const ANSWER_SELECTORS = [
        "input[name*='math-captcha']",
        "input[name*='math_captcha']",
        "input[name='captcha']",
        "input[name='captcha_answer']",
        "input[id*='captcha-answer']",
        "input[name*='quiz']",
        "input[type='text'][placeholder*='answer']",
        "input[type='number'][name*='captcha']"
    ];

    let question = null;
    for (const sel of QUESTION_SELECTORS) {
        const el = document.querySelector(sel);
        if (el && el.textContent && el.textContent.trim().length > 0) {
            question = el.textContent.trim();
            break;
        }
    }

    /* Fallback: scan the body for any small element whose text
       matches a math-captcha pattern. Tightly bounded so we don't
       false-positive on ad copy. */
    if (!question) {
        const candidates = document.querySelectorAll('label, p, span, div');
        const re = /(\d+|zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty)\s*(?:\+|-|\*|×|x|÷|\/|plus|minus|times|multiplied by|divided by)\s*(\d+|zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty)/i;
        for (const el of candidates) {
            if (el.children.length > 2) continue; /* skip wrappers */
            const text = (el.textContent || '').trim();
            if (text.length > 0 && text.length < 200 && re.test(text)) {
                question = text;
                break;
            }
        }
    }

    let inputSelector = null;
    for (const sel of ANSWER_SELECTORS) {
        if (document.querySelector(sel)) {
            inputSelector = sel;
            break;
        }
    }

    return { question, inputSelector };
})()
"#;

/// Solver for arithmetic math captchas.
pub struct MathCaptchaSolver;

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

impl MathCaptchaSolver {
    pub fn new() -> Self {
        Self
    }
}

#[derive(Debug, serde::Deserialize)]
struct ProbeResult {
    question: Option<String>,
    #[serde(rename = "inputSelector")]
    input_selector: Option<String>,
}

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

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

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        // Math captchas surface as: Custom("wp_math_captcha") (TOML
        // rule), PowCaptcha (some bundles route through it), or as a
        // generic Image/Canvas with embedded math text. Restrict
        // Custom to the known math-flavoured names so we don't
        // claim slider/click captchas like DataDome or PerimeterX.
        match kind {
            DetectedCaptcha::PowCaptcha
            | DetectedCaptcha::ImageCaptcha
            | DetectedCaptcha::CanvasCaptcha => true,
            DetectedCaptcha::Custom(name) => matches!(
                name.as_str(),
                "wp_math_captcha" | "math_captcha" | "math" | "arithmetic"
            ),
            _ => false,
        }
    }

    async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();
        let raw = page
            .evaluate(PROBE_JS)
            .await
            .map_err(|e| anyhow!("math probe failed: {e}"))?;
        let probe: ProbeResult = raw.into_value().unwrap_or(ProbeResult {
            question: None,
            input_selector: None,
        });

        let (Some(question), Some(input_sel)) = (probe.question, probe.input_selector) else {
            return Ok(CaptchaSolveResult::failure(
                self.method(),
                t0.elapsed().as_millis() as u64,
            ));
        };

        let Some(answer) = solve_expression(&question) else {
            return Ok(CaptchaSolveResult::failure(
                self.method(),
                t0.elapsed().as_millis() as u64,
            ));
        };

        // Type the answer into the input. Use evaluate so we don't
        // depend on element handle lifetimes.
        let escape = serde_json::to_string(&answer.to_string()).unwrap_or_else(|_| "\"\"".into());
        let escape_sel =
            serde_json::to_string(&input_sel).unwrap_or_else(|_| "\"input[type=text]\"".into());
        let inject = format!(
            r#"
            (() => {{
                const el = document.querySelector({sel});
                if (!el) return {{ entered: false, click: null }};
                el.value = {val};
                el.dispatchEvent(new Event('input', {{bubbles: true}}));
                el.dispatchEvent(new Event('change', {{bubbles: true}}));
                /* Locate (do NOT click) a nearby submit/verify/next button and hand
                   its centre back so the Rust side delivers a TRUSTED click: a
                   synthetic btn.click() here is event.isTrusted === false, which a
                   form that scores its submit rejects. We bound the search to the
                   form ancestor first, else the closest button in the widget. */
                const form = el.closest('form');
                let btn = null;
                const btnSelectors = [
                    'button[type="submit"]', 'input[type="submit"]',
                    'button.submit', 'button.verify', 'button.next',
                    '[onclick*="nextStep"]', 'button'
                ];
                for (const bs of btnSelectors) {{
                    btn = (form || document).querySelector(bs);
                    if (btn) break;
                }}
                let click = null;
                if (btn) {{
                    const r = btn.getBoundingClientRect();
                    if (r.width >= 1 && r.height >= 1) click = [r.left + r.width / 2, r.top + r.height / 2];
                }}
                return {{ entered: true, click: click }};
            }})()
            "#,
            sel = escape_sel,
            val = escape
        );
        // Law 10 / Screwdriver, never overclaim. The inject JS returns `false`
        // when the answer input field isn't found (`if (!el) return false`), so a
        // `false` (or an eval error) means the answer was NOT entered. The prior
        // `let _ =` + unconditional `success: true` reported a SOLVED math captcha
        // even when the field was missing and nothing was typed. Gate success on
        // the confirmed injection (`unwrap_or(false)` = couldn't confirm → not solved).
        let inject_result = page
            .evaluate(inject)
            .await
            .ok()
            .and_then(|v| v.into_value::<serde_json::Value>().ok());
        let injected = inject_result
            .as_ref()
            .and_then(|r| r.get("entered"))
            .and_then(serde_json::Value::as_bool)
            .unwrap_or(false);
        if !injected {
            tracing::warn!(
                "math-captcha answer injection did not confirm (answer field not found or eval failed); reporting success=false"
            );
        } else if let Some(arr) = inject_result
            .as_ref()
            .and_then(|r| r.get("click"))
            .and_then(|v| v.as_array())
        {
            // The answer is in the field (JS `.value` is fine); the SUBMIT must be a
            // TRUSTED click (a synthetic btn.click() is isTrusted=false and a form that
            // scores its submit rejects it). Law 10: surface a failed submit, don't drop it.
            if let (Some(x), Some(y)) = (
                arr.first().and_then(serde_json::Value::as_f64),
                arr.get(1).and_then(serde_json::Value::as_f64),
            ) {
                if let Err(e) = crate::behavior::click_realistic(page, x, y).await {
                    tracing::warn!(
                        "math-captcha submit trusted click failed ({e}); answer entered but may not be submitted"
                    );
                }
            }
        }

        let cookies = crate::cookies::capture_from_page(page)
            .await
            .unwrap_or_default();

        Ok(CaptchaSolveResult {
            solution: answer.to_string(),
            confidence: if injected { 1.0 } else { 0.0 },
            method: self.method(),
            time_ms: t0.elapsed().as_millis() as u64,
            success: injected,
            screenshot: None,
            cookies,
            verified_outcome: None,
        })
    }
}

/// Parse + evaluate a single arithmetic expression embedded in
/// natural-language English. Returns `None` if no recognisable
/// `N OP N` shape is present.
pub(crate) fn solve_expression(text: &str) -> Option<i64> {
    // Normalise whitespace + lowercase. Keep the original case
    // around for word-number lookup which is case-insensitive
    // anyway.
    let t = text.to_lowercase();

    // Tokenise into number-words / digits / operators.
    // Replace word operators with symbols first.
    let t = t
        .replace("multiplied by", "*")
        .replace("divided by", "/")
        .replace(" plus ", " + ")
        .replace(" minus ", " - ")
        .replace(" times ", " * ")
        .replace("×", "*")
        .replace(" x ", " * ")
        .replace("÷", "/")
        // Unicode minus sign (U+2212) and en/em-dashes routinely show
        // up in nicely-typeset math captchas. Single-pass normalisation
        // via a closure is faster than chained `.replace` calls (avoids
        // allocating an intermediate String per substitution).
        .replace(['\u{2212}', '\u{2013}', '\u{2014}'], "-");

    let tokens: Vec<&str> = t
        .split(|c: char| !c.is_alphanumeric() && c != '+' && c != '-' && c != '*' && c != '/')
        .filter(|s| !s.is_empty())
        .collect();

    // Find first triple that looks like N OP N.
    for window in tokens.windows(3) {
        let a = parse_number(window[0]);
        let op = window[1];
        let b = parse_number(window[2]);
        if let (Some(a), Some(b)) = (a, b) {
            return apply(a, op, b);
        }
    }
    None
}

fn parse_number(s: &str) -> Option<i64> {
    if let Ok(n) = s.parse::<i64>() {
        return Some(n);
    }
    match s {
        "zero" => Some(0),
        "one" => Some(1),
        "two" => Some(2),
        "three" => Some(3),
        "four" => Some(4),
        "five" => Some(5),
        "six" => Some(6),
        "seven" => Some(7),
        "eight" => Some(8),
        "nine" => Some(9),
        "ten" => Some(10),
        "eleven" => Some(11),
        "twelve" => Some(12),
        "thirteen" => Some(13),
        "fourteen" => Some(14),
        "fifteen" => Some(15),
        "sixteen" => Some(16),
        "seventeen" => Some(17),
        "eighteen" => Some(18),
        "nineteen" => Some(19),
        "twenty" => Some(20),
        _ => None,
    }
}

fn apply(a: i64, op: &str, b: i64) -> Option<i64> {
    match op {
        "+" => Some(a + b),
        "-" => Some(a - b),
        "*" => Some(a * b),
        "/" if b != 0 => Some(a / b),
        _ => None,
    }
}

#[cfg(test)]
#[path = "math_captcha/tests.rs"]
mod tests;