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: recaptcha_audio_client(Duration::from_millis(config.client_http_timeout_ms)),
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 audio_url = match reqwest::Url::parse(&audio_src) {
Ok(url) => url,
Err(e) => {
warn!(attempt, error = %e, "audio src is not a valid URL");
return Ok(CaptchaSolveResult::failure(
SolveMethod::AudioBypass,
t0.elapsed().as_millis() as u64,
));
}
};
let mut audio_request = self.client.get(audio_url.clone());
match crate::cookies::capture_from_page(page).await {
Ok(cookies) => {
if let Some(cookie_header) = recaptcha_audio_cookie_header(&cookies, &audio_url)
{
audio_request =
audio_request.header(reqwest::header::COOKIE, cookie_header);
}
}
Err(e) => warn!(
attempt,
error = %e,
"could not capture page cookies for reCAPTCHA audio download"
),
}
let bytes = match audio_request.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,
))
}
}
fn recaptcha_audio_client(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(timeout)
.no_gzip()
.no_brotli()
.default_headers(recaptcha_audio_headers())
.build()
.expect("reCAPTCHA audio reqwest client must build with rustls-tls enabled")
}
fn recaptcha_audio_headers() -> reqwest::header::HeaderMap {
guise::http::default_browser_request_header_map_without_compression(
guise::http::BrowserRequestKind::AudioSubresource,
)
.expect("canonical stealth audio-subresource headers must be valid")
}
fn recaptcha_audio_cookie_header(
cookies: &[crate::cookies::CapturedCookie],
audio_url: &reqwest::Url,
) -> Option<reqwest::header::HeaderValue> {
let pairs = cookies
.iter()
.filter(|cookie| cookie_matches_url(cookie, audio_url))
.filter(|cookie| cookie_pair_is_header_safe(&cookie.name, &cookie.value))
.map(|cookie| format!("{}={}", cookie.name, cookie.value))
.collect::<Vec<_>>();
if pairs.is_empty() {
return None;
}
reqwest::header::HeaderValue::from_str(&pairs.join("; ")).ok()
}
fn cookie_matches_url(cookie: &crate::cookies::CapturedCookie, url: &reqwest::Url) -> bool {
if cookie.is_expired_now() {
return false;
}
if cookie.secure && url.scheme() != "https" {
return false;
}
let Some(host) = url.host_str() else {
return false;
};
domain_matches(&cookie.domain, host) && path_matches(&cookie.path, url.path())
}
fn domain_matches(cookie_domain: &str, host: &str) -> bool {
let cookie_domain = cookie_domain.trim().to_ascii_lowercase();
if cookie_domain.is_empty() {
return false;
}
let host = host.to_ascii_lowercase();
let Some(domain) = cookie_domain.strip_prefix('.') else {
return host == cookie_domain;
};
host == domain || host.ends_with(&format!(".{domain}"))
}
fn path_matches(cookie_path: &str, request_path: &str) -> bool {
let cookie_path = if cookie_path.is_empty() {
"/"
} else {
cookie_path
};
if cookie_path == "/" || request_path == cookie_path {
return true;
}
request_path.starts_with(cookie_path)
&& (cookie_path.ends_with('/')
|| request_path
.as_bytes()
.get(cookie_path.len())
.is_some_and(|byte| *byte == b'/'))
}
fn cookie_pair_is_header_safe(name: &str, value: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'=' | b';' | b',' | b'"'))
&& value
.bytes()
.all(|byte| byte.is_ascii_graphic() && !matches!(byte, b';'))
}
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::*;
use crate::cookies::CapturedCookie;
use guise::fingerprint::default_profile_facts;
#[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);
}
fn captured_header<'a>(raw: &'a str, name: &str) -> Option<&'a str> {
raw.lines().find_map(|line| {
let (key, value) = line.split_once(':')?;
key.eq_ignore_ascii_case(name).then(|| value.trim())
})
}
#[tokio::test]
async fn recaptcha_audio_client_uses_shared_audio_subresource_headers() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/clip.mp3", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0_u8; 1024];
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
request.extend_from_slice(&buf[..n]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
socket
.write_all(
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await
.unwrap();
String::from_utf8(request).unwrap()
});
let response = RecaptchaAudioSolver::new()
.client
.get(&url)
.send()
.await
.expect("reCAPTCHA audio request");
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
let raw_request = server.await.unwrap();
let facts = default_profile_facts();
assert_eq!(
captured_header(&raw_request, "User-Agent"),
Some(facts.user_agent)
);
assert_eq!(captured_header(&raw_request, "Accept"), Some("*/*"));
assert_eq!(
captured_header(&raw_request, "Accept-Language"),
Some(facts.accept_language)
);
assert_eq!(
captured_header(&raw_request, "Sec-Fetch-Dest"),
Some("audio")
);
assert_eq!(
captured_header(&raw_request, "Sec-Fetch-Mode"),
Some("no-cors")
);
assert_eq!(
captured_header(&raw_request, "Sec-Fetch-Site"),
Some("cross-site")
);
assert!(captured_header(&raw_request, "Accept-Encoding").is_none());
assert!(captured_header(&raw_request, "Upgrade-Insecure-Requests").is_none());
assert!(captured_header(&raw_request, "Sec-Fetch-User").is_none());
}
fn ck(name: &str, value: &str, domain: &str, path: &str, secure: bool) -> CapturedCookie {
CapturedCookie {
name: name.into(),
value: value.into(),
domain: domain.into(),
path: path.into(),
expires: None,
secure,
http_only: false,
same_site: None,
}
}
#[test]
fn recaptcha_audio_cookie_header_replays_only_url_scoped_safe_cookies() {
let url =
reqwest::Url::parse("https://www.google.com/recaptcha/api2/payload?p=clip").unwrap();
let mut expired = ck("expired", "old", ".google.com", "/", false);
expired.expires = Some(1);
let cookies = vec![
ck("NID", "abc", ".google.com", "/", false),
ck(
"__Secure-3PSID",
"secure",
".google.com",
"/recaptcha",
true,
),
ck(
"host_only",
"one",
"www.google.com",
"/recaptcha/api2",
false,
),
ck("wrong_host", "skip", ".example.com", "/", false),
ck("wrong_path", "skip", ".google.com", "/accounts", false),
ck("bad;name", "skip", ".google.com", "/", false),
ck("bad_value", "skip;me", ".google.com", "/", false),
expired,
];
let header = recaptcha_audio_cookie_header(&cookies, &url)
.expect("matching cookies should produce a header");
assert_eq!(
header.to_str().unwrap(),
"NID=abc; __Secure-3PSID=secure; host_only=one"
);
}
#[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);
}
}