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
//! Race-based vendor failover.
//!
//! When you depend on third-party captcha-solving services in
//! production, you should never depend on just one. 2captcha goes
//! down for maintenance, CapSolver throttles your IP, CapMonster
//! runs out of slots — each happens often enough that a single-
//! provider chain hits 0% during the outage. The fix is to install
//! multiple providers and **race** them: dispatch all in parallel,
//! return the first valid token, cancel the rest.
//!
//! `RacingSolver` wraps N inner solvers and exposes a single
//! `CaptchaSolver` front. `solve()` returns the first inner solver
//! to return success — others are cancelled (their HTTP requests
//! get dropped). This:
//!
//! - **Cuts p99 latency** — slowest provider doesn't gate the result.
//! - **Routes around outages** — if one provider 5xx's, the others
//!   carry the solve.
//! - **Costs N× per attempt in the worst case** — losing requests
//!   still get billed by some services. Operators should monitor
//!   their `Outcome::Success` telemetry and de-rotate weak providers
//!   over time. For services that don't bill cancelled requests
//!   (CapSolver) the cost amortises to roughly 1× anyway.
//!
//! Composition example:
//!
//! ```ignore
//! use std::sync::Arc;
//! use captchaforge::solver::{
//!     CaptchaSolverChain, RacingSolver, ThirdPartyCaptchaSolver,
//! };
//!
//! let race = RacingSolver::new("ThirdPartyRace")
//!     .with(ThirdPartyCaptchaSolver::two_captcha().with_api_key("..."))
//!     .with(ThirdPartyCaptchaSolver::cap_monster().with_api_key("..."))
//!     .with(ThirdPartyCaptchaSolver::cap_solver().with_api_key("..."));
//!
//! let mut chain = CaptchaSolverChain::empty();
//! chain.add_solver(race);
//! ```
//!
//! `name()` returns the configured label (default
//! `"RacingSolver"`); `method()` returns the method of the first
//! inner solver, so PatternStore + telemetry shape stays sensible
//! even though the actual winner is decided per-attempt.

use super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::sync::Arc;
use std::time::Instant;

/// Wraps N inner solvers and races them in parallel, returning the
/// first one to report success.
pub struct RacingSolver {
    name: &'static str,
    inner: Vec<Arc<dyn CaptchaSolver>>,
}

impl RacingSolver {
    pub fn new(name: &'static str) -> Self {
        Self {
            name,
            inner: Vec::new(),
        }
    }

