use rand::{rngs::StdRng, Rng, SeedableRng};
use tracing::{debug, info};
use super::super::*;
pub const TURNSTILE_IFRAME_SELECTOR: &str =
r#"iframe[src*="challenges.cloudflare.com/cdn-cgi/challenge-platform"]"#;
pub const CHECKBOX_OFFSET_X: f64 = 28.0;
pub const CHECKBOX_OFFSET_Y: f64 = 32.0;
#[derive(Default, Debug)]
pub struct TurnstileInteractiveSolver {
config: SolveConfig,
}
impl TurnstileInteractiveSolver {
pub fn new() -> Self {
Self::default()
}
pub fn with_config(mut self, config: SolveConfig) -> Self {
self.config = config;
self
}
async fn find_iframe_rect(page: &Page) -> Result<Option<(f64, f64, f64, f64)>> {
let js = format!(
r#"(() => {{
const f = document.querySelector({sel});
if (!f) return null;
const r = f.getBoundingClientRect();
if (r.width < 10 || r.height < 10) return null;
return [r.left, r.top, r.width, r.height];
}})()"#,
sel = serde_json::to_string(TURNSTILE_IFRAME_SELECTOR).unwrap(),
);
match page.evaluate(js.as_str()).await {
Ok(v) => Ok(v.into_value::<Option<(f64, f64, f64, f64)>>().unwrap_or(None)),
Err(_) => Ok(None),
}
}
async fn managed_challenge_present(page: &Page) -> bool {
let js = r#"(() => {
const fs = document.querySelectorAll('iframe[src*="challenges.cloudflare.com"]');
for (const f of fs) {
if (f.src.includes('challenge-platform/scripts/')) return true;
}
return false;
})()"#;
page.evaluate(js)
.await
.ok()
.and_then(|v| v.into_value::<bool>().ok())
.unwrap_or(false)
}
async fn warmup(page: &Page, rng: &mut StdRng) -> Result<()> {
for _ in 0..2 {
let x1 = rng.gen_range(50.0..900.0_f64);
let y1 = rng.gen_range(50.0..600.0_f64);
let x2 = rng.gen_range(50.0..900.0_f64);
let y2 = rng.gen_range(50.0..600.0_f64);
let _ = crate::behavior::mouse_move_human(page, x1, y1, x2, y2).await;
tokio::time::sleep(Duration::from_millis(rng.gen_range(120..380))).await;
}
Ok(())
}
}
#[async_trait]
impl CaptchaSolver for TurnstileInteractiveSolver {
fn name(&self) -> &'static str {
"TurnstileInteractiveSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::BehavioralBypass
}
fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
matches!(kind, crate::captcha_detect::DetectedCaptcha::Turnstile)
}
async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
let mut rng = StdRng::from_entropy();
if crate::frame::verify_token_in_frames(page, "cf-turnstile-response")
.await
.unwrap_or(false)
{
info!("Turnstile token already populated, no click needed");
return Ok(CaptchaSolveResult {
solution: "turnstile:passive".into(),
confidence: 0.95,
method: SolveMethod::AutoPass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies: crate::cookies::capture_from_page(page).await.unwrap_or_default(),
verified_outcome: None,
});
}
let mut rect = None;
for attempt in 0..self.config.checkbox_max_attempts {
tokio::time::sleep(Duration::from_millis(self.config.checkbox_poll_interval_ms)).await;
if let Some(r) = Self::find_iframe_rect(page).await? {
rect = Some(r);
debug!(attempt, ?r, "found Turnstile iframe");
break;
}
}
let (left, top, _w, _h) = rect.ok_or_else(|| {
anyhow!(
"Turnstile iframe not found after {} attempts",
self.config.checkbox_max_attempts
)
})?;
let _ = Self::warmup(page, &mut rng).await;
let target_x = left + CHECKBOX_OFFSET_X + rng.gen_range(-3.0..3.0);
let target_y = top + CHECKBOX_OFFSET_Y + rng.gen_range(-3.0..3.0);
let approach_x = target_x + rng.gen_range(-180.0..180.0);
let approach_y = target_y + rng.gen_range(-90.0..90.0);
crate::behavior::mouse_move_human(page, approach_x, approach_y, target_x, target_y)
.await?;
tokio::time::sleep(Duration::from_millis(rng.gen_range(40..90))).await;
crate::behavior::click_realistic(page, target_x, target_y).await?;
for attempt in 0..self.config.token_max_attempts {
tokio::time::sleep(Duration::from_millis(self.config.token_poll_interval_ms)).await;
if crate::frame::verify_token_in_frames(page, "cf-turnstile-response")
.await
.unwrap_or(false)
{
info!(attempt, "Turnstile token populated after click");
return Ok(CaptchaSolveResult {
solution: "turnstile:interactive".into(),
confidence: 0.9,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies: crate::cookies::capture_from_page(page).await.unwrap_or_default(),
verified_outcome: None,
});
}
if Self::managed_challenge_present(page).await {
debug!("Cloudflare escalated to managed challenge — yielding to VLM");
let shot = super::super::screenshot_b64(page).await.ok();
return Ok(CaptchaSolveResult {
solution: String::new(),
confidence: 0.0,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: false,
screenshot: shot,
cookies: Vec::new(),
verified_outcome: None,
});
}
}
Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checkbox_offsets_match_standard_widget() {
let x = CHECKBOX_OFFSET_X;
let y = CHECKBOX_OFFSET_Y;
assert!(x > 0.0 && x < 60.0, "X offset out of widget bounds");
assert!(y > 0.0 && y < 60.0, "Y offset out of widget bounds");
}
#[test]
fn iframe_selector_matches_production_pattern() {
let live_url =
"https://challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g/turnstile/if/abcd";
assert!(live_url.contains("challenges.cloudflare.com/cdn-cgi/challenge-platform"));
}
#[test]
fn supports_only_turnstile() {
use crate::captcha_detect::DetectedCaptcha;
let s = TurnstileInteractiveSolver::new();
assert!(s.supports(&DetectedCaptcha::Turnstile));
for other in [
DetectedCaptcha::RecaptchaV2,
DetectedCaptcha::HCaptcha,
DetectedCaptcha::PowCaptcha,
DetectedCaptcha::SliderCaptcha,
DetectedCaptcha::ImageCaptcha,
DetectedCaptcha::AudioCaptcha,
DetectedCaptcha::CanvasCaptcha,
DetectedCaptcha::ShadowDomCaptcha,
DetectedCaptcha::MultiStepCaptcha,
DetectedCaptcha::Custom("datadome".into()),
] {
assert!(
!s.supports(&other),
"TurnstileInteractive must not claim {other:?}"
);
}
}
#[test]
fn name_and_method_are_stable() {
let s = TurnstileInteractiveSolver::new();
assert_eq!(s.name(), "TurnstileInteractiveSolver");
assert_eq!(s.method(), SolveMethod::BehavioralBypass);
}
#[test]
fn default_constructor_uses_default_solveconfig() {
let s = TurnstileInteractiveSolver::default();
let dflt = SolveConfig::default();
assert_eq!(s.config.checkbox_poll_interval_ms, dflt.checkbox_poll_interval_ms);
assert_eq!(s.config.token_max_attempts, dflt.token_max_attempts);
}
#[test]
fn with_config_overrides_solveconfig() {
let custom = SolveConfig {
checkbox_poll_interval_ms: 250,
token_max_attempts: 32,
..SolveConfig::default()
};
let s = TurnstileInteractiveSolver::new().with_config(custom);
assert_eq!(s.config.checkbox_poll_interval_ms, 250);
assert_eq!(s.config.token_max_attempts, 32);
}
}