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
use super::*;

#[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 (only present on reCAPTCHA v2).
        // Generic audio CAPTCHAs surface the `<audio>` element directly,
        // so a missing button is non-fatal.
        if let Ok(audio_btn) = page
            .find_element("#recaptcha-audio-button, .rc-button-audio")
            .await
        {
            audio_btn.click().await?;
            tokio::time::sleep(jittered_audio_delay(self.config.audio_button_delay_ms)).await;
        }

        // Extract the audio source URL. Resolves relative paths against
        // the document's origin so a fixture serving `/audio.wav` works.
        let audio_src = page
            .evaluate(
                r#"(function(){const a=document.querySelector('audio[src],audio source[src]');
                    if(!a)return '';
                    const raw=a.getAttribute('src');
                    if(!raw)return '';
                    try{return new URL(raw, document.baseURI).toString();}catch(e){return raw;}})()"#,
            )
            .await?
            .into_value::<String>()?;

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

        // Download the audio via the BROWSER fetch, this preserves
        // session cookies, follows redirects the same way the page
        // would, and inherits chromium's certificate policy (so
        // self-signed dev origins work when the browser is configured
        // to ignore cert errors). reqwest with the default policy
        // would reject self-signed certs.
        let audio_b64 = page
            .evaluate(format!(
                r#"(async (url) => {{
                    try {{
                        const r = await fetch(url, {{ credentials: 'include', cache: 'no-store' }});
                        if (!r.ok) return '';
                        const buf = await r.arrayBuffer();
                        const bytes = new Uint8Array(buf);
                        let bin = '';
                        for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
                        return btoa(bin);
                    }} catch (e) {{ return ''; }}
                }})({})"#,
                serde_json::to_string(&audio_src).unwrap_or_else(|_| "\"\"".into())
            ))
            .await?
            .into_value::<String>()
            .unwrap_or_default();
        if audio_b64.is_empty() {
            return Ok(CaptchaSolveResult::failure(
                SolveMethod::AudioBypass,
                t0.elapsed().as_millis() as u64,
            ));
        }
        use base64::Engine as _;
        let audio_bytes: bytes::Bytes =
            match base64::engine::general_purpose::STANDARD.decode(audio_b64.as_bytes()) {
                Ok(v) => v.into(),
                Err(_) => {
                    return Ok(CaptchaSolveResult::failure(
                        SolveMethod::AudioBypass,
                        t0.elapsed().as_millis() as u64,
                    ));
                }
            };

        // Transcribe via the auto-detected backend (whisper-cli > OpenAI
        // API > local HTTP server). Failures bubble up as solver failure
        // so the chain falls through to the next solver instead of
        // hanging the page.
        let transcript = match self.transcribe(audio_bytes).await {
            Ok(t) if !t.is_empty() => normalize_transcript(&t),
            _ => {
                return Ok(CaptchaSolveResult::failure(
                    SolveMethod::AudioBypass,
                    t0.elapsed().as_millis() as u64,
                ));
            }
        };

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

        // Type the transcript into the answer field. Use a JS pierce so
        // we don't depend on element handles (which expire when the
        // page reflows after our typed input fires the fixture's own
        // input-listeners). Selectors cover reCAPTCHA v2 (`#audio-
        // response`, `.rc-response-input`) and generic audio captchas
        // (`input[name="captcha"]`, `input[name="answer"]`,
        // `input[type="text"]` as last resort).
        let typed = page
            .evaluate(format!(
                r#"((answer) => {{
                    const inp = document.querySelector(
                        '#audio-response, .rc-response-input, ' +
                        'input[name="captcha"], input[name="answer"], ' +
                        'input[type="text"]'
                    );
                    if (!inp) return false;
                    inp.focus();
                    /* Char-by-char so the page's input listener fires per
                       keystroke: many fixtures auto-submit when the
                       value matches mid-type. */
                    inp.value = '';
                    for (let i = 0; i < answer.length; i++) {{
                        inp.value += answer[i];
                        inp.dispatchEvent(new Event('input', {{bubbles: true}}));
                    }}
                    inp.dispatchEvent(new Event('change', {{bubbles: true}}));
                    return true;
                }})({})"#,
                serde_json::to_string(&transcript).unwrap_or_else(|_| "\"\"".into())
            ))
            .await?
            .into_value::<bool>()
            .unwrap_or(false);
        if !typed {
            return Ok(CaptchaSolveResult::failure(
                SolveMethod::AudioBypass,
                t0.elapsed().as_millis() as u64,
            ));
        }

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

        // Click the verify button if present (reCAPTCHA v2 needs it;
        // generic captchas auto-validate on input).
        if let Ok(verify) = page
            .find_element("#recaptcha-verify-button, .rc-button-default, button[type='submit']")
            .await
        {
            verify.click().await.ok();
        }

        // Success signal: either the reCAPTCHA token is populated, OR
        // the page has transitioned (title/cookie/widget removed). The
        // chain's outcome oracle does the page-transition check at a
        // higher layer, so here we just report success when we typed
        // something and the answer field accepted it; chain will
        // downgrade to failure if the oracle disagrees.
        let has_token = page
            .evaluate(
                r#"(() => {
                    const t = document.querySelector('#g-recaptcha-response, [name="g-recaptcha-response"]');
                    if (t && t.value && t.value.length > 0) return true;
                    /* Title flip is the most common generic success marker. */
                    return /solved|verified|success/i.test(document.title || '');
                })()"#,
            )
            .await?
            .into_value::<bool>()
            .unwrap_or(false);

        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,
            verified_outcome: None,
        })
    }
}