use crate::Page;
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
mod audio_captcha;
pub mod block_response;
mod canvas_captcha;
mod challenge_page;
mod hcaptcha;
mod image_captcha;
mod math_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 math_captcha::MathCaptchaDetector;
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, Eq)]
#[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(MathCaptchaDetector),
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 mount = document.querySelector('#cf-turnstile, #cf-turnstile-mount, [id*="turnstile"]');
const cfEl = document.querySelector('[data-sitekey]');
if (mount || cfEl) {
return {
kind: 'turnstile',
site_key: cfEl ? cfEl.getAttribute('data-sitekey') : null,
container: mount ? ('#' + mount.id) : (cfEl ? '[data-sitekey]' : null)
};
}
return { kind: 'turnstile', site_key: null, container: null };
}
}
// --- 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)]
#[path = "detect/tests.rs"]
mod tests;