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
//! 2captcha-compatible wire protocol: build the form-encoded task
//! params for each captcha shape, submit to `in.php`, and poll
//! `res.php` until the worker returns a token (or a terminal error).
//!
//! All HTTP calls route through the [`retry`](super::retry) transient
//! backoff so a single network blip mid-solve doesn't waste the run,
//! while terminal API codes (bad key, zero balance) fail fast.

use super::*;

impl ThirdPartyCaptchaSolver {
    /// Map a CaptchaInfo to the 2captcha-protocol form-encoded
    /// parameters. Returns `None` for kinds the protocol can't express.
    pub(crate) fn build_task_params(info: &CaptchaInfo) -> Option<Vec<(&'static str, String)>> {
        let pageurl = info.page_url.clone();
        let sitekey = info.site_key.clone();

        match &info.kind {
            DetectedCaptcha::Turnstile => {
                let key = sitekey?;
                Some(vec![
                    ("method", "turnstile".into()),
                    ("sitekey", key),
                    ("pageurl", pageurl),
                    ("json", "1".into()),
                ])
            }
            DetectedCaptcha::RecaptchaV2 => {
                let key = sitekey?;
                Some(vec![
                    ("method", "userrecaptcha".into()),
                    ("googlekey", key),
                    ("pageurl", pageurl),
                    ("json", "1".into()),
                ])
            }
            DetectedCaptcha::RecaptchaV3 => {
                let key = sitekey?;
                Some(vec![
                    ("method", "userrecaptcha".into()),
                    ("googlekey", key),
                    ("pageurl", pageurl),
                    ("version", "v3".into()),
                    ("min_score", "0.3".into()),
                    ("json", "1".into()),
                ])
            }
            DetectedCaptcha::HCaptcha => {
                let key = sitekey?;
                Some(vec![
                    ("method", "hcaptcha".into()),
                    ("sitekey", key),
                    ("pageurl", pageurl),
                    ("json", "1".into()),
                ])
            }
            DetectedCaptcha::Custom(name) => match name.as_str() {
                "datadome" => {
                    // DataDome puts the captcha-url in container_selector by convention
                    // (TOML rule `triggers.window_globals = ["dd"]` plus the rule
                    // populates container with the captcha URL when emitting CaptchaInfo).
                    // If unavailable, fall back to pageurl which the service can
                    // resolve from the page itself.
                    let captcha_url = info
                        .container_selector
                        .clone()
                        .unwrap_or_else(|| pageurl.clone());
                    Some(vec![
                        ("method", "datadome".into()),
                        ("captcha_url", captcha_url),
                        ("pageurl", pageurl),
                        ("json", "1".into()),
                    ])
                }
                "arkose_funcaptcha" => {
                    let key = sitekey?;
                    Some(vec![
                        ("method", "funcaptcha".into()),
                        ("publickey", key),
                        ("pageurl", pageurl),
                        ("surl", "https://client-api.arkoselabs.com".into()),
                        ("json", "1".into()),
                    ])
                }
                "geetest_v3" => {
                    // gt + challenge are typically provided by the page; if not
                    // present in CaptchaInfo we can't construct a valid task.
                    let gt = sitekey?;
                    Some(vec![
                        ("method", "geetest".into()),
                        ("gt", gt),
                        (
                            "challenge",
                            info.container_selector.clone().unwrap_or_default(),
                        ),
                        ("pageurl", pageurl),
                        ("json", "1".into()),
                    ])
                }
                "geetest_v4" => {
                    let captcha_id = sitekey?;
                    Some(vec![
                        ("method", "geetest_v4".into()),
                        ("captcha_id", captcha_id),
                        ("pageurl", pageurl),
                        ("json", "1".into()),
                    ])
                }
                "aws_waf_captcha" => {
                    // AWS WAF needs sitekey, iv, context, only sitekey is in
                    // the standard CaptchaInfo. Without iv+context the task
                    // can't be valid; surface as None so the chain falls
                    // through cleanly rather than burning user balance.
                    let _key = sitekey?;
                    None
                }
                _ => None,
            },
            _ => None,
        }
    }

