captchaforge 0.2.35

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for chromiumoxide-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / CDP fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! DataDome dedicated solver.
//!
//! DataDome is the largest pure-play bot-management vendor outside
//! Cloudflare/Akamai (~7% of the top-1M sites per BuiltWith Q1 2026).
//! Its protection takes one of three shapes per request:
//!
//! 1. **Pass** — fingerprint scored low-risk; the response sets a
//!    long-form `datadome` cookie and the page loads normally.
//! 2. **Interstitial** — JS challenge runs in a `geo.captcha-delivery.com`
//!    iframe, posts sensor data to `/captcha/check`, sets a fresh
//!    `datadome` cookie on success, page reloads.
//! 3. **Slider** — when the JS challenge can't decide, DataDome
//!    serves a GeeTest-style horizontal slider puzzle. The generic
//!    [`super::super::SliderCaptchaSolver`] handles this case — we
//!    detect it here and yield rather than competing.
//!
//! This solver covers cases 1 and 2 (cookie-based pass + interstitial
//! waiting). Case 3 is delegated by reporting failure with a
//! screenshot so the chain falls through to the slider solver.
//!
//! # Pass criteria
//!
//! `datadome` cookie present AND value length > 80 AND value does
//! not start with `~~~~~~~~~`. The empty/zero-padded form is what
//! DataDome sets when it's *expecting* the sensor POST to follow;
//! the real "we accepted you" cookie is a 100+ char base64-ish
//! string. Pure-string check keeps the solver dependency-free.

use super::super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::{Duration, Instant};

/// Default total wait budget. DataDome's sensor POST + response
/// typically lands within 4–8s; 20s upper bound matches the
/// Cloudflare/Akamai interstitial solvers.
const DEFAULT_MAX_WAIT_MS: u64 = 20_000;

/// DataDome interstitial / cookie-pass solver.
pub struct DataDomeSolver {
    max_wait_ms: u64,
}

impl Default for DataDomeSolver {
    fn default() -> Self {
        Self::new()
    }
}

impl DataDomeSolver {
    pub fn new() -> Self {
        Self {
            max_wait_ms: DEFAULT_MAX_WAIT_MS,
        }
    }

    pub fn with_max_wait_ms(mut self, ms: u64) -> Self {
        self.max_wait_ms = ms;
        self
    }
}

/// Pure classifier for the `datadome` cookie value.
///
/// Returns one of [`DataDomeCookie::Missing`], [`DataDomeCookie::Pending`]
/// (sensor not yet POSTed — empty / placeholder shape), or
/// [`DataDomeCookie::Valid`] (server accepted the session).
///
/// # Examples
///
/// ```
/// use captchaforge::solver::vendors::datadome::{classify_cookie, DataDomeCookie};
///
/// assert_eq!(classify_cookie(""), DataDomeCookie::Missing);
/// assert_eq!(classify_cookie("~~~~~~~~~~~~~"), DataDomeCookie::Pending);
/// // Real DataDome cookies are 100+ char base64-ish strings:
/// let real = "ABCdef0123456789".repeat(7);
/// assert_eq!(classify_cookie(&real), DataDomeCookie::Valid);
/// ```
pub fn classify_cookie(value: &str) -> DataDomeCookie {
    if value.is_empty() {
        return DataDomeCookie::Missing;
    }
    let trimmed = value.trim_matches('"');
    if trimmed.is_empty() {
        return DataDomeCookie::Missing;
    }
    // Placeholder / pre-sensor cookies are short and tilde-heavy.
    // Real "you passed" cookies are URL-safe base64-ish, 100+ chars,
    // with at most a tiny number of tildes (DataDome uses tilde as
    // a segment separator in some payload variants but never as
    // padding for valid cookies).
    let tilde_ratio =
        trimmed.chars().filter(|c| *c == '~').count() as f64 / trimmed.len().max(1) as f64;
    if trimmed.len() < 80 || tilde_ratio > 0.5 {
        return DataDomeCookie::Pending;
    }
    DataDomeCookie::Valid
}

