use anyhow::Result;
use async_trait::async_trait;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
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,
PowCaptcha,
CanvasCaptcha,
ShadowDomCaptcha,
MultiStepCaptcha,
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::PowCaptcha => write!(f, "pow_captcha"),
CaptchaType::CanvasCaptcha => write!(f, "canvas_captcha"),
CaptchaType::ShadowDomCaptcha => write!(f, "shadow_dom_captcha"),
CaptchaType::MultiStepCaptcha => write!(f, "multi_step_captcha"),
CaptchaType::Custom(s) => write!(f, "custom:{}", s),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, 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>,
#[serde(default)]
pub cookies: Vec<crate::cookies::CapturedCookie>,
}
impl CaptchaSolveResult {
pub fn failure(method: SolveMethod, time_ms: u64) -> Self {
Self {
solution: String::new(),
confidence: 0.0,
method,
time_ms,
success: false,
screenshot: None,
cookies: Vec::new(),
}
}
pub fn unsolved(time_ms: u64, screenshot: Option<String>) -> Self {
Self {
solution: String::new(),
confidence: 0.0,
method: SolveMethod::CrowdSourced,
time_ms,
success: false,
screenshot,
cookies: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
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;
fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool;
}
#[cfg(test)]
mod tests {
use super::*;
#[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"
);
assert_eq!(CaptchaType::PowCaptcha.to_string(), "pow_captcha");
assert_eq!(CaptchaType::CanvasCaptcha.to_string(), "canvas_captcha");
assert_eq!(
CaptchaType::ShadowDomCaptcha.to_string(),
"shadow_dom_captcha"
);
assert_eq!(
CaptchaType::MultiStepCaptcha.to_string(),
"multi_step_captcha"
);
}
#[test]
fn captcha_type_serializes() {
let json = serde_json::to_string(&CaptchaType::HCaptcha).unwrap();
assert_eq!(json, r#""hcaptcha""#);
}
#[test]
fn captcha_type_roundtrips_all_variants() {
for variant in [
CaptchaType::RecaptchaV2,
CaptchaType::RecaptchaV3,
CaptchaType::HCaptcha,
CaptchaType::CloudflareTurnstile,
CaptchaType::ImageGrid,
CaptchaType::TextCaptcha,
CaptchaType::AudioCaptcha,
CaptchaType::Slider,
CaptchaType::PowCaptcha,
CaptchaType::CanvasCaptcha,
CaptchaType::ShadowDomCaptcha,
CaptchaType::MultiStepCaptcha,
CaptchaType::Custom("foo".to_string()),
] {
let json = serde_json::to_string(&variant).unwrap();
let rt: CaptchaType = serde_json::from_str(&json).unwrap();
assert_eq!(variant, rt);
}
}
#[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,
cookies: Vec::new(),
};
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 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()));
}
}