captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
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();

                        /* Return an element's viewport-centre so the Rust side can deliver a
                           TRUSTED BiDi pointer click there (event.isTrusted === true). A JS
                           el.click() inside this evaluate arrives isTrusted === false and is
                           rejected on sight by any step that scores input trust, the exact
                           reason synthetic clicks never solve a real challenge
                           (foxdriver cross_origin_click pins the positive/negative pair).
                           Filling an input VALUE in JS is fine (forms read `.value`); only the
                           advance CLICK must be trusted, so we hand its coordinate back. */
                        const centre = (el) => {
                            if (!el) return null;
                            try { el.scrollIntoView({block:'center', inline:'center'}); } catch (e) {}
                            const r = el.getBoundingClientRect();
                            if (r.width < 1 || r.height < 1) return null;
                            return [r.left + r.width / 2, r.top + r.height / 2];
                        };

                        /* 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]');
                                    return { acted: true, reason: 'math:' + ans, click: centre(btn) };
                                }
                            }
                        }

                        /* 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]');
                                return { acted: true, reason: 'code:' + codeMatch[0], click: centre(btn) };
                            }
                        }

                        /* Pick-the-X step: clickable tiles that advance
                           on click. Hand back the first tile's centre; 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) {
                            return { acted: true, reason: 'tile-click', click: centre(tile) };
                        }

                        /* Fallback: advance via 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 || '')) {
                                return { acted: true, reason: 'button-' + (b.textContent || '').trim(), click: centre(b) };
                            }
                        }
                        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;
            }

            // Deliver the advance interaction as a TRUSTED BiDi click (isTrusted === true).
            // The evaluate above set any form value in JS (fine, forms read `.value`) but
            // returned the advance control's viewport centre instead of clicking it, because
            // a JS el.click() arrives isTrusted === false and is rejected by every step that
            // scores input trust (proven by foxdriver cross_origin_click's negative case).
            // `click` is null only when no advance control was found; then we skip the click
            // and the transition check below catches the no-advance and stops.
            if let Some(arr) = acted.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),
                ) {
                    page.click_at(x, y).await?;
                }
            }

            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);
    }
}