captchaforge 0.2.28

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
//! 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;

/// Selectors we'll search for the challenge text. Ordered most to
/// least specific — first hit wins. Mirrored in the JS probe below;
/// kept here for the test that asserts they're in sync.
#[allow(dead_code)]
const QUESTION_SELECTORS: &[&str] = &[
    "[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",
];

/// Selectors for the answer input. Mirrored in the JS probe.
#[allow(dead_code)]
const ANSWER_SELECTORS: &[&str] = &[
    "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']",
];

/// 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 false;
                el.value = {val};
                el.dispatchEvent(new Event('input', {{bubbles: true}}));
                el.dispatchEvent(new Event('change', {{bubbles: true}}));
                /* Click a nearby submit/verify/next button so live-validating
                   forms see the change AND single-page forms with explicit
                   submission also pass. We walk up the form ancestor first
                   to bound the click; otherwise look for the closest button
                   in the same 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;
                }}
                if (btn) {{
                    btn.click();
                }}
                return true;
            }})()
            "#,
            sel = escape_sel,
            val = escape
        );
        let _ = page.evaluate(inject).await;

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

        Ok(CaptchaSolveResult {
            solution: answer.to_string(),
            confidence: 1.0,
            method: self.method(),
            time_ms: t0.elapsed().as_millis() as u64,
            success: true,
            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)]
mod tests {
    use super::*;

    #[test]
    fn solves_digit_addition() {
        assert_eq!(solve_expression("What is 3 + 5?"), Some(8));
    }

    #[test]
    fn solves_digit_subtraction() {
        assert_eq!(solve_expression("12 - 4 = ?"), Some(8));
    }

    #[test]
    fn solves_digit_multiplication() {
        assert_eq!(solve_expression("7 * 2 = ?"), Some(14));
        assert_eq!(solve_expression("7 × 2 = ?"), Some(14));
        assert_eq!(solve_expression("7 x 2 = ?"), Some(14));
    }

    #[test]
    fn solves_digit_division() {
        assert_eq!(solve_expression("20 / 4 = ?"), Some(5));
        assert_eq!(solve_expression("20 ÷ 4 = ?"), Some(5));
    }

    #[test]
    fn solves_word_numbers() {
        assert_eq!(solve_expression("What is two plus four?"), Some(6));
        assert_eq!(solve_expression("six minus three"), Some(3));
        assert_eq!(solve_expression("five times three"), Some(15));
    }

    #[test]
    fn solves_word_operators() {
        assert_eq!(solve_expression("4 plus 5"), Some(9));
        assert_eq!(solve_expression("10 minus 3"), Some(7));
        assert_eq!(solve_expression("3 times 4"), Some(12));
        assert_eq!(solve_expression("12 divided by 3"), Some(4));
        assert_eq!(solve_expression("5 multiplied by 6"), Some(30));
    }

    #[test]
    fn ignores_surrounding_text() {
        assert_eq!(
            solve_expression("Solve this anti-spam check: 8 + 7 = ? then submit"),
            Some(15)
        );
    }

    #[test]
    fn handles_no_match() {
        assert!(solve_expression("How are you today?").is_none());
        assert!(solve_expression("").is_none());
        assert!(solve_expression("just one number 5").is_none());
    }

    #[test]
    fn handles_div_by_zero() {
        assert!(solve_expression("5 / 0").is_none());
    }

    #[test]
    fn handles_negative_results() {
        assert_eq!(solve_expression("3 - 10"), Some(-7));
    }

    #[test]
    fn solves_unicode_minus_sign() {
        // Typeset math captchas (e.g. multi-step wizards using a CSS
        // font-stack with proper math glyphs) emit U+2212 instead of
        // ASCII '-'. Solver must handle both — without this, the
        // multi_step bench fixture (8 − 3 = ?) fails detection.
        assert_eq!(solve_expression("8 \u{2212} 3 = ?"), Some(5));
        assert_eq!(solve_expression("8 \u{2013} 3"), Some(5));
        assert_eq!(solve_expression("8 \u{2014} 3"), Some(5));
    }

    #[test]
    fn first_match_wins_when_multiple_expressions() {
        // First expression in source order is what we return.
        assert_eq!(
            solve_expression("Question 1: 2 + 2 = ? Question 2: 3 + 3 = ?"),
            Some(4)
        );
    }

    #[test]
    fn solves_zero_through_twenty_word_numbers() {
        for (i, word) in [
            "zero",
            "one",
            "two",
            "three",
            "four",
            "five",
            "six",
            "seven",
            "eight",
            "nine",
            "ten",
            "eleven",
            "twelve",
            "thirteen",
            "fourteen",
            "fifteen",
            "sixteen",
            "seventeen",
            "eighteen",
            "nineteen",
            "twenty",
        ]
        .iter()
        .enumerate()
        {
            assert_eq!(parse_number(word), Some(i as i64), "word: {word}");
        }
    }

    #[test]
    fn name_and_method_stable() {
        let s = MathCaptchaSolver::new();
        assert_eq!(s.name(), "MathCaptchaSolver");
        assert_eq!(s.method(), SolveMethod::BehavioralBypass);
    }

    #[test]
    fn supports_relevant_kinds_only() {
        let s = MathCaptchaSolver::new();
        assert!(s.supports(&DetectedCaptcha::Custom("wp_math_captcha".into())));
        assert!(s.supports(&DetectedCaptcha::Custom("math_captcha".into())));
        assert!(s.supports(&DetectedCaptcha::PowCaptcha));
        assert!(s.supports(&DetectedCaptcha::ImageCaptcha));
        assert!(s.supports(&DetectedCaptcha::CanvasCaptcha));
        // Slider/non-math Custom names must NOT match — math should
        // not claim DataDome or PerimeterX, which are interactive
        // slider/click captchas.
        assert!(!s.supports(&DetectedCaptcha::Custom("datadome".into())));
        assert!(!s.supports(&DetectedCaptcha::Custom("perimeterx_human".into())));
        assert!(!s.supports(&DetectedCaptcha::Custom("akamai_bot_manager".into())));
        assert!(!s.supports(&DetectedCaptcha::Turnstile));
        assert!(!s.supports(&DetectedCaptcha::None));
    }

    #[test]
    fn probe_js_includes_documented_selectors() {
        for sel in QUESTION_SELECTORS {
            assert!(
                PROBE_JS.contains(sel),
                "probe must contain question sel: {sel}"
            );
        }
        for sel in ANSWER_SELECTORS {
            assert!(
                PROBE_JS.contains(sel),
                "probe must contain input sel: {sel}"
            );
        }
    }
}