use super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::Duration;
const DEFAULT_POLL_INTERVAL_MS: u64 = 200;
const DEFAULT_MAX_WAIT_MS: u64 = 15_000;
const TOKEN_PROBE_JS: &str = r#"
(() => {
const probes = [
'input[name="cf-turnstile-response"]',
'textarea[name="cf-turnstile-response"]',
'#cf-chl-widget-' /* Cloudflare interstitial */,
'textarea[name="g-recaptcha-response"]',
'#g-recaptcha-response',
'textarea[name="h-captcha-response"]',
'input[name="h-captcha-response"]',
'[name="captchaToken"]',
'[name="captcha_token"]',
'[name="frc-captcha-solution"]' /* Friendly Captcha */,
'[name="mtcaptcha-verifiedtoken"]' /* MTCaptcha */,
'[name="altcha"]' /* ALTCHA */,
'[name="mcaptcha__token"]' /* mCaptcha */,
'[name="cap_token"]' /* Cap.dev */,
'[name="aws-waf-token"]' /* AWS WAF */,
];
/* Recursively walk light + shadow + same-origin iframe DOM so a
captcha rendered inside a closed-from-light <captcha-widget>
element with attachShadow() OR a same-origin nested iframe still
surfaces its response field. Iterative BFS to avoid blowing the
JS stack on deeply-nested trees. Cross-origin iframes throw on
contentDocument access — we swallow and skip them, the same
boundary a non-instrumented browser would hit. */
function* walkAllRoots(root) {
const queue = [root];
const seen = new WeakSet();
while (queue.length) {
const r = queue.shift();
if (seen.has(r)) continue;
seen.add(r);
yield r;
const subtree = r.querySelectorAll ? r.querySelectorAll('*') : [];
for (const el of subtree) {
if (el.shadowRoot) queue.push(el.shadowRoot);
if (el.tagName === 'IFRAME') {
let inner = null;
try { inner = el.contentDocument; } catch (_) {}
if (inner) queue.push(inner);
}
}
}
}
for (const root of walkAllRoots(document)) {
for (const sel of probes) {
let el;
try { el = root.querySelector(sel); } catch (_) { continue; }
if (!el) continue;
const v = (el.value || el.textContent || el.innerHTML || '').trim();
if (v && v.length > 0) return v;
}
}
/* grecaptcha.getResponse() handles invisible reCAPTCHA where
no DOM element holds the token directly. */
if (typeof grecaptcha !== 'undefined') {
try {
const r = grecaptcha.getResponse();
if (r && r.length > 0) return r;
} catch (_) {}
}
return null;
})()
"#;
pub struct WaitForTokenSolver {
poll_interval_ms: u64,
max_wait_ms: u64,
}
impl Default for WaitForTokenSolver {
fn default() -> Self {
Self::new()
}
}
impl WaitForTokenSolver {
pub fn new() -> Self {
Self {
poll_interval_ms: DEFAULT_POLL_INTERVAL_MS,
max_wait_ms: DEFAULT_MAX_WAIT_MS,
}
}
pub fn with_poll_interval_ms(mut self, ms: u64) -> Self {
self.poll_interval_ms = ms;
self
}
pub fn with_max_wait_ms(mut self, ms: u64) -> Self {
self.max_wait_ms = ms;
self
}
}
#[async_trait]
impl CaptchaSolver for WaitForTokenSolver {
fn name(&self) -> &'static str {
"WaitForTokenSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::AutoPass
}
fn supports(&self, kind: &DetectedCaptcha) -> bool {
matches!(
kind,
DetectedCaptcha::Turnstile
| DetectedCaptcha::RecaptchaV2
| DetectedCaptcha::RecaptchaV3
| DetectedCaptcha::HCaptcha
| DetectedCaptcha::ImageCaptcha
| DetectedCaptcha::PowCaptcha
| DetectedCaptcha::SliderCaptcha
| DetectedCaptcha::CanvasCaptcha
| DetectedCaptcha::MultiStepCaptcha
| DetectedCaptcha::ShadowDomCaptcha
| DetectedCaptcha::Custom(_)
)
}
async fn solve(
&self,
page: &Page,
_info: &crate::captcha_detect::CaptchaInfo,
) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
let deadline = t0 + Duration::from_millis(self.max_wait_ms);
loop {
let raw = page
.evaluate(TOKEN_PROBE_JS)
.await
.map_err(|e| anyhow!("wait_for_token probe failed: {e}"))?;
let token = raw.into_value::<Option<String>>().ok().flatten();
if let Some(token) = token {
if !token.is_empty() {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: token,
confidence: 1.0,
method: SolveMethod::AutoPass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
}
if Instant::now() >= deadline {
return Ok(CaptchaSolveResult::failure(
SolveMethod::AutoPass,
t0.elapsed().as_millis() as u64,
));
}
tokio::time::sleep(Duration::from_millis(self.poll_interval_ms)).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::captcha_detect::DetectedCaptcha;
#[test]
fn defaults_are_sane() {
let s = WaitForTokenSolver::new();
assert_eq!(s.poll_interval_ms, 200);
assert_eq!(s.max_wait_ms, 15_000);
}
#[test]
fn builders_override_each_field() {
let s = WaitForTokenSolver::new()
.with_poll_interval_ms(50)
.with_max_wait_ms(3_000);
assert_eq!(s.poll_interval_ms, 50);
assert_eq!(s.max_wait_ms, 3_000);
}
#[test]
fn supports_covers_all_passive_pass_kinds() {
let s = WaitForTokenSolver::new();
assert!(s.supports(&DetectedCaptcha::Turnstile));
assert!(s.supports(&DetectedCaptcha::RecaptchaV3));
assert!(s.supports(&DetectedCaptcha::HCaptcha));
assert!(s.supports(&DetectedCaptcha::Custom("datadome".into())));
}
#[test]
fn supports_returns_false_for_no_captcha() {
assert!(!WaitForTokenSolver::new().supports(&DetectedCaptcha::None));
}
#[test]
fn name_and_method_are_stable() {
let s = WaitForTokenSolver::new();
assert_eq!(s.name(), "WaitForTokenSolver");
assert_eq!(s.method(), SolveMethod::AutoPass);
}
#[test]
fn token_probe_js_walks_shadow_roots() {
assert!(TOKEN_PROBE_JS.contains("shadowRoot"));
assert!(TOKEN_PROBE_JS.contains("walkAllRoots"));
}
#[test]
fn token_probe_js_walks_same_origin_iframes() {
assert!(TOKEN_PROBE_JS.contains("contentDocument"));
assert!(TOKEN_PROBE_JS.contains("IFRAME"));
}
#[test]
fn token_probe_js_covers_documented_vendor_fields() {
let must_contain = [
"cf-turnstile-response",
"g-recaptcha-response",
"h-captcha-response",
"frc-captcha-solution",
"mtcaptcha-verifiedtoken",
"grecaptcha.getResponse",
];
for needle in must_contain {
assert!(
TOKEN_PROBE_JS.contains(needle),
"TOKEN_PROBE_JS must cover: {needle}"
);
}
}
}