use super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::Instant;
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 {{ 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
);
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())
{
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,
})
}
}
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("÷", "/")
.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();
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;