captchaforge 0.2.30

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Generic audio CAPTCHA detector.
use anyhow::Result;
use async_trait::async_trait;
use chromiumoxide::Page;

use super::{parse_detection_result, CaptchaInfo, Detector};

/// Detector for generic audio CAPTCHAs.
pub struct AudioCaptchaDetector;

impl AudioCaptchaDetector {
    /// JS payload evaluated against the live page.
    ///
    /// Two-pass detection:
    ///   1. Vendor-specific selectors (`audio[src*="captcha"]`,
    ///      `.audio-captcha`, `#audio-captcha`, `#audio-player`).
    ///   2. Generic heuristic — any `<audio>` element accompanied by a
    ///      text input asking for the numbers/digits being heard. Catches
    ///      bespoke audio CAPTCHAs that don't follow vendor naming.
    pub const JS: &'static str = r#"(function() {
    const vendor = document.querySelector(
        'audio[src*="captcha"], .audio-captcha, #audio-captcha, #audio-player'
    );
    if (vendor) {
        return { kind: 'audio', site_key: null, container: '.audio-captcha, #audio-captcha, #audio-player' };
    }
    // Generic: any audio + a nearby input with a matching prompt.
    const audio = document.querySelector('audio');
    if (audio) {
        const txt = (document.body.innerText || '').toLowerCase();
        const promptHit = txt.includes('listen and type')
            || txt.includes('type the numbers')
            || txt.includes('type the digits')
            || txt.includes('audio captcha')
            || txt.includes('audio challenge');
        const input = document.querySelector('input[type="text"], input[name="captcha"], input[name="answer"]');
        if (promptHit && input) {
            return { kind: 'audio', site_key: null, container: 'audio' };
        }
    }
    return { kind: 'none', site_key: null, container: null };
})()"#;
}

#[async_trait]
impl Detector for AudioCaptchaDetector {
    fn name(&self) -> &'static str {
        "audio_captcha"
    }
    fn priority(&self) -> i32 {
        50
    }
    async fn detect(&self, page: &Page) -> Result<Option<CaptchaInfo>> {
        let raw = page.evaluate(Self::JS).await?;
        let val = raw.into_value::<serde_json::Value>()?;
        Ok(parse_detection_result(val))
    }
}

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

    #[test]
    fn audio_captcha_detector_js_has_selectors() {
        assert!(AudioCaptchaDetector::JS.contains("audio[src*=\"captcha\"]"));
        assert!(AudioCaptchaDetector::JS.contains(".audio-captcha"));
    }

    #[test]
    fn audio_captcha_detector_js_has_generic_heuristic() {
        // Generic-fallback prompts must appear so a bespoke audio
        // CAPTCHA without vendor-class names still detects.
        let js = AudioCaptchaDetector::JS;
        assert!(js.contains("listen and type"));
        assert!(js.contains("type the numbers"));
    }

    #[test]
    fn audio_captcha_detector_priority_below_vendor_specific() {
        // Generic audio detector runs after vendor-specific detectors
        // (priority 50 vs 5-25 for vendors) so a vendor-named CAPTCHA
        // with an `<audio>` child still routes through the right
        // vendor solver instead of the generic one.
        assert_eq!(AudioCaptchaDetector.priority(), 50);
    }
}