use anyhow::Result;
use async_trait::async_trait;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
mod audio_captcha;
mod canvas_captcha;
mod challenge_page;
mod hcaptcha;
mod image_captcha;
mod multi_step;
mod pow_captcha;
mod recaptcha;
pub mod rules;
mod shadow_dom;
mod slider_captcha;
mod turnstile;
pub use audio_captcha::AudioCaptchaDetector;
pub use canvas_captcha::CanvasCaptchaDetector;
pub use challenge_page::ChallengePageDetector;
pub use hcaptcha::HCaptchaDetector;
pub use image_captcha::ImageCaptchaDetector;
pub use multi_step::MultiStepCaptchaDetector;
pub use pow_captcha::PowCaptchaDetector;
pub use recaptcha::RecaptchaDetector;
pub use shadow_dom::ShadowDomDetector;
pub use slider_captcha::SliderCaptchaDetector;
pub use turnstile::TurnstileDetector;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CaptchaInfo {
pub kind: DetectedCaptcha,
pub site_key: Option<String>,
pub page_url: String,
pub container_selector: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum DetectedCaptcha {
Turnstile,
RecaptchaV2,
RecaptchaV3,
#[serde(rename = "hcaptcha")]
HCaptcha,
ImageCaptcha,
AudioCaptcha,
PowCaptcha,
SliderCaptcha,
CanvasCaptcha,
ShadowDomCaptcha,
MultiStepCaptcha,
Custom(String),
None,
}
#[async_trait]
pub trait Detector: Send + Sync {
fn name(&self) -> &'static str;
fn priority(&self) -> i32;
async fn detect(&self, page: &Page) -> Result<Option<CaptchaInfo>>;
}
pub struct DetectorRegistry {
detectors: Vec<Box<dyn Detector>>,
}
impl DetectorRegistry {
pub fn new() -> Self {
let mut detectors: Vec<Box<dyn Detector>> = vec![
Box::new(ChallengePageDetector),
Box::new(TurnstileDetector),
Box::new(RecaptchaDetector),
Box::new(HCaptchaDetector),
Box::new(ImageCaptchaDetector),
Box::new(AudioCaptchaDetector),
Box::new(PowCaptchaDetector),
Box::new(CanvasCaptchaDetector),
Box::new(SliderCaptchaDetector),
Box::new(MultiStepCaptchaDetector),
Box::new(ShadowDomDetector),
];
detectors.sort_by_key(|d| d.priority());
Self { detectors }
}
pub async fn detect(&self, page: &Page) -> Result<Option<CaptchaInfo>> {
for detector in &self.detectors {
if let Some(info) = detector.detect(page).await? {
return Ok(Some(info));
}
}
Ok(None)
}
}
impl Default for DetectorRegistry {
fn default() -> Self {
Self::new()
}
}
pub(crate) fn parse_detection_result(val: serde_json::Value) -> Option<CaptchaInfo> {
let kind_str = val["kind"].as_str().unwrap_or("none");
if kind_str == "none" {
return None;
}
let kind = match kind_str {
"turnstile" => DetectedCaptcha::Turnstile,
"recaptcha_v2" => DetectedCaptcha::RecaptchaV2,
"recaptcha_v3" => DetectedCaptcha::RecaptchaV3,
"hcaptcha" => DetectedCaptcha::HCaptcha,
"image" => DetectedCaptcha::ImageCaptcha,
"audio" => DetectedCaptcha::AudioCaptcha,
"pow" => DetectedCaptcha::PowCaptcha,
"slider" => DetectedCaptcha::SliderCaptcha,
"canvas" => DetectedCaptcha::CanvasCaptcha,
"shadow_dom" => DetectedCaptcha::ShadowDomCaptcha,
"multi_step" => DetectedCaptcha::MultiStepCaptcha,
s if s.starts_with("custom:") => DetectedCaptcha::Custom(s[7..].to_owned()),
_ => DetectedCaptcha::None,
};
if kind == DetectedCaptcha::None {
return None;
}
Some(CaptchaInfo {
kind,
site_key: val["site_key"].as_str().map(String::from),
page_url: String::new(),
container_selector: val["container"].as_str().map(String::from),
})
}
pub const DETECT_JS: &str = r#"(function() {
// --- Challenge page (priority 5) ---
{
if (document.title.includes('Just a moment') ||
document.title.includes('Attention Required') ||
document.querySelector('#challenge-running, #challenge-form, .cf-browser-verification')) {
return { kind: 'turnstile', site_key: null, container: '#challenge-form' };
}
}
// --- Turnstile (priority 10) ---
{
const turnstile = document.querySelector('.cf-turnstile, [data-turnstile-sitekey]');
if (turnstile) {
return {
kind: 'turnstile',
site_key: turnstile.getAttribute('data-sitekey') ||
turnstile.getAttribute('data-turnstile-sitekey'),
container: '.cf-turnstile'
};
}
const turnstileScript = document.querySelector('script[src*="challenges.cloudflare.com"]');
if (turnstileScript) {
const cfEl = document.querySelector('[data-sitekey]');
if (cfEl) {
return {
kind: 'turnstile',
site_key: cfEl.getAttribute('data-sitekey'),
container: '[data-sitekey]'
};
}
}
}
// --- reCAPTCHA (priority 20) ---
{
const recaptcha = document.querySelector('.g-recaptcha');
const recaptchaScript = document.querySelector('script[src*="google.com/recaptcha"]');
if (recaptcha || recaptchaScript) {
let el = recaptcha;
if (!el) {
const fallback = document.querySelector('[data-sitekey]');
if (fallback && !fallback.hasAttribute('data-hcaptcha-sitekey') && !fallback.hasAttribute('data-turnstile-sitekey')) {
el = fallback;
}
}
if (el) {
const size = el.getAttribute('data-size');
return {
kind: size === 'invisible' ? 'recaptcha_v3' : 'recaptcha_v2',
site_key: el.getAttribute('data-sitekey'),
container: '.g-recaptcha'
};
}
return { kind: 'recaptcha_v3', site_key: null, container: null };
}
}
// --- hCaptcha (priority 30) ---
{
const hcaptcha = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]');
const hcaptchaScript = document.querySelector('script[src*="hcaptcha.com"]');
if (hcaptcha || hcaptchaScript) {
let el = hcaptcha;
if (!el) {
const fallback = document.querySelector('[data-sitekey]');
if (fallback && !fallback.hasAttribute('data-turnstile-sitekey') && !fallback.matches('.g-recaptcha')) {
el = fallback;
}
}
const result = { kind: 'hcaptcha', site_key: null, container: '.h-captcha' };
if (el) {
result.site_key = el.getAttribute('data-sitekey') ||
el.getAttribute('data-hcaptcha-sitekey');
}
return result;
}
}
// --- Image CAPTCHA (priority 40) ---
{
const imgCaptcha = document.querySelector(
'img[src*="captcha"], img[src*="Captcha"], .captcha img, #captcha img'
);
if (imgCaptcha) {
return { kind: 'image', site_key: null, container: '.captcha, #captcha' };
}
}
// --- Audio CAPTCHA (priority 50) ---
{
const audioCaptcha = document.querySelector(
'audio[src*="captcha"], .audio-captcha, #audio-captcha'
);
if (audioCaptcha) {
return { kind: 'audio', site_key: null, container: '.audio-captcha, #audio-captcha' };
}
}
return { kind: 'none', site_key: null, container: null };
})()"#;
pub async fn detect(page: &Page) -> Result<CaptchaInfo> {
let page_url = page
.evaluate("window.location.href")
.await?
.into_value::<String>()
.unwrap_or_else(|_| String::new());
let registry = DetectorRegistry::new();
if let Some(mut info) = registry.detect(page).await? {
info.page_url = page_url;
return Ok(info);
}
Ok(CaptchaInfo {
kind: DetectedCaptcha::None,
site_key: None,
page_url,
container_selector: None,
})
}
pub fn is_captcha(info: &CaptchaInfo) -> bool {
info.kind != DetectedCaptcha::None
}
pub fn solver_kind_str(detected: &DetectedCaptcha) -> Option<&'static str> {
match detected {
DetectedCaptcha::Turnstile => Some("turnstile"),
DetectedCaptcha::RecaptchaV2 => Some("recaptcha_v2"),
DetectedCaptcha::RecaptchaV3 => Some("recaptcha_v3"),
DetectedCaptcha::HCaptcha => Some("hcaptcha"),
DetectedCaptcha::ImageCaptcha => Some("image_captcha"),
DetectedCaptcha::AudioCaptcha => Some("audio_captcha"),
DetectedCaptcha::PowCaptcha => Some("pow_captcha"),
DetectedCaptcha::SliderCaptcha => Some("slider_captcha"),
DetectedCaptcha::CanvasCaptcha => Some("canvas_captcha"),
DetectedCaptcha::ShadowDomCaptcha => Some("shadow_dom_captcha"),
DetectedCaptcha::MultiStepCaptcha => Some("multi_step_captcha"),
DetectedCaptcha::Custom(_) => None,
DetectedCaptcha::None => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_detect_js_contains_turnstile() {
assert!(DETECT_JS.contains("cf-turnstile"));
assert!(DETECT_JS.contains("challenges.cloudflare.com"));
}
#[test]
fn legacy_detect_js_contains_recaptcha() {
assert!(DETECT_JS.contains("g-recaptcha"));
assert!(DETECT_JS.contains("google.com/recaptcha"));
}
#[test]
fn legacy_detect_js_contains_hcaptcha() {
assert!(DETECT_JS.contains("h-captcha"));
assert!(DETECT_JS.contains("hcaptcha.com"));
}
#[test]
fn legacy_detect_js_contains_challenge_page() {
assert!(DETECT_JS.contains("Just a moment"));
assert!(DETECT_JS.contains("challenge-running"));
}
#[test]
fn legacy_detect_js_contains_image_captcha() {
assert!(DETECT_JS.contains("img[src*=\"captcha\"]"));
assert!(DETECT_JS.contains("'image'"));
}
#[test]
fn legacy_detect_js_contains_audio_captcha() {
assert!(DETECT_JS.contains("audio[src*=\"captcha\"]"));
assert!(DETECT_JS.contains("'audio'"));
}
#[test]
fn legacy_detect_js_recaptcha_prefers_g_recaptcha_class() {
assert!(DETECT_JS.contains("document.querySelector('.g-recaptcha')"));
}
#[test]
fn legacy_detect_js_recaptcha_fallback_excludes_other_providers() {
assert!(DETECT_JS.contains("!fallback.hasAttribute('data-hcaptcha-sitekey')"));
assert!(DETECT_JS.contains("!fallback.hasAttribute('data-turnstile-sitekey')"));
}
#[test]
fn legacy_detect_js_hcaptcha_fallback_excludes_other_providers() {
assert!(DETECT_JS.contains("!fallback.hasAttribute('data-turnstile-sitekey')"));
assert!(DETECT_JS.contains("!fallback.matches('.g-recaptcha')"));
}
#[test]
fn captcha_info_serializes() {
let info = CaptchaInfo {
kind: DetectedCaptcha::Turnstile,
site_key: Some("abc123".into()),
page_url: "https://example.com".into(),
container_selector: Some(".cf-turnstile".into()),
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"kind\":\"turnstile\""));
assert!(json.contains("\"site_key\":\"abc123\""));
}
#[test]
fn is_captcha_false_for_none() {
let info = CaptchaInfo {
kind: DetectedCaptcha::None,
site_key: None,
page_url: String::new(),
container_selector: None,
};
assert!(!is_captcha(&info));
}
#[test]
fn is_captcha_true_for_every_non_none_variant() {
for variant in [
DetectedCaptcha::Turnstile,
DetectedCaptcha::RecaptchaV2,
DetectedCaptcha::RecaptchaV3,
DetectedCaptcha::HCaptcha,
DetectedCaptcha::ImageCaptcha,
DetectedCaptcha::AudioCaptcha,
DetectedCaptcha::PowCaptcha,
DetectedCaptcha::SliderCaptcha,
DetectedCaptcha::CanvasCaptcha,
DetectedCaptcha::ShadowDomCaptcha,
DetectedCaptcha::MultiStepCaptcha,
] {
let info = CaptchaInfo {
kind: variant.clone(),
site_key: None,
page_url: String::new(),
container_selector: None,
};
assert!(is_captcha(&info), "variant {variant:?} should be a captcha");
}
}
#[test]
fn solver_kind_str_maps_all_variants() {
assert_eq!(
solver_kind_str(&DetectedCaptcha::Turnstile),
Some("turnstile")
);
assert_eq!(
solver_kind_str(&DetectedCaptcha::RecaptchaV2),
Some("recaptcha_v2")
);
assert_eq!(
solver_kind_str(&DetectedCaptcha::RecaptchaV3),
Some("recaptcha_v3")
);
assert_eq!(
solver_kind_str(&DetectedCaptcha::HCaptcha),
Some("hcaptcha")
);
assert_eq!(
solver_kind_str(&DetectedCaptcha::ImageCaptcha),
Some("image_captcha")
);
assert_eq!(
solver_kind_str(&DetectedCaptcha::AudioCaptcha),
Some("audio_captcha")
);
assert_eq!(
solver_kind_str(&DetectedCaptcha::PowCaptcha),
Some("pow_captcha")
);
assert_eq!(
solver_kind_str(&DetectedCaptcha::SliderCaptcha),
Some("slider_captcha")
);
assert_eq!(
solver_kind_str(&DetectedCaptcha::CanvasCaptcha),
Some("canvas_captcha")
);
assert_eq!(
solver_kind_str(&DetectedCaptcha::ShadowDomCaptcha),
Some("shadow_dom_captcha")
);
assert_eq!(
solver_kind_str(&DetectedCaptcha::MultiStepCaptcha),
Some("multi_step_captcha")
);
assert_eq!(solver_kind_str(&DetectedCaptcha::None), None);
}
#[test]
fn detected_captcha_serde_roundtrip_all_variants() {
for variant in [
DetectedCaptcha::Turnstile,
DetectedCaptcha::RecaptchaV2,
DetectedCaptcha::RecaptchaV3,
DetectedCaptcha::HCaptcha,
DetectedCaptcha::ImageCaptcha,
DetectedCaptcha::AudioCaptcha,
DetectedCaptcha::PowCaptcha,
DetectedCaptcha::SliderCaptcha,
DetectedCaptcha::CanvasCaptcha,
DetectedCaptcha::ShadowDomCaptcha,
DetectedCaptcha::MultiStepCaptcha,
DetectedCaptcha::None,
] {
let json = serde_json::to_string(&variant).unwrap();
let back: DetectedCaptcha = serde_json::from_str(&json).unwrap();
assert_eq!(variant, back, "roundtrip failed for {:?}", variant);
}
}
#[test]
fn captcha_info_serde_roundtrip() {
let info = CaptchaInfo {
kind: DetectedCaptcha::Turnstile,
site_key: Some("key".into()),
page_url: "https://example.com".into(),
container_selector: Some(".cf".into()),
};
let json = serde_json::to_string(&info).unwrap();
let back: CaptchaInfo = serde_json::from_str(&json).unwrap();
assert_eq!(info.kind, back.kind);
assert_eq!(info.site_key, back.site_key);
assert_eq!(info.page_url, back.page_url);
assert_eq!(info.container_selector, back.container_selector);
}
#[test]
fn captcha_info_serde_with_null_site_key() {
let info = CaptchaInfo {
kind: DetectedCaptcha::None,
site_key: None,
page_url: "https://example.com".into(),
container_selector: None,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("null"));
let back: CaptchaInfo = serde_json::from_str(&json).unwrap();
assert!(back.site_key.is_none());
}
#[test]
fn challenge_page_detector_name_and_priority() {
let d = ChallengePageDetector;
assert_eq!(d.name(), "challenge_page");
assert_eq!(d.priority(), 5);
}
#[test]
fn turnstile_detector_name_and_priority() {
let d = TurnstileDetector;
assert_eq!(d.name(), "turnstile");
assert_eq!(d.priority(), 10);
}
#[test]
fn recaptcha_detector_name_and_priority() {
let d = RecaptchaDetector;
assert_eq!(d.name(), "recaptcha");
assert_eq!(d.priority(), 20);
}
#[test]
fn hcaptcha_detector_name_and_priority() {
let d = HCaptchaDetector;
assert_eq!(d.name(), "hcaptcha");
assert_eq!(d.priority(), 30);
}
#[test]
fn image_captcha_detector_name_and_priority() {
let d = ImageCaptchaDetector;
assert_eq!(d.name(), "image_captcha");
assert_eq!(d.priority(), 40);
}
#[test]
fn audio_captcha_detector_name_and_priority() {
let d = AudioCaptchaDetector;
assert_eq!(d.name(), "audio_captcha");
assert_eq!(d.priority(), 50);
}
#[test]
fn pow_captcha_detector_name_and_priority() {
let d = PowCaptchaDetector;
assert_eq!(d.name(), "pow_captcha");
assert_eq!(d.priority(), 60);
}
#[test]
fn canvas_captcha_detector_name_and_priority() {
let d = CanvasCaptchaDetector;
assert_eq!(d.name(), "canvas_captcha");
assert_eq!(d.priority(), 65);
}
#[test]
fn slider_captcha_detector_name_and_priority() {
let d = SliderCaptchaDetector;
assert_eq!(d.name(), "slider_captcha");
assert_eq!(d.priority(), 75);
}
#[test]
fn multi_step_captcha_detector_name_and_priority() {
let d = MultiStepCaptchaDetector;
assert_eq!(d.name(), "multi_step_captcha");
assert_eq!(d.priority(), 85);
}
#[test]
fn shadow_dom_detector_name_and_priority() {
let d = ShadowDomDetector;
assert_eq!(d.name(), "shadow_dom");
assert_eq!(d.priority(), 90);
}
#[test]
fn detector_registry_runs_in_priority_order() {
let registry = DetectorRegistry::new();
let priorities: Vec<i32> = registry.detectors.iter().map(|d| d.priority()).collect();
let mut sorted = priorities.clone();
sorted.sort_unstable();
assert_eq!(priorities, sorted);
}
#[test]
fn detector_registry_contains_all_detectors() {
let registry = DetectorRegistry::new();
let names: Vec<&str> = registry.detectors.iter().map(|d| d.name()).collect();
assert!(names.contains(&"challenge_page"));
assert!(names.contains(&"turnstile"));
assert!(names.contains(&"recaptcha"));
assert!(names.contains(&"hcaptcha"));
assert!(names.contains(&"image_captcha"));
assert!(names.contains(&"audio_captcha"));
assert!(names.contains(&"pow_captcha"));
assert!(names.contains(&"slider_captcha"));
assert!(names.contains(&"canvas_captcha"));
assert!(names.contains(&"multi_step_captcha"));
assert!(names.contains(&"shadow_dom"));
}
#[test]
fn parse_detection_result_returns_none_for_null() {
assert!(parse_detection_result(serde_json::Value::Null).is_none());
}
#[test]
fn parse_detection_result_returns_none_for_unknown_kind() {
let val = serde_json::json!({"kind": "unknown_xyz", "site_key": null, "container": null});
assert!(parse_detection_result(val).is_none());
}
#[test]
fn parse_detection_result_parses_all_kinds() {
let cases = [
("turnstile", DetectedCaptcha::Turnstile),
("recaptcha_v2", DetectedCaptcha::RecaptchaV2),
("recaptcha_v3", DetectedCaptcha::RecaptchaV3),
("hcaptcha", DetectedCaptcha::HCaptcha),
("image", DetectedCaptcha::ImageCaptcha),
("audio", DetectedCaptcha::AudioCaptcha),
("pow", DetectedCaptcha::PowCaptcha),
("slider", DetectedCaptcha::SliderCaptcha),
("canvas", DetectedCaptcha::CanvasCaptcha),
("shadow_dom", DetectedCaptcha::ShadowDomCaptcha),
("multi_step", DetectedCaptcha::MultiStepCaptcha),
];
for (kind_str, expected) in cases {
let val = serde_json::json!({
"kind": kind_str,
"site_key": "test_key",
"container": ".test"
});
let info = parse_detection_result(val).unwrap();
assert_eq!(info.kind, expected, "kind mismatch for {}", kind_str);
assert_eq!(info.site_key, Some("test_key".to_string()));
assert_eq!(info.container_selector, Some(".test".to_string()));
}
}
}