use anyhow::{anyhow, Result};
use async_trait::async_trait;
use base64::Engine as _;
use chromiumoxide::Page;
use rand::{Rng, SeedableRng};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
use crate::captcha_detect::CaptchaInfo;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CaptchaType {
RecaptchaV2,
RecaptchaV3,
#[serde(rename = "hcaptcha")]
HCaptcha,
CloudflareTurnstile,
ImageGrid,
TextCaptcha,
AudioCaptcha,
Slider,
Custom(String),
}
impl std::fmt::Display for CaptchaType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CaptchaType::RecaptchaV2 => write!(f, "recaptcha_v2"),
CaptchaType::RecaptchaV3 => write!(f, "recaptcha_v3"),
CaptchaType::HCaptcha => write!(f, "hcaptcha"),
CaptchaType::CloudflareTurnstile => write!(f, "cloudflare_turnstile"),
CaptchaType::ImageGrid => write!(f, "image_grid"),
CaptchaType::TextCaptcha => write!(f, "text_captcha"),
CaptchaType::AudioCaptcha => write!(f, "audio_captcha"),
CaptchaType::Slider => write!(f, "slider"),
CaptchaType::Custom(s) => write!(f, "custom:{}", s),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SolveMethod {
#[serde(rename = "vision_llm")]
VisionLLM,
AudioBypass,
BehavioralBypass,
ThirdPartyService,
CrowdSourced,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptchaSolveResult {
pub solution: String,
pub confidence: f32,
pub method: SolveMethod,
pub time_ms: u64,
pub success: bool,
pub screenshot: Option<String>,
}
impl CaptchaSolveResult {
fn failure(method: SolveMethod, time_ms: u64) -> Self {
Self {
solution: String::new(),
confidence: 0.0,
method,
time_ms,
success: false,
screenshot: None,
}
}
fn unsolved(time_ms: u64, screenshot: Option<String>) -> Self {
Self {
solution: String::new(),
confidence: 0.0,
method: SolveMethod::CrowdSourced,
time_ms,
success: false,
screenshot,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct SolveConfig {
pub checkbox_poll_interval_ms: u64,
pub checkbox_max_attempts: u32,
pub token_poll_interval_ms: u64,
pub token_max_attempts: u32,
pub audio_button_delay_ms: u64,
pub audio_submit_delay_ms: u64,
pub vlm_http_timeout_ms: u64,
pub client_http_timeout_ms: u64,
}
impl Default for SolveConfig {
fn default() -> Self {
Self {
checkbox_poll_interval_ms: 500,
checkbox_max_attempts: 15,
token_poll_interval_ms: 500,
token_max_attempts: 16,
audio_button_delay_ms: 2000,
audio_submit_delay_ms: 2000,
vlm_http_timeout_ms: 120_000,
client_http_timeout_ms: 180_000,
}
}
}
#[async_trait]
pub trait CaptchaSolver: Send + Sync {
async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> Result<CaptchaSolveResult>;
fn name(&self) -> &'static str;
fn method(&self) -> SolveMethod;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptchaPattern {
pub domain: String,
pub captcha_type: CaptchaType,
pub best_method: SolveMethod,
pub success_rate: f32,
pub avg_solve_time: u64,
pub sample_count: u32,
}
impl CaptchaPattern {
pub fn new(domain: impl Into<String>, captcha_type: CaptchaType, method: SolveMethod) -> Self {
Self {
domain: domain.into(),
captcha_type,
best_method: method,
success_rate: 0.0,
avg_solve_time: 0,
sample_count: 0,
}
}
pub fn record(&mut self, success: bool, solve_time_ms: u64, method: SolveMethod) {
self.sample_count += 1;
let n = self.sample_count as f32;
self.success_rate = self.success_rate * (n - 1.0) / n + (success as u8 as f32) / n;
self.avg_solve_time = (self.avg_solve_time * (self.sample_count - 1) as u64
+ solve_time_ms)
/ self.sample_count as u64;
if success {
self.best_method = method;
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PatternStore {
inner: Arc<RwLock<HashMap<String, CaptchaPattern>>>,
}
impl PatternStore {
fn key(domain: &str, ct: &CaptchaType) -> String {
format!("{}:{}", domain, ct)
}
pub fn record(
&self,
domain: &str,
captcha_type: &CaptchaType,
success: bool,
time_ms: u64,
method: SolveMethod,
) {
let key = Self::key(domain, captcha_type);
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
map.entry(key)
.and_modify(|p| p.record(success, time_ms, method.clone()))
.or_insert_with(|| {
let mut p = CaptchaPattern::new(domain, captcha_type.clone(), method.clone());
p.record(success, time_ms, method);
p
});
}
pub fn best_method(&self, domain: &str, captcha_type: &CaptchaType) -> Option<SolveMethod> {
let key = Self::key(domain, captcha_type);
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
map.get(&key).map(|p| p.best_method.clone())
}
pub fn all_patterns(&self) -> Vec<CaptchaPattern> {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.values()
.cloned()
.collect()
}
}
const DEFAULT_OLLAMA_BASE: &str = "http://localhost:11434";
const DEFAULT_OLLAMA_MODEL: &str = "qwen3-vl:30b";
#[derive(Debug, Serialize)]
struct OllamaRequest<'a> {
model: &'a str,
prompt: &'a str,
images: Vec<String>,
stream: bool,
}
#[derive(Debug, Deserialize)]
struct OllamaResponse {
response: String,
}
async fn vlm_query(
client: &reqwest::Client,
endpoint: &str,
model: &str,
image_b64: &str,
prompt: &str,
timeout_ms: u64,
) -> Result<String> {
let req = OllamaRequest {
model,
prompt,
images: vec![image_b64.to_string()],
stream: false,
};
let resp = client
.post(format!("{}/api/generate", endpoint.trim_end_matches('/')))
.json(&req)
.timeout(Duration::from_millis(timeout_ms))
.send()
.await
.map_err(|e| anyhow!("vlm request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!("vlm returned {}: {}", status, body));
}
let ollama_resp: OllamaResponse = resp
.json()
.await
.map_err(|e| anyhow!("vlm json parse: {}", e))?;
Ok(ollama_resp.response)
}
async fn screenshot_b64(page: &Page) -> Result<String> {
let data = page
.screenshot(
chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParams::builder()
.format(chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat::Jpeg)
.quality(85)
.build(),
)
.await?;
Ok(base64::engine::general_purpose::STANDARD.encode(&data))
}
fn extract_json(text: &str) -> Option<&str> {
if let Some(start) = text.find("```json") {
let rest = &text[start + 7..];
if let Some(end) = rest.find("```") {
return Some(rest[..end].trim());
}
}
if let Some(start) = text.find("```") {
let rest = &text[start + 3..];
if let Some(end) = rest.find("```") {
let inner = rest[..end].trim();
if inner.starts_with('{') {
return Some(inner);
}
}
}
if let Some(start) = text.find('{') {
if let Some(end) = text.rfind('}') {
if end > start {
return Some(&text[start..=end]);
}
}
}
None
}
pub struct VlmCaptchaSolver {
client: reqwest::Client,
endpoint: String,
model: String,
config: SolveConfig,
}
impl VlmCaptchaSolver {
pub fn new() -> Self {
let config = SolveConfig::default();
Self {
client: reqwest::Client::builder()
.timeout(Duration::from_millis(config.client_http_timeout_ms))
.build()
.expect("reqwest client"),
endpoint: DEFAULT_OLLAMA_BASE.to_string(),
model: DEFAULT_OLLAMA_MODEL.to_string(),
config,
}
}
pub fn with_endpoint(mut self, url: impl Into<String>) -> Self {
self.endpoint = url.into();
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
pub fn with_config(mut self, config: SolveConfig) -> Self {
self.config = config;
self
}
fn grid_prompt(task_description: &str) -> String {
format!(
r#"You are a CAPTCHA solver. You see a 3x3 (or 4x4) image grid CAPTCHA.
Task: {}
Instructions:
- Identify which grid cells (0-indexed, row-major) match the task description.
- Return ONLY a JSON object: {{"selected": [0, 4, 7], "confidence": 0.9}}
- "selected" is an array of zero-based cell indices.
- If you are not confident, still make your best attempt.
Return ONLY the JSON, no other text."#,
task_description
)
}
fn text_captcha_prompt() -> &'static str {
r#"You are a CAPTCHA solver. You see a text CAPTCHA with distorted characters.
Read the characters carefully, ignoring noise and distortions.
Return ONLY a JSON object: {"text": "ABCDE", "confidence": 0.85}
No other text."#
}
fn click_target_prompt(target: &str) -> String {
format!(
r#"You are a CAPTCHA solver. Click on the element described below.
Target: {}
Return ONLY a JSON object:
{{"x": 320, "y": 240, "confidence": 0.9}}
where x,y are pixel coordinates of the target in the screenshot.
No other text."#,
target
)
}
async fn click_grid_cells(
page: &Page,
cell_indices: &[usize],
grid_cols: usize,
grid_top: f64,
grid_left: f64,
cell_size: f64,
) -> Result<()> {
let mut rng = rand::rngs::StdRng::from_entropy();
for &idx in cell_indices {
let row = idx / grid_cols;
let col = idx % grid_cols;
let cx =
grid_left + col as f64 * cell_size + cell_size / 2.0 + rng.gen_range(-5.0..5.0_f64);
let cy =
grid_top + row as f64 * cell_size + cell_size / 2.0 + rng.gen_range(-5.0..5.0_f64);
crate::behavior::mouse_move_bezier(
page,
cx - rng.gen_range(50.0..150.0_f64),
cy - rng.gen_range(30.0..100.0_f64),
cx,
cy,
)
.await?;
crate::behavior::click_realistic(page, cx, cy).await?;
tokio::time::sleep(Duration::from_millis(rng.gen_range(300..800))).await;
}
Ok(())
}
async fn solve_image_grid(
&self,
page: &Page,
_captcha_info: &CaptchaInfo,
) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
let task_js = r#"(function() {
const el = document.querySelector(
'.rc-imageselect-desc-no-canonical, .rc-imageselect-desc, ' +
'[class*="challenge-prompt"], [class*="task-description"]'
);
return el ? el.innerText : 'Select all matching images';
})()"#;
let task_text = page
.evaluate(task_js)
.await?
.into_value::<String>()
.unwrap_or_else(|_| "Select all matching images".to_string());
let image_b64 = screenshot_b64(page).await?;
let prompt = Self::grid_prompt(&task_text);
let raw = vlm_query(
&self.client,
&self.endpoint,
&self.model,
&image_b64,
&prompt,
self.config.vlm_http_timeout_ms,
)
.await?;
debug!(task = %task_text, vlm_response = %raw, "image grid solve");
let json_str = extract_json(&raw).unwrap_or(&raw);
let val: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| anyhow!("failed to parse grid response: {}", e))?;
let confidence = val["confidence"].as_f64().unwrap_or(0.5) as f32;
let selected: Vec<usize> = val["selected"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| v.as_u64().map(|n| n as usize))
.collect();
if selected.is_empty() {
return Ok(CaptchaSolveResult::failure(
SolveMethod::VisionLLM,
t0.elapsed().as_millis() as u64,
));
}
let grid_js = r#"(function() {
const grid = document.querySelector('.rc-imageselect-table, table[role="grid"]');
if (!grid) return null;
const r = grid.getBoundingClientRect();
const cols = grid.querySelectorAll('tr')[0]?.querySelectorAll('td').length || 3;
return { top: r.top, left: r.left, width: r.width, height: r.height, cols };
})()"#;
let grid_val = page
.evaluate(grid_js)
.await?
.into_value::<serde_json::Value>()
.unwrap_or(serde_json::Value::Null);
let grid_top = grid_val["top"].as_f64().unwrap_or(200.0);
let grid_left = grid_val["left"].as_f64().unwrap_or(20.0);
let grid_width = grid_val["width"].as_f64().unwrap_or(300.0);
let grid_cols = grid_val["cols"].as_u64().unwrap_or(3) as usize;
let cell_size = grid_width / grid_cols as f64;
Self::click_grid_cells(page, &selected, grid_cols, grid_top, grid_left, cell_size).await?;
let verify_js = r#"
const btn = document.querySelector('#recaptcha-verify-button, [id*="verify"], .verify-button');
if (btn) btn.click();
"#;
page.evaluate(verify_js).await.ok();
tokio::time::sleep(Duration::from_millis(800)).await;
Ok(CaptchaSolveResult {
solution: format!(
"grid:{}",
serde_json::to_string(&selected).unwrap_or_default()
),
confidence,
method: SolveMethod::VisionLLM,
time_ms: t0.elapsed().as_millis() as u64,
success: confidence > 0.6,
screenshot: None,
})
}
async fn solve_text_captcha(
&self,
page: &Page,
_captcha_info: &CaptchaInfo,
) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
let image_b64 = screenshot_b64(page).await?;
let raw = vlm_query(
&self.client,
&self.endpoint,
&self.model,
&image_b64,
Self::text_captcha_prompt(),
self.config.vlm_http_timeout_ms,
)
.await?;
debug!(vlm_response = %raw, "text captcha solve");
let json_str = extract_json(&raw).unwrap_or(&raw);
let val: serde_json::Value =
serde_json::from_str(json_str).unwrap_or(serde_json::Value::Null);
let text = val["text"].as_str().unwrap_or("").trim().to_string();
let confidence = val["confidence"].as_f64().unwrap_or(0.5) as f32;
if text.is_empty() {
return Ok(CaptchaSolveResult::failure(
SolveMethod::VisionLLM,
t0.elapsed().as_millis() as u64,
));
}
let focus_js = r#"
const inp = document.querySelector(
'input[name="captcha"], input[id*="captcha"], input[placeholder*="captcha"],
input[autocomplete="off"][type="text"]'
);
if (inp) { inp.focus(); inp.value = ''; }
inp;
"#;
page.evaluate(focus_js).await.ok();
crate::behavior::type_realistic(page, &text).await?;
Ok(CaptchaSolveResult {
solution: text,
confidence,
method: SolveMethod::VisionLLM,
time_ms: t0.elapsed().as_millis() as u64,
success: confidence > 0.5,
screenshot: None,
})
}
}
impl Default for VlmCaptchaSolver {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl CaptchaSolver for VlmCaptchaSolver {
fn name(&self) -> &'static str {
"VlmCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::VisionLLM
}
async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
use crate::captcha_detect::DetectedCaptcha;
match &captcha_info.kind {
DetectedCaptcha::RecaptchaV2 | DetectedCaptcha::HCaptcha => {
let grid_active_js = r#"
!!document.querySelector('.rc-imageselect-table, table[role="grid"]')
"#;
let grid_active = page
.evaluate(grid_active_js)
.await?
.into_value::<bool>()
.unwrap_or(false);
if grid_active {
self.solve_image_grid(page, captcha_info).await
} else {
self.solve_text_captcha(page, captcha_info).await
}
}
DetectedCaptcha::ImageCaptcha => self.solve_image_grid(page, captcha_info).await,
_ => {
let t0 = Instant::now();
let image_b64 = screenshot_b64(page).await?;
let prompt = Self::click_target_prompt("captcha checkbox or verification element");
let raw = vlm_query(
&self.client,
&self.endpoint,
&self.model,
&image_b64,
&prompt,
self.config.vlm_http_timeout_ms,
)
.await?;
let json_str = extract_json(&raw).unwrap_or(&raw);
let val: serde_json::Value =
serde_json::from_str(json_str).unwrap_or(serde_json::Value::Null);
let x = val["x"].as_f64().unwrap_or(0.0);
let y = val["y"].as_f64().unwrap_or(0.0);
let confidence = val["confidence"].as_f64().unwrap_or(0.0) as f32;
if x < 1.0 && y < 1.0 {
return Ok(CaptchaSolveResult::failure(
SolveMethod::VisionLLM,
t0.elapsed().as_millis() as u64,
));
}
let (pre_x, pre_y) = {
use rand::Rng;
let mut rng = rand::rngs::StdRng::from_entropy();
(
rng.gen_range(50.0..150.0_f64),
rng.gen_range(30.0..80.0_f64),
)
};
crate::behavior::mouse_move_bezier(page, x - pre_x, y - pre_y, x, y).await?;
crate::behavior::click_realistic(page, x, y).await?;
Ok(CaptchaSolveResult {
solution: format!("click:{},{}", x as i64, y as i64),
confidence,
method: SolveMethod::VisionLLM,
time_ms: t0.elapsed().as_millis() as u64,
success: confidence > 0.5,
screenshot: None,
})
}
}
}
}
pub struct AudioCaptchaSolver {
client: reqwest::Client,
stt_endpoint: String,
config: SolveConfig,
}
impl AudioCaptchaSolver {
pub fn new() -> Self {
let config = SolveConfig::default();
Self {
client: reqwest::Client::builder()
.timeout(Duration::from_millis(config.client_http_timeout_ms))
.build()
.expect("reqwest client"),
stt_endpoint: "http://localhost:9000/asr".to_string(),
config,
}
}
pub fn with_stt_endpoint(mut self, url: impl Into<String>) -> Self {
self.stt_endpoint = url.into();
self
}
pub fn with_config(mut self, config: SolveConfig) -> Self {
self.config = config;
self
}
async fn click_audio_button(&self, page: &Page) -> Result<()> {
let js = r#"
const btn = document.querySelector(
'#recaptcha-audio-button, button[id*="audio"], .rc-button-audio'
);
if (btn) { btn.click(); return true; }
// Try inside the reCAPTCHA iframe.
for (const f of document.querySelectorAll('iframe')) {
try {
const inner = f.contentDocument || f.contentWindow?.document;
if (!inner) continue;
const b = inner.querySelector('#recaptcha-audio-button, button[id*="audio"]');
if (b) { b.click(); return true; }
} catch (_) {}
}
return false;
"#;
let clicked = page
.evaluate(js)
.await?
.into_value::<bool>()
.unwrap_or(false);
if !clicked {
return Err(anyhow!("audio challenge button not found"));
}
tokio::time::sleep(Duration::from_millis(self.config.audio_button_delay_ms)).await;
Ok(())
}
async fn get_audio_url(&self, page: &Page) -> Result<String> {
let js = r#"(function() {
// Check main document
let el = document.querySelector('.rc-audiochallenge-tdownload-link, a[href*=".mp3"]');
if (el) return el.href;
// Search inside iframes
for (const f of document.querySelectorAll('iframe')) {
try {
const inner = f.contentDocument || f.contentWindow?.document;
if (!inner) continue;
const a = inner.querySelector('.rc-audiochallenge-tdownload-link, a[href*=".mp3"]');
if (a) return a.href;
} catch (_) {}
}
return null;
})()"#;
let url = page
.evaluate(js)
.await?
.into_value::<Option<String>>()
.unwrap_or(None)
.ok_or_else(|| anyhow!("audio MP3 URL not found on page"))?;
Ok(url)
}
async fn download_audio(&self, url: &str) -> Result<Vec<u8>> {
let resp = self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0")
.send()
.await
.map_err(|e| anyhow!("audio download failed: {}", e))?;
let bytes = resp
.bytes()
.await
.map_err(|e| anyhow!("audio read failed: {}", e))?;
Ok(bytes.to_vec())
}
async fn transcribe(&self, audio: Vec<u8>) -> Result<String> {
use reqwest::multipart;
let part = multipart::Part::bytes(audio)
.file_name("audio.mp3")
.mime_str("audio/mpeg")?;
let form = multipart::Form::new().part("audio_file", part);
let resp = self
.client
.post(&self.stt_endpoint)
.multipart(form)
.send()
.await
.map_err(|e| anyhow!("STT request failed: {}", e))?;
if !resp.status().is_success() {
return Err(anyhow!("STT API returned {}", resp.status()));
}
let body = resp.text().await?;
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(text) = val["text"].as_str() {
return Ok(text.trim().to_string());
}
}
Ok(body.trim().to_string())
}
async fn type_audio_response(&self, page: &Page, text: &str) -> Result<()> {
let focus_js = r#"
let inp = document.querySelector('#audio-response, input[id*="audio"]');
if (!inp) {
for (const f of document.querySelectorAll('iframe')) {
try {
const inner = f.contentDocument || f.contentWindow?.document;
if (!inner) continue;
inp = inner.querySelector('#audio-response, input[id*="audio"]');
if (inp) break;
} catch (_) {}
}
}
if (inp) { inp.focus(); inp.value = ''; }
"#;
page.evaluate(focus_js).await.ok();
crate::behavior::type_realistic(page, text).await?;
let submit_js = r#"
let btn = document.querySelector('#recaptcha-verify-button, button[id*="verify"]');
if (!btn) {
for (const f of document.querySelectorAll('iframe')) {
try {
const inner = f.contentDocument || f.contentWindow?.document;
if (!inner) continue;
btn = inner.querySelector('#recaptcha-verify-button');
if (btn) break;
} catch (_) {}
}
}
if (btn) btn.click();
"#;
page.evaluate(submit_js).await.ok();
tokio::time::sleep(Duration::from_millis(self.config.audio_submit_delay_ms)).await;
Ok(())
}
}
impl Default for AudioCaptchaSolver {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl CaptchaSolver for AudioCaptchaSolver {
fn name(&self) -> &'static str {
"AudioCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::AudioBypass
}
async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
use crate::captcha_detect::DetectedCaptcha;
if captcha_info.kind != DetectedCaptcha::RecaptchaV2
&& captcha_info.kind != DetectedCaptcha::AudioCaptcha
{
return Ok(CaptchaSolveResult::failure(SolveMethod::AudioBypass, 0));
}
let t0 = Instant::now();
self.click_audio_button(page).await?;
let audio_url = self.get_audio_url(page).await?;
debug!(url = %audio_url, "downloading audio challenge");
let audio_bytes = self.download_audio(&audio_url).await?;
let transcription = self.transcribe(audio_bytes).await?;
info!(text = %transcription, "audio transcription");
self.type_audio_response(page, &transcription).await?;
tokio::time::sleep(Duration::from_millis(500)).await;
let verified = crate::frame::verify_token_in_frames(page, "g-recaptcha-response").await?;
if verified {
Ok(CaptchaSolveResult {
solution: transcription,
confidence: 0.85,
method: SolveMethod::AudioBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
})
} else {
Ok(CaptchaSolveResult::failure(
SolveMethod::AudioBypass,
t0.elapsed().as_millis() as u64,
))
}
}
}
pub struct BehavioralCaptchaSolver {
config: SolveConfig,
}
impl BehavioralCaptchaSolver {
pub fn new() -> Self {
Self {
config: SolveConfig::default(),
}
}
pub fn with_config(mut self, config: SolveConfig) -> Self {
self.config = config;
self
}
async fn natural_browsing(&self, page: &Page) -> Result<()> {
let mut rng = rand::rngs::StdRng::from_entropy();
let vp_js = "({ w: window.innerWidth || 1280, h: window.innerHeight || 800 })";
let vp = page
.evaluate(vp_js)
.await?
.into_value::<serde_json::Value>()
.unwrap_or(serde_json::Value::Null);
let vw = vp["w"].as_f64().unwrap_or(1280.0);
let vh = vp["h"].as_f64().unwrap_or(800.0);
let meanders = rng.gen_range(3..=5);
let mut cx = vw * 0.5;
let mut cy = vh * 0.5;
for _ in 0..meanders {
let tx = rng.gen_range(80.0..vw - 80.0_f64);
let ty = rng.gen_range(80.0..vh - 80.0_f64);
crate::behavior::mouse_move_bezier(page, cx, cy, tx, ty).await?;
cx = tx;
cy = ty;
crate::behavior::micro_pause().await;
}
let scrolls = rng.gen_range(1..=2);
for _ in 0..scrolls {
crate::behavior::scroll_realistic(
page,
crate::behavior::ScrollDirection::Down,
rng.gen_range(80..300),
)
.await?;
crate::behavior::idle_pause().await;
crate::behavior::scroll_realistic(
page,
crate::behavior::ScrollDirection::Up,
rng.gen_range(40..200),
)
.await?;
}
Ok(())
}
async fn click_recaptcha_v2(&self, page: &Page) -> Result<()> {
let mut rng = rand::rngs::StdRng::from_entropy();
for attempt in 0..self.config.checkbox_max_attempts {
tokio::time::sleep(Duration::from_millis(self.config.checkbox_poll_interval_ms)).await;
if let Some((x, y)) =
crate::frame::find_element_centre_in_frames(page, "#recaptcha-anchor, .recaptcha-checkbox").await?
{
let ox = x + rng.gen_range(-200.0..200.0_f64);
let oy = y + rng.gen_range(-100.0..100.0_f64);
crate::behavior::mouse_move_bezier(page, ox, oy, x, y).await?;
crate::behavior::click_realistic(page, x, y).await?;
debug!(attempt, x, y, "reCAPTCHA v2 checkbox clicked");
return Ok(());
}
debug!(attempt, "waiting for reCAPTCHA v2 checkbox…");
}
Err(anyhow!("reCAPTCHA v2 checkbox not found after {} attempts", self.config.checkbox_max_attempts))
}
async fn click_turnstile(&self, page: &Page) -> Result<()> {
let mut rng = rand::rngs::StdRng::from_entropy();
for attempt in 0..self.config.checkbox_max_attempts {
tokio::time::sleep(Duration::from_millis(self.config.checkbox_poll_interval_ms)).await;
if let Some((x, y)) = crate::frame::find_element_centre_in_frames(
page,
"input[type='checkbox'], [data-testid='checkbox']",
)
.await?
{
let ox = x + rng.gen_range(-200.0..200.0_f64);
let oy = y + rng.gen_range(-100.0..100.0_f64);
crate::behavior::mouse_move_bezier(page, ox, oy, x, y).await?;
crate::behavior::click_realistic(page, x, y).await?;
debug!(attempt, x, y, "turnstile checkbox clicked");
return Ok(());
}
debug!(attempt, "waiting for Turnstile checkbox…");
}
Err(anyhow!(
"Turnstile checkbox not found after {} attempts",
self.config.checkbox_max_attempts
))
}
}
impl Default for BehavioralCaptchaSolver {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl CaptchaSolver for BehavioralCaptchaSolver {
fn name(&self) -> &'static str {
"BehavioralCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::BehavioralBypass
}
async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
use crate::captcha_detect::DetectedCaptcha;
let t0 = Instant::now();
match &captcha_info.kind {
DetectedCaptcha::RecaptchaV2 => {
self.click_recaptcha_v2(page).await?;
for _ in 0..self.config.token_max_attempts {
tokio::time::sleep(Duration::from_millis(self.config.token_poll_interval_ms)).await;
let solved_js = r#"
!!(document.querySelector('[name="g-recaptcha-response"][value]:not([value=""])') ||
document.querySelector('input[name="g-recaptcha-response"][value!=""]'))
"#;
let solved = page
.evaluate(solved_js)
.await?
.into_value::<bool>()
.unwrap_or(false);
if solved {
return Ok(CaptchaSolveResult {
solution: "recaptcha_v2:behavioral".to_string(),
confidence: 0.90,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
});
}
let challenge_visible_js = r#"
(function() {
const f = document.querySelector('iframe[src*="google.com/recaptcha/api2/bframe"]');
if (!f) return false;
const r = f.getBoundingClientRect();
return r.width > 0 && r.height > 0 && window.getComputedStyle(f).visibility !== 'hidden';
})()
"#;
let challenge_visible = page
.evaluate(challenge_visible_js)
.await?
.into_value::<bool>()
.unwrap_or(false);
if challenge_visible {
debug!("reCAPTCHA v2 challenge popped up; behavioral bypass failed");
return Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
));
}
if crate::frame::verify_token_in_frames(page, "g-recaptcha-response").await? {
return Ok(CaptchaSolveResult {
solution: "recaptcha_v2:behavioral".to_string(),
confidence: 0.90,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
});
}
}
Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
))
}
DetectedCaptcha::Turnstile => {
if crate::frame::verify_token_in_frames(page, "cf-turnstile-response").await? {
return Ok(CaptchaSolveResult {
solution: "turnstile:behavioral".to_string(),
confidence: 0.95,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
});
}
self.natural_browsing(page).await?;
if self.click_turnstile(page).await.is_ok() {
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, "cf-turnstile-response").await? {
return Ok(CaptchaSolveResult {
solution: "turnstile:behavioral".to_string(),
confidence: 0.80,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
});
}
}
}
Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
))
}
DetectedCaptcha::RecaptchaV3 => {
self.natural_browsing(page).await?;
crate::behavior::idle_pause().await;
self.natural_browsing(page).await?;
let token_present =
crate::frame::verify_token_in_frames(page, "g-recaptcha-response").await?;
if token_present {
Ok(CaptchaSolveResult {
solution: "recaptcha_v3:behavioral".to_string(),
confidence: 0.70,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
})
} else {
Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
))
}
}
_ => {
warn!(kind = ?captcha_info.kind, "BehavioralCaptchaSolver: unsupported type, skipping");
Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
))
}
}
}
}
pub struct CaptchaSolverChain {
solvers: Vec<Box<dyn CaptchaSolver>>,
patterns: PatternStore,
}
impl CaptchaSolverChain {
pub fn default_chain() -> Self {
let mut chain = Self {
solvers: Vec::new(),
patterns: PatternStore::default(),
};
chain.add_solver(BehavioralCaptchaSolver::new());
chain.add_solver(VlmCaptchaSolver::new());
chain.add_solver(AudioCaptchaSolver::new());
chain
}
pub fn empty() -> Self {
Self {
solvers: Vec::new(),
patterns: PatternStore::default(),
}
}
pub fn add_solver<S: CaptchaSolver + 'static>(&mut self, solver: S) {
self.solvers.push(Box::new(solver));
}
pub fn with_pattern_store(mut self, store: PatternStore) -> Self {
self.patterns = store;
self
}
pub async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> CaptchaSolveResult {
let domain = extract_domain(&captcha_info.page_url);
let captcha_type = detected_to_type(&captcha_info.kind);
let t0 = Instant::now();
let ordered = self.ordered_solvers(&domain, &captcha_type);
for solver in &ordered {
info!(solver = solver.name(), "attempting captcha solve");
match solver.solve(page, captcha_info).await {
Ok(result) if result.success => {
info!(
solver = solver.name(),
confidence = result.confidence,
time_ms = result.time_ms,
"captcha solved"
);
self.patterns.record(
&domain,
&captcha_type,
true,
result.time_ms,
result.method.clone(),
);
return result;
}
Ok(result) => {
warn!(solver = solver.name(), "solver returned failure result");
self.patterns.record(
&domain,
&captcha_type,
false,
result.time_ms,
result.method.clone(),
);
}
Err(e) => {
warn!(solver = solver.name(), error = %e, "solver error");
}
}
}
warn!("all captcha solvers failed, human fallback required");
let screenshot = screenshot_b64(page).await.ok();
CaptchaSolveResult::unsolved(t0.elapsed().as_millis() as u64, screenshot)
}
fn ordered_solvers(&self, domain: &str, captcha_type: &CaptchaType) -> Vec<&dyn CaptchaSolver> {
let preferred = self.patterns.best_method(domain, captcha_type);
let mut ordered: Vec<&dyn CaptchaSolver> =
self.solvers.iter().map(|s| s.as_ref()).collect();
if let Some(pref) = preferred {
if let Some(pos) = ordered.iter().position(|s| s.method() == pref) {
if pos > 0 {
let item = ordered.remove(pos);
ordered.insert(0, item);
}
}
}
ordered
}
}
fn extract_domain(page_url: &str) -> String {
if let Ok(u) = url::Url::parse(page_url) {
if let Some(host) = u.host_str() {
return host.to_string();
}
}
if let Some(after_scheme) = page_url.split("://").nth(1) {
return after_scheme
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("")
.to_string();
}
let candidate = page_url
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("")
.trim();
if candidate.is_empty() || candidate.contains(' ') {
String::new()
} else {
candidate.to_string()
}
}
fn detected_to_type(detected: &crate::captcha_detect::DetectedCaptcha) -> CaptchaType {
use crate::captcha_detect::DetectedCaptcha;
match detected {
DetectedCaptcha::RecaptchaV2 => CaptchaType::RecaptchaV2,
DetectedCaptcha::RecaptchaV3 => CaptchaType::RecaptchaV3,
DetectedCaptcha::HCaptcha => CaptchaType::HCaptcha,
DetectedCaptcha::Turnstile => CaptchaType::CloudflareTurnstile,
DetectedCaptcha::ImageCaptcha => CaptchaType::ImageGrid,
DetectedCaptcha::AudioCaptcha => CaptchaType::AudioCaptcha,
DetectedCaptcha::None => CaptchaType::Custom("none".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::captcha_detect::DetectedCaptcha;
#[test]
fn captcha_type_display() {
assert_eq!(CaptchaType::RecaptchaV2.to_string(), "recaptcha_v2");
assert_eq!(
CaptchaType::CloudflareTurnstile.to_string(),
"cloudflare_turnstile"
);
assert_eq!(
CaptchaType::Custom("banana".to_string()).to_string(),
"custom:banana"
);
}
#[test]
fn captcha_type_serializes() {
let json = serde_json::to_string(&CaptchaType::HCaptcha).unwrap();
assert_eq!(json, r#""hcaptcha""#);
}
#[test]
fn solve_method_serializes() {
let json = serde_json::to_string(&SolveMethod::VisionLLM).unwrap();
assert_eq!(json, r#""vision_llm""#);
}
#[test]
fn solve_result_failure_constructor() {
let r = CaptchaSolveResult::failure(SolveMethod::AudioBypass, 500);
assert!(!r.success);
assert_eq!(r.time_ms, 500);
assert_eq!(r.confidence, 0.0);
assert!(r.solution.is_empty());
}
#[test]
fn solve_result_round_trips_json() {
let r = CaptchaSolveResult {
solution: "abc123".to_string(),
confidence: 0.9,
method: SolveMethod::BehavioralBypass,
time_ms: 1234,
success: true,
screenshot: None,
};
let json = serde_json::to_string(&r).unwrap();
let r2: CaptchaSolveResult = serde_json::from_str(&json).unwrap();
assert_eq!(r2.solution, "abc123");
assert_eq!(r2.time_ms, 1234);
assert!(r2.success);
}
#[test]
fn pattern_record_updates_stats() {
let mut p = CaptchaPattern::new(
"example.com",
CaptchaType::CloudflareTurnstile,
SolveMethod::BehavioralBypass,
);
p.record(true, 2000, SolveMethod::BehavioralBypass);
p.record(false, 3000, SolveMethod::VisionLLM);
p.record(true, 1000, SolveMethod::BehavioralBypass);
assert_eq!(p.sample_count, 3);
assert!((p.success_rate - 2.0 / 3.0).abs() < 0.01);
assert_eq!(p.avg_solve_time, 2000);
}
#[test]
fn pattern_store_record_and_lookup() {
let store = PatternStore::default();
store.record(
"example.com",
&CaptchaType::RecaptchaV2,
true,
1500,
SolveMethod::AudioBypass,
);
let method = store.best_method("example.com", &CaptchaType::RecaptchaV2);
assert_eq!(method, Some(SolveMethod::AudioBypass));
}
#[test]
fn pattern_store_unknown_returns_none() {
let store = PatternStore::default();
assert!(store
.best_method("unknown.com", &CaptchaType::Slider)
.is_none());
}
#[test]
fn pattern_store_all_patterns() {
let store = PatternStore::default();
store.record(
"a.com",
&CaptchaType::HCaptcha,
true,
1000,
SolveMethod::VisionLLM,
);
store.record(
"b.com",
&CaptchaType::RecaptchaV2,
false,
2000,
SolveMethod::AudioBypass,
);
assert_eq!(store.all_patterns().len(), 2);
}
#[test]
fn extract_domain_strips_path_and_port() {
assert_eq!(extract_domain("https://example.com/path"), "example.com");
assert_eq!(
extract_domain("http://sub.domain.org:8080/foo"),
"sub.domain.org"
);
assert_eq!(extract_domain("example.com"), "example.com");
assert_eq!(extract_domain(""), "");
assert_eq!(extract_domain("not a url"), "");
}
#[test]
fn detected_to_type_mapping() {
assert_eq!(
detected_to_type(&DetectedCaptcha::Turnstile),
CaptchaType::CloudflareTurnstile
);
assert_eq!(
detected_to_type(&DetectedCaptcha::RecaptchaV2),
CaptchaType::RecaptchaV2
);
assert_eq!(
detected_to_type(&DetectedCaptcha::HCaptcha),
CaptchaType::HCaptcha
);
}
#[test]
fn extract_json_from_plain_text() {
let s = r#"Some preamble {"key": "value"} trailing"#;
assert_eq!(extract_json(s), Some(r#"{"key": "value"}"#));
}
#[test]
fn extract_json_from_markdown_fence() {
let s = "```json\n{\"answer\": 42}\n```";
assert_eq!(extract_json(s), Some("{\"answer\": 42}"));
}
#[test]
fn extract_json_no_json_returns_none() {
assert_eq!(extract_json("no json here"), None);
}
#[test]
fn grid_prompt_includes_task() {
let prompt = VlmCaptchaSolver::grid_prompt("Select all traffic lights");
assert!(prompt.contains("Select all traffic lights"));
assert!(prompt.contains("selected"));
assert!(prompt.contains("confidence"));
}
#[test]
fn text_captcha_prompt_is_valid() {
let prompt = VlmCaptchaSolver::text_captcha_prompt();
assert!(prompt.contains("text"));
assert!(prompt.contains("confidence"));
}
#[test]
fn click_target_prompt_includes_target() {
let prompt = VlmCaptchaSolver::click_target_prompt("submit button");
assert!(prompt.contains("submit button"));
assert!(prompt.contains('"'));
}
#[test]
fn solver_chain_add_and_count() {
let mut chain = CaptchaSolverChain::empty();
chain.add_solver(BehavioralCaptchaSolver::new());
chain.add_solver(AudioCaptchaSolver::new());
assert_eq!(chain.solvers.len(), 2);
}
#[test]
fn default_chain_has_three_solvers() {
let chain = CaptchaSolverChain::default_chain();
assert_eq!(chain.solvers.len(), 3);
assert_eq!(chain.solvers[0].name(), "BehavioralCaptchaSolver");
assert_eq!(chain.solvers[1].name(), "VlmCaptchaSolver");
assert_eq!(chain.solvers[2].name(), "AudioCaptchaSolver");
}
#[test]
fn solver_names() {
assert_eq!(
BehavioralCaptchaSolver::new().name(),
"BehavioralCaptchaSolver"
);
assert_eq!(VlmCaptchaSolver::new().name(), "VlmCaptchaSolver");
assert_eq!(AudioCaptchaSolver::new().name(), "AudioCaptchaSolver");
}
#[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");
}
#[test]
fn vlm_solver_default_endpoint_and_model() {
let s = VlmCaptchaSolver::new();
assert_eq!(s.endpoint, "http://localhost:11434");
assert_eq!(s.model, "qwen3-vl:30b");
}
#[test]
fn vlm_solver_custom_endpoint() {
let s = VlmCaptchaSolver::new().with_endpoint("http://ollama.internal:11434");
assert_eq!(s.endpoint, "http://ollama.internal:11434");
}
#[test]
fn vlm_solver_custom_model() {
let s = VlmCaptchaSolver::new().with_model("llava:13b");
assert_eq!(s.model, "llava:13b");
}
#[test]
fn vlm_solver_chained_builders() {
let s = VlmCaptchaSolver::new()
.with_endpoint("http://gpu-box:11434")
.with_model("qwen3-vl:72b");
assert_eq!(s.endpoint, "http://gpu-box:11434");
assert_eq!(s.model, "qwen3-vl:72b");
}
#[test]
fn ordered_solvers_puts_preferred_first() {
let mut chain = CaptchaSolverChain::empty();
chain.add_solver(BehavioralCaptchaSolver::new());
chain.add_solver(VlmCaptchaSolver::new());
chain.add_solver(AudioCaptchaSolver::new());
chain.patterns.record(
"example.com",
&CaptchaType::RecaptchaV2,
true,
1000,
SolveMethod::VisionLLM,
);
let ordered = chain.ordered_solvers("example.com", &CaptchaType::RecaptchaV2);
assert_eq!(ordered.len(), 3);
assert_eq!(ordered[0].name(), "VlmCaptchaSolver");
assert_eq!(ordered[1].name(), "BehavioralCaptchaSolver");
assert_eq!(ordered[2].name(), "AudioCaptchaSolver");
}
#[test]
fn ordered_solvers_keeps_original_order_when_no_preference() {
let chain = CaptchaSolverChain::default_chain();
let ordered = chain.ordered_solvers("unknown.com", &CaptchaType::HCaptcha);
assert_eq!(ordered[0].name(), "BehavioralCaptchaSolver");
assert_eq!(ordered[1].name(), "VlmCaptchaSolver");
assert_eq!(ordered[2].name(), "AudioCaptchaSolver");
}
#[test]
fn ordered_solvers_keeps_original_order_when_preference_not_in_chain() {
let mut chain = CaptchaSolverChain::empty();
chain.add_solver(BehavioralCaptchaSolver::new());
chain.add_solver(VlmCaptchaSolver::new());
chain.patterns.record(
"example.com",
&CaptchaType::RecaptchaV2,
true,
1000,
SolveMethod::AudioBypass,
);
let ordered = chain.ordered_solvers("example.com", &CaptchaType::RecaptchaV2);
assert_eq!(ordered[0].name(), "BehavioralCaptchaSolver");
assert_eq!(ordered[1].name(), "VlmCaptchaSolver");
}
#[test]
fn unsolved_result_has_no_screenshot() {
let r = CaptchaSolveResult::unsolved(1234, None);
assert!(!r.success);
assert_eq!(r.time_ms, 1234);
assert!(r.screenshot.is_none());
}
#[test]
fn unsolved_result_can_carry_screenshot() {
let r = CaptchaSolveResult::unsolved(5678, Some("b64img".into()));
assert!(!r.success);
assert_eq!(r.screenshot, Some("b64img".into()));
}
}