1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
use super::*;
#[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();
// Click the audio challenge button (only present on reCAPTCHA v2).
// Generic audio CAPTCHAs surface the `<audio>` element directly,
// so a missing button is non-fatal.
if let Ok(audio_btn) = page
.find_element("#recaptcha-audio-button, .rc-button-audio")
.await
{
audio_btn.click().await?;
tokio::time::sleep(jittered_audio_delay(self.config.audio_button_delay_ms)).await;
}
// Extract the audio source URL. Resolves relative paths against
// the document's origin so a fixture serving `/audio.wav` works.
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,
));
}
// Download the audio via the BROWSER fetch, this preserves
// session cookies, follows redirects the same way the page
// would, and inherits chromium's certificate policy (so
// self-signed dev origins work when the browser is configured
// to ignore cert errors). reqwest with the default policy
// would reject self-signed certs.
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,
));
}
};
// Transcribe via the auto-detected backend (whisper-cli > OpenAI
// API > local HTTP server). Failures bubble up as solver failure
// so the chain falls through to the next solver instead of
// hanging the page.
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,
));
}
// Type the transcript into the answer field. Use a JS pierce so
// we don't depend on element handles (which expire when the
// page reflows after our typed input fires the fixture's own
// input-listeners). Selectors cover reCAPTCHA v2 (`#audio-
// response`, `.rc-response-input`) and generic audio captchas
// (`input[name="captcha"]`, `input[name="answer"]`,
// `input[type="text"]` as last resort).
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(jittered_audio_delay(self.config.audio_submit_delay_ms)).await;
// Click the verify button if present (reCAPTCHA v2 needs it;
// generic captchas auto-validate on input).
if let Ok(verify) = page
.find_element("#recaptcha-verify-button, .rc-button-default, button[type='submit']")
.await
{
verify.click().await.ok();
}
// Success signal: either the reCAPTCHA token is populated, OR
// the page has transitioned (title/cookie/widget removed). The
// chain's outcome oracle does the page-transition check at a
// higher layer, so here we just report success when we typed
// something and the answer field accepted it; chain will
// downgrade to failure if the oracle disagrees.
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,
})
}
}