    pub fn with<S: CaptchaSolver + 'static>(mut self, s: S) -> Self {
        self.inner.push(Arc::new(s));
        self
    }

    pub fn add<S: CaptchaSolver + 'static>(&mut self, s: S) {
        self.inner.push(Arc::new(s));
    }

    /// Borrow the inner solver list.
    pub fn inner(&self) -> &[Arc<dyn CaptchaSolver>] {
        &self.inner
    }
}

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

    fn method(&self) -> SolveMethod {
        // Inherit the first inner solver's method so the chain's
        // PatternStore + telemetry continue to key sensibly.
        // RacingSolver only makes sense when its members all
        // implement the same method (e.g. all ThirdPartyService).
        self.inner
            .first()
            .map(|s| s.method())
            .unwrap_or(SolveMethod::CrowdSourced)
    }

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        // Race supports the union: if ANY inner solver supports the
        // kind, the race will dispatch that one (and the rest noop).
        self.inner.iter().any(|s| s.supports(kind))
    }

    async fn solve(&self, page: &Page, info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();
        if self.inner.is_empty() {
            return Ok(CaptchaSolveResult::failure(
                self.method(),
                t0.elapsed().as_millis() as u64,
            ));
        }

        // FuturesUnordered with Pin<Box<dyn Future>> — non-'static
        // futures can borrow &Page directly. JoinSet would require
        // 'static futures and we can't safely move &Page across
        // tasks. When the first success arrives we early-return,
        // dropping the rest of the futures (which cancels their
        // pending HTTP requests at the next await point).
        use futures_util::stream::{FuturesUnordered, StreamExt};

        type RaceItem<'a> = std::pin::Pin<
            Box<
                dyn std::future::Future<
                        Output = (Arc<dyn CaptchaSolver>, Result<CaptchaSolveResult>),
                    > + Send
                    + 'a,
            >,
        >;

        let mut tasks: FuturesUnordered<RaceItem<'_>> = FuturesUnordered::new();
        for solver in &self.inner {
            if !solver.supports(&info.kind) {
                continue;
            }
            let s_clone = solver.clone();
            let s_call = solver.clone();
            tasks.push(Box::pin(async move {
                let r = s_call.solve(page, info).await;
                (s_clone, r)
            }));
        }

        if tasks.is_empty() {
            return Ok(CaptchaSolveResult::failure(
                self.method(),
                t0.elapsed().as_millis() as u64,
            ));
        }

        let mut last_err: Option<anyhow::Error> = None;
        let mut last_failure: Option<CaptchaSolveResult> = None;
        while let Some((solver, result)) = tasks.next().await {
            match result {
                Ok(r) if r.success => {
                    tracing::debug!(winner = solver.name(), "race winner");
                    return Ok(r);
                }
                Ok(r) => {
                    last_failure = Some(r);
                }
                Err(e) => {
                    last_err = Some(e);
                }
            }
        }

        if let Some(r) = last_failure {
            return Ok(r);
        }
        if let Some(e) = last_err {
            return Err(e);
        }
        Ok(CaptchaSolveResult::failure(
            self.method(),
            t0.elapsed().as_millis() as u64,
        ))
    }
}

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

    struct StubSolver {
        name: &'static str,
        kind_supports: bool,
        outcome: bool,
    }

    #[async_trait]
    impl CaptchaSolver for StubSolver {
        fn name(&self) -> &'static str {
            self.name
        }
        fn method(&self) -> SolveMethod {
            SolveMethod::ThirdPartyService
        }
        fn supports(&self, _kind: &DetectedCaptcha) -> bool {
            self.kind_supports
        }
        async fn solve(&self, _page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
            Ok(CaptchaSolveResult {
                solution: format!("from-{}", self.name),
                confidence: 1.0,
                method: SolveMethod::ThirdPartyService,
                time_ms: 1,
                success: self.outcome,
                screenshot: None,
                cookies: Vec::new(),
                verified_outcome: None,
            })
        }
    }

    #[test]
    fn empty_race_method_is_crowd_sourced() {
        let r = RacingSolver::new("empty");
        assert_eq!(r.method(), SolveMethod::CrowdSourced);
    }

    #[test]
    fn race_inherits_first_solver_method() {
        let r = RacingSolver::new("race").with(StubSolver {
            name: "a",
            kind_supports: true,
            outcome: true,
        });
        assert_eq!(r.method(), SolveMethod::ThirdPartyService);
    }

    #[test]
    fn race_supports_union_of_inner() {
        let r = RacingSolver::new("race")
            .with(StubSolver {
                name: "a",
                kind_supports: false,
                outcome: true,
            })
            .with(StubSolver {
                name: "b",
                kind_supports: true,
                outcome: true,
            });
        assert!(r.supports(&DetectedCaptcha::Turnstile));
    }

    #[test]
    fn race_supports_false_when_no_inner_supports() {
        let r = RacingSolver::new("race")
            .with(StubSolver {
                name: "a",
                kind_supports: false,
                outcome: true,
            })
            .with(StubSolver {
                name: "b",
                kind_supports: false,
                outcome: true,
            });
        assert!(!r.supports(&DetectedCaptcha::Turnstile));
    }

    #[test]
    fn name_propagates() {
        let r = RacingSolver::new("MyRace");
        assert_eq!(r.name(), "MyRace");
    }
}