use super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::Instant;
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",
];
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']",
];
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 };
})()
"#;
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 {
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,
));
};
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}}));
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,
})
}
}
pub(crate) fn solve_expression(text: &str) -> Option<i64> {
let t = text.to_lowercase();
let t = t
.replace("multiplied by", "*")
.replace("divided by", "/")
.replace(" plus ", " + ")
.replace(" minus ", " - ")
.replace(" times ", " * ")
.replace("×", "*")
.replace(" x ", " * ")
.replace("÷", "/");
let tokens: Vec<&str> = t
.split(|c: char| !c.is_alphanumeric() && c != '+' && c != '-' && c != '*' && c != '/')
.filter(|s| !s.is_empty())
.collect();
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 first_match_wins_when_multiple_expressions() {
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));
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}"
);
}
}
}