captchaforge 0.2.9

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
use super::*;

// ─── AudioCaptchaSolver ───────────────────────────────────────────────────────

/// Solves reCAPTCHA v2 via the audio challenge path.
///
/// Workflow:
///   1. Click the audio challenge button.
///   2. Extract the `.mp3` download URL from the audio challenge widget.
///   3. Download the audio and send to a speech-to-text endpoint.
///   4. Type the transcription into the answer field.
pub struct AudioCaptchaSolver {
    client: reqwest::Client,
    /// Speech-to-text endpoint. Defaults to a local Whisper-compatible API.
    pub(crate) stt_endpoint: String,
    pub(crate) config: SolveConfig,
}

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

impl AudioCaptchaSolver {
    pub fn new() -> Self {
        let config = SolveConfig::default();
        Self {
            client: reqwest::Client::builder()
                .timeout(Duration::from_millis(config.client_http_timeout_ms))
                .build()
                .unwrap_or_else(|_| reqwest::Client::new()),
            stt_endpoint: "http://localhost:9000/asr".to_string(),
            config,
        }
    }

    pub fn with_stt_endpoint(mut self, url: impl Into<String>) -> Self {
        self.stt_endpoint = url.into();
        self
    }

    pub fn with_config(mut self, config: SolveConfig) -> Self {
        self.config = config;
        self
    }
}

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

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

    fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
        use crate::captcha_detect::DetectedCaptcha;
        matches!(
            kind,
            DetectedCaptcha::RecaptchaV2 | DetectedCaptcha::AudioCaptcha
        )
    }

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

        // Click the audio challenge button.
        let audio_btn = page
            .find_element("#recaptcha-audio-button, .rc-button-audio")
            .await?;
        audio_btn.click().await?;
        tokio::time::sleep(Duration::from_millis(self.config.audio_button_delay_ms)).await;

        // Extract the audio source URL.
        let audio_src = page
            .evaluate(r#"document.querySelector('audio').getAttribute('src')"#)
            .await?
            .into_value::<String>()?;

        if audio_src.is_empty() {
            return Ok(CaptchaSolveResult::failure(
                SolveMethod::AudioBypass,
                t0.elapsed().as_millis() as u64,
            ));
        }

        // Download the audio file.
        let audio_bytes = self.client.get(&audio_src).send().await?.bytes().await?;

        // Send to STT endpoint.
        let stt_resp = self
            .client
            .post(&self.stt_endpoint)
            .header("Content-Type", "audio/mpeg")
            .body(audio_bytes)
            .send()
            .await?;

        if !stt_resp.status().is_success() {
            return Ok(CaptchaSolveResult::failure(
                SolveMethod::AudioBypass,
                t0.elapsed().as_millis() as u64,
            ));
        }

        let transcript = stt_resp.text().await?.trim().to_string();

        if transcript.is_empty() {
            return Ok(CaptchaSolveResult::failure(
                SolveMethod::AudioBypass,
                t0.elapsed().as_millis() as u64,
            ));
        }

        // Type the transcript into the answer field and submit.
        let input = page
            .find_element("#audio-response, .rc-response-input")
            .await?;
        input.click().await?;
        input.type_str(&transcript).await?;

        tokio::time::sleep(Duration::from_millis(self.config.audio_submit_delay_ms)).await;

        let verify = page
            .find_element("#recaptcha-verify-button, .rc-button-default")
            .await?;
        verify.click().await?;

        // Check whether the token / g-recaptcha-response field is populated.
        let has_token = page
            .evaluate("!!document.querySelector('#g-recaptcha-response')?.value")
            .await?
            .into_value::<bool>()
            .unwrap_or(false);

        // Capture session cookies on success so the caller can replay
        // them and avoid re-triggering captcha on the next page load.
        let cookies = if has_token {
            crate::cookies::capture_from_page(page)
                .await
                .unwrap_or_default()
        } else {
            Vec::new()
        };

        Ok(CaptchaSolveResult {
            solution: transcript.clone(),
            confidence: if has_token { 0.9 } else { 0.5 },
            method: SolveMethod::AudioBypass,
            time_ms: t0.elapsed().as_millis() as u64,
            success: has_token,
            screenshot: None,
            cookies,
        })
    }
}

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

    #[test]
    fn audio_solver_custom_endpoint() {
        let s = AudioCaptchaSolver::new().with_stt_endpoint("http://custom:9999/stt");
        assert_eq!(s.stt_endpoint, "http://custom:9999/stt");
    }
}