use anyhow::Result;
use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, 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")]
pub enum DetectedCaptcha {
Turnstile,
RecaptchaV2,
RecaptchaV3,
#[serde(rename = "hcaptcha")]
HCaptcha,
ImageCaptcha,
AudioCaptcha,
None,
}
const DETECT_JS: &str = r#"(function() {
const result = { kind: 'none', site_key: null, container: null };
// Cloudflare Turnstile
const turnstile = document.querySelector('.cf-turnstile, [data-turnstile-sitekey]');
if (turnstile) {
result.kind = 'turnstile';
result.site_key = turnstile.getAttribute('data-sitekey') ||
turnstile.getAttribute('data-turnstile-sitekey');
result.container = '.cf-turnstile';
return result;
}
// Also check for Turnstile script
const turnstileScript = document.querySelector('script[src*="challenges.cloudflare.com"]');
if (turnstileScript) {
const cfEl = document.querySelector('[data-sitekey]');
if (cfEl) {
result.kind = 'turnstile';
result.site_key = cfEl.getAttribute('data-sitekey');
result.container = '[data-sitekey]';
return result;
}
}
// reCAPTCHA v2/v3
const recaptcha = document.querySelector('.g-recaptcha, [data-sitekey]');
const recaptchaScript = document.querySelector('script[src*="google.com/recaptcha"]');
if (recaptcha || recaptchaScript) {
const el = recaptcha || document.querySelector('[data-sitekey]');
if (el) {
const size = el.getAttribute('data-size');
result.kind = size === 'invisible' ? 'recaptcha_v3' : 'recaptcha_v2';
result.site_key = el.getAttribute('data-sitekey');
result.container = '.g-recaptcha';
return result;
}
// v3 invisible might not have a visible element
result.kind = 'recaptcha_v3';
return result;
}
// hCaptcha
const hcaptcha = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]');
const hcaptchaScript = document.querySelector('script[src*="hcaptcha.com"]');
if (hcaptcha || hcaptchaScript) {
const el = hcaptcha || document.querySelector('[data-sitekey]');
result.kind = 'hcaptcha';
if (el) {
result.site_key = el.getAttribute('data-sitekey');
result.container = '.h-captcha';
}
return result;
}
// Generic image CAPTCHA (img inside known captcha container)
const imgCaptcha = document.querySelector(
'img[src*="captcha"], img[src*="Captcha"], .captcha img, #captcha img'
);
if (imgCaptcha) {
result.kind = 'image';
result.container = '.captcha, #captcha';
return result;
}
// Generic audio CAPTCHA
const audioCaptcha = document.querySelector(
'audio[src*="captcha"], .audio-captcha, #audio-captcha'
);
if (audioCaptcha) {
result.kind = 'audio';
result.container = '.audio-captcha, #audio-captcha';
return result;
}
// Cloudflare challenge page (bot detection interstitial)
if (document.title.includes('Just a moment') ||
document.title.includes('Attention Required') ||
document.querySelector('#challenge-running, #challenge-form, .cf-browser-verification')) {
result.kind = 'turnstile';
result.container = '#challenge-form';
return result;
}
return result;
})()"#;
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 raw = page.evaluate(DETECT_JS).await?;
let val = raw.into_value::<serde_json::Value>()?;
let kind_str = val["kind"].as_str().unwrap_or("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,
_ => DetectedCaptcha::None,
};
Ok(CaptchaInfo {
kind,
site_key: val["site_key"].as_str().map(String::from),
page_url,
container_selector: val["container"].as_str().map(String::from),
})
}
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"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_js_checks_turnstile() {
assert!(DETECT_JS.contains("cf-turnstile"));
assert!(DETECT_JS.contains("challenges.cloudflare.com"));
}
#[test]
fn detect_js_checks_recaptcha() {
assert!(DETECT_JS.contains("g-recaptcha"));
assert!(DETECT_JS.contains("google.com/recaptcha"));
}
#[test]
fn detect_js_checks_hcaptcha() {
assert!(DETECT_JS.contains("h-captcha"));
assert!(DETECT_JS.contains("hcaptcha.com"));
}
#[test]
fn detect_js_checks_challenge_page() {
assert!(DETECT_JS.contains("Just a moment"));
assert!(DETECT_JS.contains("challenge-running"));
}
#[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_turnstile() {
let info = CaptchaInfo {
kind: DetectedCaptcha::Turnstile,
site_key: Some("key".into()),
page_url: String::new(),
container_selector: None,
};
assert!(is_captcha(&info));
}
#[test]
fn solver_kind_str_maps_correctly() {
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::HCaptcha),
Some("hcaptcha")
);
assert_eq!(solver_kind_str(&DetectedCaptcha::None), None);
}
#[test]
fn detect_js_checks_image_captcha() {
assert!(DETECT_JS.contains("img[src*=\"captcha\"]"));
assert!(DETECT_JS.contains("'image'"));
}
#[test]
fn detect_js_checks_audio_captcha() {
assert!(DETECT_JS.contains("audio[src*=\"captcha\"]"));
assert!(DETECT_JS.contains("'audio'"));
}
}