/// Validity classification of a `datadome` cookie value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataDomeCookie {
    /// Cookie not present at all.
    Missing,
    /// Cookie present but in placeholder / pre-sensor shape.
    Pending,
    /// Cookie carries the long-form session token — DataDome accepted us.
    Valid,
}

/// JS that probes the page for DataDome state. Returns one of:
///   - `{ phase: "blocked" }` — page surfaces DataDome's "you have
///     been blocked" content
///   - `{ phase: "slider" }` — captcha-delivery.com slider iframe
///     present; chain should yield to SliderCaptchaSolver
///   - `{ phase: "interstitial" }` — sensor script loaded but no
///     valid cookie yet
///   - `{ phase: "passed", cookie: "..." }` — `datadome` cookie set,
///     classifier decides validity
const PHASE_PROBE_JS: &str = r#"
(() => {
    const title = (document.title || '').toLowerCase();
    const body = document.body ? (document.body.innerText || '').toLowerCase() : '';
    if (body.includes('blocked') && body.includes('datadome')) {
        return { phase: 'blocked' };
    }
    if (document.querySelector('iframe[src*="captcha-delivery.com"]')) {
        return { phase: 'slider' };
    }
    let dd = '';
    for (const c of (document.cookie || '').split(';')) {
        const t = c.trim();
        if (t.startsWith('datadome=')) {
            dd = t.slice('datadome='.length);
            break;
        }
    }
    if (dd) return { phase: 'passed', cookie: dd };
    const sensorPresent =
        !!document.querySelector('script[src*="js.datadome.co"], script[src*="captcha-delivery.com"]') ||
        typeof window.DataDomeOptions !== 'undefined' ||
        typeof window.ddjskey !== 'undefined';
    if (sensorPresent) return { phase: 'interstitial' };
    return { phase: 'unknown' };
})()
"#;

#[derive(Debug, serde::Deserialize)]
struct PhaseProbe {
    phase: String,
    #[serde(default)]
    cookie: String,
}