    pub(crate) async fn submit_task(&self, params: &[(&'static str, String)]) -> Result<String> {
        let key = self
            .api_key
            .as_deref()
            .ok_or_else(|| anyhow!("no API key configured for ThirdPartyCaptchaSolver"))?;
        let mut form: Vec<(&str, String)> = Vec::with_capacity(params.len() + 1);
        form.push(("key", key.to_string()));
        for (k, v) in params {
            form.push((*k, v.clone()));
        }

        let url = format!("{}/in.php", self.service.base_url().trim_end_matches('/'));
        retry_transient("third-party submit", || async {
            let resp = self
                .client
                .post(&url)
                .form(&form)
                .send()
                .await
                .map_err(transient)?;
            let status = resp.status();
            let body = resp.text().await.map_err(transient)?;
            // 5xx = server-side, retry. 4xx = client error (bad key
            // shape etc.), terminal (fail without retry).
            if status.is_server_error() {
                return Err(RetryError::Transient(anyhow!(
                    "third-party submit {status}: {body}"
                )));
            }
            if !status.is_success() {
                return Err(RetryError::Terminal(anyhow!(
                    "third-party submit returned {status}: {body}"
                )));
            }
            // Response is JSON: {"status":1,"request":"<id>"} on success.
            let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
                RetryError::Terminal(anyhow!("third-party submit json parse: {e}; body={body}"))
            })?;
            if v["status"].as_i64().unwrap_or(0) != 1 {
                let err_str = v["request"].as_str().unwrap_or("<no error>").to_string();
                // Terminal API errors (bad key etc.) shouldn't be
                // retried even if the HTTP layer succeeded.
                if is_terminal_error(&err_str) {
                    return Err(RetryError::Terminal(anyhow!(
                        "third-party submit terminal error: {err_str}"
                    )));
                }
                return Err(RetryError::Transient(anyhow!(
                    "third-party submit error: {err_str}"
                )));
            }
            v["request"].as_str().map(|s| s.to_string()).ok_or_else(|| {
                RetryError::Terminal(anyhow!(
                    "third-party submit response missing 'request' field"
                ))
            })
        })
        .await
    }

    pub(crate) async fn poll_result(&self, task_id: &str) -> Result<String> {
        let key = self
            .api_key
            .as_deref()
            .ok_or_else(|| anyhow!("no API key configured for ThirdPartyCaptchaSolver"))?;
        let url = format!("{}/res.php", self.service.base_url().trim_end_matches('/'));

        for _ in 0..self.max_polls {
            tokio::time::sleep(Duration::from_millis(self.poll_interval_ms)).await;
            // Per-poll HTTP also gets transient retry, a single
            // network blip mid-poll shouldn't waste the whole solve.
            let body = retry_transient("third-party poll", || async {
                let resp = self
                    .client
                    .get(&url)
                    .query(&[
                        ("key", key),
                        ("action", "get"),
                        ("id", task_id),
                        ("json", "1"),
                    ])
                    .send()
                    .await
                    .map_err(transient)?;
                let status = resp.status();
                if status.is_server_error() {
                    return Err(RetryError::Transient(anyhow!("third-party poll {status}")));
                }
                if !status.is_success() {
                    return Err(RetryError::Terminal(anyhow!(
                        "third-party poll returned {status}"
                    )));
                }
                resp.text().await.map_err(transient)
            })
            .await?;

            let v: serde_json::Value = serde_json::from_str(&body)
                .map_err(|e| anyhow!("third-party poll json parse: {e}; body={body}"))?;
            let status = v["status"].as_i64().unwrap_or(0);
            let request = v["request"].as_str().unwrap_or("");
            if status == 1 {
                return Ok(request.to_string());
            }
            // CAPCHA_NOT_READY is the documented in-flight signal.
            // Anything else with status == 0 is an error code (see
            // https://2captcha.com/2captcha-api#error_handling). Some
            // are terminal (bad key, no balance, banned IP) and will
            // never flip to ready, so polling 30 more times wastes
            // ~150s on a doomed request. Fail fast on the known
            // terminal codes.
            if request == "CAPCHA_NOT_READY" {
                continue;
            }
            if is_terminal_error(request) {
                return Err(anyhow!(
                    "third-party terminal error (won't retry): {request}"
                ));
            }
            // Unknown non-ready code with status==0: keep polling
            // rather than abort, service-specific error strings vary
            // and some recover (e.g. WRONG_USER_KEY on a CapMonster
            // re-auth).
        }
        Err(anyhow!(
            "third-party solver timed out after {} polls",
            self.max_polls
        ))
    }
}