use rand::{rngs::StdRng, Rng, SeedableRng};
use tracing::{debug, info, warn};
use super::super::*;
pub const RECAPTCHA_BFRAME_SELECTOR: &str =
r#"iframe[src*="google.com/recaptcha/api2/bframe"]"#;
pub const AUDIO_BUTTON_SELECTOR: &str = "#recaptcha-audio-button";
pub const AUDIO_ELEMENT_SELECTOR: &str = "#audio-source, audio";
pub const RESPONSE_INPUT_SELECTOR: &str = "#audio-response";
pub const VERIFY_BUTTON_SELECTOR: &str = "#recaptcha-verify-button";
pub const TOKEN_INPUT_NAME: &str = "g-recaptcha-response";
pub const MAX_AUDIO_RETRIES: usize = 2;
pub const RATE_LIMIT_PHRASES: &[&str] = &[
"Your computer or network may be sending automated queries",
"Try again later",
"we have detected unusual traffic",
];
pub struct RecaptchaAudioSolver {
client: reqwest::Client,
pub stt_endpoint: String,
pub stt_pipeline: Option<crate::stt::SttPipeline>,
pub config: SolveConfig,
}
impl Default for RecaptchaAudioSolver {
fn default() -> Self {
Self::new()
}
}
impl RecaptchaAudioSolver {
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(),
stt_pipeline: None,
config,
}
}
pub fn with_stt_endpoint(mut self, url: impl Into<String>) -> Self {
self.stt_endpoint = url.into();
self
}
pub fn with_stt_pipeline(mut self, pipeline: crate::stt::SttPipeline) -> Self {
self.stt_pipeline = Some(pipeline);
self
}
pub fn with_config(mut self, config: SolveConfig) -> Self {
self.config = config;
self
}
async fn poll_for_control(&self, page: &Page, selector: &str) -> Option<(f64, f64)> {
for _ in 0..self.config.checkbox_max_attempts {
if let Ok(Some(c)) =
crate::frame::find_element_centre_in_frames(page, selector).await
{
return Some(c);
}
tokio::time::sleep(Duration::from_millis(self.config.checkbox_poll_interval_ms))
.await;
}
None
}
async fn rate_limited(&self, page: &Page) -> bool {
let js = r#"(() => document.body ? document.body.innerText || '' : '')()"#;
let texts: Vec<String> =
crate::frame::evaluate_in_all_frames(page, js).await.unwrap_or_default();
texts.iter().any(|t| is_rate_limited(t))
}
async fn poll_for_audio_src(&self, page: &Page) -> Option<String> {
let js = format!(
r#"(() => {{
const el = document.querySelector({sel});
if (!el) return null;
const s = el.getAttribute('src') || '';
return s.length > 0 ? s : null;
}})()"#,
sel = serde_json::to_string(AUDIO_ELEMENT_SELECTOR).unwrap(),
);
for _ in 0..self.config.token_max_attempts {
tokio::time::sleep(Duration::from_millis(self.config.token_poll_interval_ms)).await;
let urls: Vec<String> =
crate::frame::evaluate_in_all_frames(page, &js).await.unwrap_or_default();
if let Some(u) = urls.into_iter().find(|s| looks_like_audio_url(s)) {
return Some(u);
}
}
None
}
}
#[async_trait]
impl CaptchaSolver for RecaptchaAudioSolver {
fn name(&self) -> &'static str {
"RecaptchaAudioSolver"
}
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,
_info: &crate::captcha_detect::CaptchaInfo,
) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
let mut rng = StdRng::from_entropy();
if crate::frame::verify_token_in_frames(page, TOKEN_INPUT_NAME)
.await
.unwrap_or(false)
{
info!("reCAPTCHA token already populated, no audio path needed");
return Ok(CaptchaSolveResult {
solution: "recaptcha:passive".into(),
confidence: 0.95,
method: SolveMethod::AutoPass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies: crate::cookies::capture_from_page(page).await.unwrap_or_default(),
verified_outcome: None,
});
}
let (ax, ay) = match self.poll_for_control(page, AUDIO_BUTTON_SELECTOR).await {
Some(c) => c,
None => {
debug!("audio button not found — bframe not open / wrong vendor");
return Ok(CaptchaSolveResult::failure(
SolveMethod::AudioBypass,
t0.elapsed().as_millis() as u64,
));
}
};
let approach_x = ax + rng.gen_range(-120.0..120.0);
let approach_y = ay + rng.gen_range(-60.0..60.0);
crate::behavior::mouse_move_human(page, approach_x, approach_y, ax, ay).await?;
crate::behavior::click_realistic(page, ax, ay).await?;
tokio::time::sleep(Duration::from_millis(self.config.audio_button_delay_ms)).await;
for attempt in 0..MAX_AUDIO_RETRIES {
if self.rate_limited(page).await {
warn!(attempt, "reCAPTCHA audio path is rate-limited — yielding");
return Ok(CaptchaSolveResult {
solution: "recaptcha:rate_limited".into(),
confidence: 0.0,
method: SolveMethod::AudioBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: false,
screenshot: super::super::screenshot_b64(page).await.ok(),
cookies: Vec::new(),
verified_outcome: None,
});
}
let audio_src = match self.poll_for_audio_src(page).await {
Some(u) => u,
None => {
debug!(attempt, "no audio src after click");
return Ok(CaptchaSolveResult::failure(
SolveMethod::AudioBypass,
t0.elapsed().as_millis() as u64,
));
}
};
let bytes = match self.client.get(&audio_src).send().await {
Ok(r) if r.status().is_success() => r.bytes().await.ok(),
Ok(r) => {
warn!(status = r.status().as_u16(), "audio download non-2xx");
None
}
Err(e) => {
warn!(error = %e, "audio download failed");
None
}
};
let Some(bytes) = bytes else {
return Ok(CaptchaSolveResult::failure(
SolveMethod::AudioBypass,
t0.elapsed().as_millis() as u64,
));
};
let transcript_raw = if let Some(pipeline) = &self.stt_pipeline {
match pipeline.transcribe(bytes.to_vec()).await {
Ok(t) => t,
Err(e) => {
warn!(attempt, error = %e, "STT pipeline exhausted");
String::new()
}
}
} else {
let stt = self
.client
.post(&self.stt_endpoint)
.header("Content-Type", "audio/mpeg")
.body(bytes)
.send()
.await;
match stt {
Ok(r) if r.status().is_success() => r.text().await.unwrap_or_default(),
_ => String::new(),
}
};
let transcript = clean_transcript(&transcript_raw);
if transcript.is_empty() {
warn!(attempt, "STT returned empty transcript");
return Ok(CaptchaSolveResult::failure(
SolveMethod::AudioBypass,
t0.elapsed().as_millis() as u64,
));
}
debug!(attempt, len = transcript.len(), "got STT transcript");
let (rx, ry) = match self.poll_for_control(page, RESPONSE_INPUT_SELECTOR).await {
Some(c) => c,
None => {
return Ok(CaptchaSolveResult::failure(
SolveMethod::AudioBypass,
t0.elapsed().as_millis() as u64,
));
}
};
crate::behavior::mouse_move_human(page, ax, ay, rx, ry).await?;
crate::behavior::click_realistic(page, rx, ry).await?;
crate::behavior::type_human(page, &transcript).await?;
tokio::time::sleep(Duration::from_millis(self.config.audio_submit_delay_ms)).await;
let (vx, vy) = match self.poll_for_control(page, VERIFY_BUTTON_SELECTOR).await {
Some(c) => c,
None => {
return Ok(CaptchaSolveResult::failure(
SolveMethod::AudioBypass,
t0.elapsed().as_millis() as u64,
));
}
};
crate::behavior::mouse_move_human(page, rx, ry, vx, vy).await?;
crate::behavior::click_realistic(page, vx, vy).await?;
for _ in 0..self.config.token_max_attempts {
tokio::time::sleep(Duration::from_millis(self.config.token_poll_interval_ms))
.await;
if crate::frame::verify_token_in_frames(page, TOKEN_INPUT_NAME)
.await
.unwrap_or(false)
{
info!(attempt, "g-recaptcha-response populated");
return Ok(CaptchaSolveResult {
solution: transcript,
confidence: 0.9,
method: SolveMethod::AudioBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies: crate::cookies::capture_from_page(page)
.await
.unwrap_or_default(),
verified_outcome: None,
});
}
}
debug!(attempt, "no token after submit, retrying");
}
Ok(CaptchaSolveResult::failure(
SolveMethod::AudioBypass,
t0.elapsed().as_millis() as u64,
))
}
}
pub fn clean_transcript(raw: &str) -> String {
let lowered = raw.to_lowercase();
let mut buf = String::with_capacity(lowered.len());
let mut last_space = true;
for ch in lowered.chars() {
if ch.is_alphanumeric() {
buf.push(ch);
last_space = false;
} else if ch.is_whitespace() || matches!(ch, ',' | '.' | '!' | '?' | ';' | ':') {
if !last_space {
buf.push(' ');
last_space = true;
}
} else {
}
}
buf.trim().to_string()
}
pub fn is_rate_limited(text: &str) -> bool {
let lower = text.to_lowercase();
RATE_LIMIT_PHRASES.iter().any(|p| lower.contains(&p.to_lowercase()))
}
pub fn looks_like_audio_url(s: &str) -> bool {
if !(s.starts_with("https://") || s.starts_with("http://")) {
return false;
}
s.contains("google.com/recaptcha") || s.to_lowercase().ends_with(".mp3")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_transcript_strips_punctuation_and_lowercases() {
assert_eq!(clean_transcript("Five Seven, Nine!"), "five seven nine");
}
#[test]
fn clean_transcript_collapses_whitespace() {
assert_eq!(clean_transcript("a b\t\tc\nd"), "a b c d");
}
#[test]
fn clean_transcript_keeps_numbers() {
assert_eq!(clean_transcript("5 7 9 2 6"), "5 7 9 2 6");
}
#[test]
fn clean_transcript_drops_unicode_punctuation() {
assert_eq!(clean_transcript("\u{201C}hello\u{201D}"), "hello");
}
#[test]
fn clean_transcript_empty_input_is_empty() {
assert_eq!(clean_transcript(""), "");
assert_eq!(clean_transcript(" "), "");
assert_eq!(clean_transcript(",,,..."), "");
}
#[test]
fn rate_limit_matches_known_phrases() {
for p in RATE_LIMIT_PHRASES {
assert!(is_rate_limited(p), "should match: {p}");
assert!(
is_rate_limited(&p.to_uppercase()),
"case-insensitive match should hold for: {p}",
);
}
}
#[test]
fn rate_limit_substring_match_works() {
assert!(is_rate_limited(
"Sorry — please try again later. We have detected unusual traffic from your network."
));
}
#[test]
fn rate_limit_does_not_match_normal_prompts() {
assert!(!is_rate_limited("Press PLAY to listen and type what you hear"));
assert!(!is_rate_limited(""));
}
#[test]
fn audio_url_accepts_recaptcha_host() {
assert!(looks_like_audio_url(
"https://www.google.com/recaptcha/api2/payload?p=ASDF"
));
}
#[test]
fn audio_url_accepts_mp3_suffix_on_other_hosts() {
assert!(looks_like_audio_url("https://cdn.example.com/clip.mp3"));
assert!(looks_like_audio_url("http://x.test/y.mp3"));
}
#[test]
fn audio_url_rejects_data_blob_relative_empty() {
assert!(!looks_like_audio_url(""));
assert!(!looks_like_audio_url("data:audio/mpeg;base64,abc"));
assert!(!looks_like_audio_url("blob:https://x.test/abc"));
assert!(!looks_like_audio_url("//google.com/recaptcha/x.mp3"));
assert!(!looks_like_audio_url("/relative/clip.mp3"));
}
#[test]
fn solver_constructs_with_default_endpoint() {
let s = RecaptchaAudioSolver::new();
assert_eq!(s.stt_endpoint, "http://localhost:9000/asr");
assert_eq!(s.name(), "RecaptchaAudioSolver");
assert_eq!(s.method(), SolveMethod::AudioBypass);
}
#[test]
fn solver_with_custom_endpoint() {
let s = RecaptchaAudioSolver::new().with_stt_endpoint("http://stt.test:5000/v1");
assert_eq!(s.stt_endpoint, "http://stt.test:5000/v1");
}
#[test]
fn solver_supports_recaptcha_v2_and_audio_kinds() {
use crate::captcha_detect::DetectedCaptcha;
let s = RecaptchaAudioSolver::new();
assert!(s.supports(&DetectedCaptcha::RecaptchaV2));
assert!(s.supports(&DetectedCaptcha::AudioCaptcha));
assert!(!s.supports(&DetectedCaptcha::HCaptcha));
assert!(!s.supports(&DetectedCaptcha::Turnstile));
assert!(!s.supports(&DetectedCaptcha::None));
}
#[test]
fn selectors_are_stable_strings() {
assert_eq!(AUDIO_BUTTON_SELECTOR, "#recaptcha-audio-button");
assert_eq!(RESPONSE_INPUT_SELECTOR, "#audio-response");
assert_eq!(VERIFY_BUTTON_SELECTOR, "#recaptcha-verify-button");
assert_eq!(TOKEN_INPUT_NAME, "g-recaptcha-response");
assert!(RECAPTCHA_BFRAME_SELECTOR.contains("api2/bframe"));
}
#[test]
fn max_audio_retries_is_bounded_small() {
assert_eq!(MAX_AUDIO_RETRIES, 2);
}
}