#[async_trait]
impl CaptchaSolver for DataDomeSolver {
    fn name(&self) -> &'static str {
        "DataDomeSolver"
    }

    fn method(&self) -> SolveMethod {
        SolveMethod::AutoPass
    }

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        // DataDome is detected as Custom("datadome") by the bundled
        // community rule. Also accept SliderCaptcha so a misclassified
        // DataDome (it does also surface as a slider) routes here
        // first; we'll yield to the slider solver via screenshot+failure
        // when we see the captcha-delivery.com iframe.
        matches!(
            kind,
            DetectedCaptcha::Custom(_) | DetectedCaptcha::SliderCaptcha
        )
    }

    async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();
        let deadline = t0 + Duration::from_millis(self.max_wait_ms);

        loop {
            let raw = page
                .evaluate(PHASE_PROBE_JS)
                .await
                .map_err(|e| anyhow!("DataDome probe failed: {e}"))?;
            let probe: PhaseProbe = match raw.into_value() {
                Ok(p) => p,
                Err(_) => PhaseProbe {
                    phase: "interstitial".into(),
                    cookie: String::new(),
                },
            };

            match probe.phase.as_str() {
                "passed" if classify_cookie(&probe.cookie) == DataDomeCookie::Valid => {
                    let cookies = crate::cookies::capture_from_page(page)
                        .await
                        .unwrap_or_default();
                    return Ok(CaptchaSolveResult {
                        solution: "datadome:cookie".into(),
                        confidence: 1.0,
                        method: SolveMethod::AutoPass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: true,
                        screenshot: None,
                        cookies,
                        verified_outcome: None,
                    });
                }
                "passed" => {
                    // Cookie set but not yet Valid — keep waiting.
                }
                "slider" => {
                    // Yield to SliderCaptchaSolver. We surface a
                    // screenshot so the human-in-the-loop fallback
                    // (if reached) has something to show, but
                    // success=false so the chain advances.
                    let shot = super::super::screenshot_b64(page).await.ok();
                    return Ok(CaptchaSolveResult {
                        solution: String::new(),
                        confidence: 0.0,
                        method: SolveMethod::AutoPass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: false,
                        screenshot: shot,
                        cookies: Vec::new(),
                        verified_outcome: None,
                    });
                }
                "blocked" => {
                    return Ok(CaptchaSolveResult::failure(
                        SolveMethod::AutoPass,
                        t0.elapsed().as_millis() as u64,
                    ));
                }
                _ => {} // still on interstitial — keep waiting
            }

            if Instant::now() >= deadline {
                return Ok(CaptchaSolveResult::failure(
                    SolveMethod::AutoPass,
                    t0.elapsed().as_millis() as u64,
                ));
            }
            tokio::time::sleep(Duration::from_millis(500)).await;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn defaults_are_sane() {
        assert_eq!(DataDomeSolver::new().max_wait_ms, 20_000);
    }

    #[test]
    fn builders_override() {
        assert_eq!(
            DataDomeSolver::new().with_max_wait_ms(8_000).max_wait_ms,
            8_000
        );
    }

    #[test]
    fn name_method_stable() {
        let s = DataDomeSolver::new();
        assert_eq!(s.name(), "DataDomeSolver");
        assert_eq!(s.method(), SolveMethod::AutoPass);
    }

    #[test]
    fn supports_custom_and_slider() {
        let s = DataDomeSolver::new();
        assert!(s.supports(&DetectedCaptcha::Custom("datadome".into())));
        assert!(s.supports(&DetectedCaptcha::SliderCaptcha));
        assert!(!s.supports(&DetectedCaptcha::Turnstile));
        assert!(!s.supports(&DetectedCaptcha::HCaptcha));
        assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
    }

    #[test]
    fn classify_cookie_empty_is_missing() {
        assert_eq!(classify_cookie(""), DataDomeCookie::Missing);
        assert_eq!(classify_cookie("\"\""), DataDomeCookie::Missing);
    }

    #[test]
    fn classify_cookie_short_or_tildey_is_pending() {
        assert_eq!(classify_cookie("short"), DataDomeCookie::Pending);
        // Tilde-heavy placeholder
        assert_eq!(
            classify_cookie(
                "~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"
            ),
            DataDomeCookie::Pending,
        );
    }

    #[test]
    fn classify_cookie_long_b64ish_is_valid() {
        // Real cookies: 100+ char base64-ish
        let real = "ABCdef0123456789".repeat(7);
        assert_eq!(classify_cookie(&real), DataDomeCookie::Valid);
        // Quoted (some servers wrap in quotes)
        let quoted = format!("\"{real}\"");
        assert_eq!(classify_cookie(&quoted), DataDomeCookie::Valid);
    }

    #[test]
    fn classify_cookie_long_but_too_tildey_is_pending() {
        // Long but >50% tildes — that's not a real session token.
        // 40 'a' chars + 60 tildes = 100 char total, 60% tilde.
        let s = format!("{}{}", "a".repeat(40), "~".repeat(60));
        assert_eq!(classify_cookie(&s), DataDomeCookie::Pending);
    }

    #[test]
    fn phase_probe_js_covers_documented_signals() {
        for needle in [
            "datadome=",
            "captcha-delivery.com",
            "js.datadome.co",
            "datadomeoptions",
            "ddjskey",
            "blocked",
        ] {
            assert!(
                PHASE_PROBE_JS.to_lowercase().contains(needle),
                "PHASE_PROBE_JS must reference: {needle}"
            );
        }
    }

    #[test]
    fn phase_probe_js_emits_canonical_phases() {
        for phase in ["blocked", "slider", "interstitial", "passed", "unknown"] {
            assert!(
                PHASE_PROBE_JS.contains(&format!("'{phase}'")),
                "PHASE_PROBE_JS must emit phase: {phase}"
            );
        }
    }
}