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,
))
}
}
#[path = "recaptcha_audio/http.rs"]
mod http;
use http::{recaptcha_audio_client, recaptcha_audio_cookie_header};
#[path = "recaptcha_audio/text.rs"]
mod text;
pub use text::{clean_transcript, is_rate_limited, looks_like_audio_url};
#[cfg(test)]
#[path = "recaptcha_audio/tests.rs"]
mod tests;