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

use crate::backends::{SttBackend, SttKind};

/// Spread for jittering the audio solver's configured action delays. A real
/// visitor's pause after a UI action varies run-to-run; emitting the exact
/// configured `*_delay_ms` every time is a fixed-cadence timing tell (Law 7)
/// that a behavioral scorer can key on. ±25 % around the operator's nominal.
const AUDIO_DELAY_JITTER_SPREAD: f64 = 0.25;

/// Jitter a configured audio action delay around its nominal, using guise's
/// canonical human-timing primitive so there is ONE jitter source fleet-wide
/// (no local re-implementation: NO DUPLICATION). Fresh entropy per call: audio
/// solves are rare, never a hot path, so the per-call seed is immaterial.
fn jittered_audio_delay(nominal_ms: u64) -> std::time::Duration {
    guise::human::timing::jittered_step(
        std::time::Duration::from_millis(nominal_ms),
        AUDIO_DELAY_JITTER_SPREAD,
        &mut rand::rngs::StdRng::from_entropy(),
    )
}

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

/// Solves reCAPTCHA v2 (and generic) audio challenges.
///
/// Backend selection (auto-detected on construction):
///   - **Local `whisper` CLI** on PATH, preferred. Audio bytes are written
///     to a tempfile and `whisper <tmp> --model tiny --output_format txt`
///     is spawned; the resulting `.txt` is parsed.
///   - **OpenAI Whisper API** if `OPENAI_API_KEY` is set.
///   - **Local HTTP server** at `http://localhost:9000/asr` (the legacy
///     [openai-whisper-asr-webservice] convention).
///
/// The first available backend wins. Override with [`Self::with_stt_backend`]
/// or [`Self::with_stt_endpoint`] for explicit configuration.
///
/// [openai-whisper-asr-webservice]: https://github.com/ahmetoner/whisper-asr-webservice
pub struct AudioCaptchaSolver {
    client: reqwest::Client,
    /// Auto-detected STT backend. `None` means no local backend was found at
    /// construction; the solver falls back to [`Self::stt_endpoint`].
    pub(crate) stt_backend: Option<SttBackend>,
    /// HTTP STT endpoint, used when [`Self::stt_backend`] is `None` or its
    /// kind is `LocalServer`. Default `http://localhost:9000/asr`.
    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();
        // Best-effort sync probe for whisper-cli + OPENAI_API_KEY. Fast
        // (PATH walk + env lookup), no network.
        let stt_backend = Self::probe_stt_sync();
        Self {
            client: crate::http_client::timed_client_or_panic(Duration::from_millis(
                config.client_http_timeout_ms,
            )),
            stt_backend,
            stt_endpoint: "http://localhost:9000/asr".to_string(),
            config,
        }
    }

    /// Synchronous subset of [`crate::backends::probe`] focused on STT 
    /// safe to call from `new()` (no async, no network). Picks whisper-cli
    /// over OpenAI API when both are present (local is faster + free).
    fn probe_stt_sync() -> Option<SttBackend> {
        if let Some(path) =
            crate::backends::which("whisper").or_else(|| crate::backends::which("whisper-cli"))
        {
            return Some(SttBackend {
                kind: SttKind::WhisperCli,
                endpoint: path,
                model: Some(crate::backends::DEFAULT_WHISPER_MODEL.to_string()),
            });
        }
        if std::env::var("OPENAI_API_KEY").is_ok() {
            return Some(SttBackend {
                kind: SttKind::OpenAIApi,
                endpoint: "https://api.openai.com/v1/audio/transcriptions".to_string(),
                model: Some("whisper-1".to_string()),
            });
        }
        None
    }

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

    /// Explicitly set the STT backend. Use this when probing should be
    /// skipped (tests, custom pipelines, vendored whisper builds).
    pub fn with_stt_backend(mut self, backend: SttBackend) -> Self {
        self.stt_backend = Some(backend);
        self
    }

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

/// Normalize a whisper transcript to a CAPTCHA-friendly answer.
///
/// Captcha answer fields commonly accept either spelled digits ("four two
/// nine") or compact digits ("429"). Whisper most often returns the spelled
/// form for slowly-spoken digit audio. This helper:
///   - lowercases + trims
///   - strips punctuation
///   - converts each spelled-digit token to its digit form
///   - collapses whitespace
///
/// Pure helper (pub so callers can normalize for diffing in tests).
///
/// # Examples
///
/// ```
/// use captchaforge::solver::normalize_transcript;
/// assert_eq!(normalize_transcript("Four two nine."), "429");
/// assert_eq!(normalize_transcript(" SEVEN one zero "), "710");
/// // Mixed numerals + words pass through.
/// assert_eq!(normalize_transcript("4 two 9"), "429");
/// // Non-digit transcripts keep their words (lowercased, single-spaced).
/// assert_eq!(normalize_transcript("Hello World"), "hello world");
/// ```
pub fn normalize_transcript(s: &str) -> String {
    let lowered: String = s
        .to_lowercase()
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { ' ' })
        .collect();
    let tokens: Vec<&str> = lowered.split_whitespace().collect();
    let mut out = Vec::with_capacity(tokens.len());
    let mut all_digits = true;
    for tok in &tokens {
        let digit = match *tok {
            "zero" | "oh" => Some("0"),
            "one" => Some("1"),
            "two" | "to" | "too" => Some("2"),
            "three" => Some("3"),
            "four" | "for" => Some("4"),
            "five" => Some("5"),
            "six" => Some("6"),
            "seven" => Some("7"),
            "eight" | "ate" => Some("8"),
            "nine" => Some("9"),
            t if t.chars().all(|c| c.is_ascii_digit()) => Some(*tok),
            _ => None,
        };
        if let Some(d) = digit {
            out.push(d.to_string());
        } else {
            all_digits = false;
            out.push((*tok).to_string());
        }
    }
    if all_digits {
        out.join("")
    } else {
        out.join(" ")
    }
}

mod transcribe;

mod solve;

#[cfg(test)]
mod tests;