use super::*;
use crate::backends::{SttBackend, SttKind};
pub struct AudioCaptchaSolver {
client: reqwest::Client,
pub(crate) stt_backend: Option<SttBackend>,
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();
let stt_backend = Self::probe_stt_sync();
Self {
client: reqwest::Client::builder()
.timeout(Duration::from_millis(config.client_http_timeout_ms))
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
stt_backend,
stt_endpoint: "http://localhost:9000/asr".to_string(),
config,
}
}
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
}
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
}
pub async fn transcribe(&self, audio_bytes: bytes::Bytes) -> Result<String> {
let prepared = if audio_bytes.starts_with(b"RIFF") {
match crate::audio_dsp::preprocess_for_stt(&audio_bytes) {
Ok(pcm) => bytes::Bytes::from(crate::audio_dsp::encode_wav_pcm16(&pcm)),
Err(e) => {
tracing::debug!(
error = %e,
"audio_dsp pre-process failed; sending raw audio to STT"
);
audio_bytes
}
}
} else {
audio_bytes
};
match &self.stt_backend {
Some(b) if b.kind == SttKind::WhisperCli => {
Self::transcribe_via_whisper_cli(b, prepared).await
}
Some(b) if b.kind == SttKind::OpenAIApi => {
self.transcribe_via_openai(b, prepared).await
}
_ => self.transcribe_via_http(prepared).await,
}
}
async fn transcribe_via_whisper_cli(
backend: &SttBackend,
audio_bytes: bytes::Bytes,
) -> Result<String> {
let model = backend.model.as_deref().unwrap_or("tiny");
let bin = backend.endpoint.clone();
let ext = if audio_bytes.starts_with(b"RIFF") {
"wav"
} else if audio_bytes.starts_with(b"OggS") {
"ogg"
} else {
"mp3"
};
let mut tmp = tempfile::Builder::new()
.prefix("captchaforge-stt-")
.suffix(&format!(".{ext}"))
.tempfile()?;
use std::io::Write;
tmp.write_all(&audio_bytes)?;
tmp.flush()?;
let audio_path = tmp.path().to_path_buf();
let outdir = tempfile::tempdir()?;
let outdir_path = outdir.path().to_path_buf();
let model_owned = model.to_string();
let result = tokio::task::spawn_blocking(move || -> Result<String> {
let status = std::process::Command::new(&bin)
.arg(&audio_path)
.args(["--model", &model_owned])
.args(["--output_format", "txt"])
.arg("--output_dir")
.arg(&outdir_path)
.args(["--language", "en"])
.arg("--fp16=False")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map_err(|e| anyhow::anyhow!("spawning whisper: {e}"))?;
if !status.success() {
anyhow::bail!("whisper exited {status}");
}
let stem = audio_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("audio path has no stem"))?;
let txt_path = outdir_path.join(format!("{stem}.txt"));
let body = std::fs::read_to_string(&txt_path)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", txt_path.display()))?;
Ok(body.trim().to_string())
})
.await
.map_err(|e| anyhow::anyhow!("whisper task join: {e}"))??;
Ok(result)
}
async fn transcribe_via_openai(
&self,
backend: &SttBackend,
audio_bytes: bytes::Bytes,
) -> Result<String> {
let key = std::env::var("OPENAI_API_KEY")
.map_err(|_| anyhow::anyhow!("OPENAI_API_KEY not set"))?;
let model = backend.model.as_deref().unwrap_or("whisper-1");
let part = reqwest::multipart::Part::bytes(audio_bytes.to_vec())
.file_name("audio.mp3")
.mime_str("audio/mpeg")?;
let form = reqwest::multipart::Form::new()
.text("model", model.to_string())
.text("response_format", "text")
.part("file", part);
let resp = self
.client
.post(&backend.endpoint)
.bearer_auth(key)
.multipart(form)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("openai whisper returned {}", resp.status());
}
Ok(resp.text().await?.trim().to_string())
}
async fn transcribe_via_http(&self, audio_bytes: bytes::Bytes) -> Result<String> {
let resp = self
.client
.post(&self.stt_endpoint)
.header("Content-Type", "audio/mpeg")
.body(audio_bytes)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("local STT returned {}", resp.status());
}
Ok(resp.text().await?.trim().to_string())
}
}
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(" ")
}
}
#[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();
if let Ok(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;
}
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,
));
}
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,
));
}
};
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,
));
}
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(Duration::from_millis(self.config.audio_submit_delay_ms)).await;
if let Ok(verify) = page
.find_element("#recaptcha-verify-button, .rc-button-default, button[type='submit']")
.await
{
verify.click().await.ok();
}
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,
})
}
}
#[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");
}
}