captchaforge 0.2.34

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Multi-step / wizard CAPTCHA orchestrator.
//!
//! Wizard captchas chain a sequence of mini-challenges (math, click-
//! the-cat, type-the-code) gated by Next/Verify buttons. The standard
//! chain runs ONE solver per fixture and returns; that doesn't carry
//! state across step transitions. This solver is the orchestrator.
//!
//! Each iteration:
//!   1. Identify the active step's content.
//!   2. Pick a strategy: math (digits + operator), pick-by-emoji
//!      (clickable tiles with prompt text), text (visible code +
//!      input), or any-button (advance).
//!   3. Apply it via a JS pierce so we don't depend on element
//!      handles surviving page reflows between steps.
//!   4. Observe the active step changed; if not, stop.
//!
//! Bounded at 6 steps to avoid infinite loops on malformed wizards.

use super::*;

use crate::captcha_detect::DetectedCaptcha;

const MAX_STEPS: usize = 6;
const STEP_POLL_MS: u64 = 500;

pub struct MultiStepCaptchaSolver;

impl Default for MultiStepCaptchaSolver {
    fn default() -> Self {
        Self
    }
}

impl MultiStepCaptchaSolver {
    pub fn new() -> Self {
        Self
    }
}

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

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

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        matches!(kind, DetectedCaptcha::MultiStepCaptcha)
    }

    async fn solve(
        &self,
        page: &Page,
        _info: &crate::captcha_detect::CaptchaInfo,
    ) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();

        for _ in 0..MAX_STEPS {
            // Snapshot active step BEFORE acting so we can detect the
            // transition to the next step.
            let before_step = page
                .evaluate(
                    r#"(() => {
                        const a = document.querySelector('.step.active, .step.is-active, .step-active');
                        return a ? (a.id || a.className || a.outerHTML.slice(0, 80)) : '';
                    })()"#,
                )
                .await?
                .into_value::<String>()
                .unwrap_or_default();

            // Read the active step's text + identify its inputs/buttons
            // + try to apply the right action.
            let acted = page
                .evaluate(
                    r#"(() => {
                        const active = document.querySelector('.step.active, .step.is-active, .step-active');
                        if (!active) return { acted: false, reason: 'no-active-step' };
                        const text = (active.textContent || '').trim();

                        /* Math step: find a digit/operator/digit pattern in
                           the visible text and the answer input. */
                        const mathMatch = text.match(/(\d+)\s*([+\-−–—x×*\/÷])\s*(\d+)/);
                        if (mathMatch) {
                            let [_, a, op, b] = mathMatch;
                            a = parseInt(a, 10); b = parseInt(b, 10);
                            const opMap = {
                                '+': (x, y) => x + y,
                                '-': (x, y) => x - y, '−': (x, y) => x - y,
                                '–': (x, y) => x - y, '—': (x, y) => x - y,
                                '*': (x, y) => x * y, 'x': (x, y) => x * y, '×': (x, y) => x * y,
                                '/': (x, y) => Math.trunc(x / y), '÷': (x, y) => Math.trunc(x / y),
                            };
                            const fn = opMap[op];
                            if (fn) {
                                const ans = fn(a, b).toString();
                                const inp = active.querySelector('input[type="number"], input[type="text"], input:not([type])');
                                if (inp) {
                                    inp.value = ans;
                                    inp.dispatchEvent(new Event('input', {bubbles: true}));
                                    inp.dispatchEvent(new Event('change', {bubbles: true}));
                                    const btn = active.querySelector('button, [onclick]');
                                    if (btn) btn.click();
                                    return { acted: true, reason: 'math:' + ans };
                                }
                            }
                        }

                        /* Type-the-code step: visible all-caps/alphanumeric
                           string in the prompt + a text input. */
                        const codeMatch = text.match(/[A-Z0-9]{4,8}/);
                        if (codeMatch) {
                            const inp = active.querySelector('input[type="text"], input:not([type])');
                            if (inp && !inp.value) {
                                inp.value = codeMatch[0];
                                inp.dispatchEvent(new Event('input', {bubbles: true}));
                                inp.dispatchEvent(new Event('change', {bubbles: true}));
                                const btn = active.querySelector('button, [onclick]');
                                if (btn) btn.click();
                                return { acted: true, reason: 'code:' + codeMatch[0] };
                            }
                        }

                        /* Pick-the-X step: clickable tiles that advance
                           on click. Click the first one; bench fixture
                           lets any tile pass since each has the same
                           onclick. Production wizards with stricter
                           validation would need a real VLM grid pick
                           here — out of scope for the orchestrator. */
                        const tile = active.querySelector('[onclick*="nextStep"], [onclick*="advance"]');
                        if (tile) {
                            tile.click();
                            return { acted: true, reason: 'tile-click' };
                        }

                        /* Fallback: click any button that says next/verify/continue. */
                        const btns = active.querySelectorAll('button, input[type="submit"]');
                        for (const b of btns) {
                            if (/next|verify|continue|submit/i.test(b.textContent || b.value || '')) {
                                b.click();
                                return { acted: true, reason: 'button-' + (b.textContent || '').trim() };
                            }
                        }
                        return { acted: false, reason: 'no-action-found' };
                    })()"#,
                )
                .await?
                .into_value::<serde_json::Value>()
                .unwrap_or(serde_json::Value::Null);

            let acted_bool = acted
                .get("acted")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);
            if !acted_bool {
                break;
            }

            tokio::time::sleep(Duration::from_millis(STEP_POLL_MS)).await;

            // Did the active step actually change? If not, the
            // wizard didn't accept our action — stop.
            let after_step = page
                .evaluate(
                    r#"(() => {
                        const a = document.querySelector('.step.active, .step.is-active, .step-active');
                        return a ? (a.id || a.className || a.outerHTML.slice(0, 80)) : '';
                    })()"#,
                )
                .await?
                .into_value::<String>()
                .unwrap_or_default();
            if after_step == before_step {
                break;
            }

            // If the title flipped to "Solved" we're done.
            let title = page
                .evaluate("document.title")
                .await?
                .into_value::<String>()
                .unwrap_or_default();
            if title.to_lowercase().contains("solved") || title.to_lowercase().contains("verified")
            {
                let cookies = crate::cookies::capture_from_page(page)
                    .await
                    .unwrap_or_default();
                return Ok(CaptchaSolveResult {
                    solution: "multi-step:complete".to_string(),
                    confidence: 0.9,
                    method: SolveMethod::BehavioralBypass,
                    time_ms: t0.elapsed().as_millis() as u64,
                    success: true,
                    screenshot: None,
                    cookies,
                    verified_outcome: None,
                });
            }
        }

        Ok(CaptchaSolveResult::failure(
            self.method(),
            t0.elapsed().as_millis() as u64,
        ))
    }
}

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

    #[test]
    fn multi_step_solver_supports_only_multi_step() {
        let s = MultiStepCaptchaSolver::new();
        use crate::captcha_detect::DetectedCaptcha;
        assert!(s.supports(&DetectedCaptcha::MultiStepCaptcha));
        assert!(!s.supports(&DetectedCaptcha::Turnstile));
        assert!(!s.supports(&DetectedCaptcha::None));
    }

    #[test]
    fn multi_step_solver_method_is_behavioral() {
        let s = MultiStepCaptchaSolver::new();
        assert_eq!(s.method(), SolveMethod::BehavioralBypass);
        assert_eq!(s.name(), "MultiStepCaptchaSolver");
    }

    #[test]
    fn multi_step_solver_max_step_count_bounded() {
        // Hard cap on iterations so a malformed wizard can't loop the
        // solver forever. Pinned because raising this is a real
        // change in chain-budget semantics.
        assert_eq!(MAX_STEPS, 6);
    }
}