use super::*;
use crate::captcha_detect::DetectedCaptcha;
use rand::{Rng, SeedableRng};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy)]
pub struct SliderSelectors {
pub canvas: &'static str,
pub handle: &'static str,
pub success_marker: Option<&'static str>,
}
const GAP_DETECT_JS: &str = r#"
(canvasSelector) => {
const canvas = document.querySelector(canvasSelector);
if (!canvas || !(canvas instanceof HTMLCanvasElement)) return null;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
let img;
try { img = ctx.getImageData(0, 0, canvas.width, canvas.height); }
catch (e) { return null; /* CORS-tainted */ }
const w = img.width, h = img.height, data = img.data;
/* Per-column average luminance, then find the column with the
largest delta vs its neighbour (the gap edge). */
const lum = new Float32Array(w);
for (let x = 0; x < w; x++) {
let sum = 0;
for (let y = 0; y < h; y++) {
const i = (y * w + x) * 4;
sum += 0.299 * data[i] + 0.587 * data[i+1] + 0.114 * data[i+2];
}
lum[x] = sum / h;
}
let bestX = -1, bestDelta = 0;
for (let x = 1; x < w - 1; x++) {
const d = Math.abs(lum[x] - lum[x-1]) + Math.abs(lum[x+1] - lum[x]);
if (d > bestDelta) { bestDelta = d; bestX = x; }
}
if (bestX < 0 || bestDelta < 6) return null; /* too noisy */
return { gapX: bestX, canvasWidth: w };
}
"#;
const ANCHORS_JS: &str = r#"
(canvasSelector, handleSelector) => {
const canvas = document.querySelector(canvasSelector);
const handle = document.querySelector(handleSelector);
if (!canvas || !handle) return null;
const c = canvas.getBoundingClientRect();
const h = handle.getBoundingClientRect();
return {
canvasLeft: c.left, canvasTop: c.top,
canvasWidth: c.width, canvasHeight: c.height,
handleX: h.left + h.width / 2,
handleY: h.top + h.height / 2,
handleWidth: h.width
};
}
"#;
pub struct SliderCaptchaSolver {
selectors: Vec<SliderSelectors>,
}
impl Default for SliderCaptchaSolver {
fn default() -> Self {
Self::new()
}
}
impl SliderCaptchaSolver {
pub fn new() -> Self {
Self {
selectors: vec![
SliderSelectors {
canvas: ".geetest_canvas_slice",
handle: ".geetest_slider_button",
success_marker: Some(".geetest_success"),
},
SliderSelectors {
canvas: ".geetest_item_img",
handle: ".geetest_btn",
success_marker: Some(".geetest_success_radar_tip"),
},
SliderSelectors {
canvas: "#captcha__puzzle",
handle: "#sliderIcon",
success_marker: None,
},
SliderSelectors {
canvas: ".px-captcha-puzzle",
handle: ".px-captcha-slider",
success_marker: Some(".px-captcha-success"),
},
SliderSelectors {
canvas: "[data-puzzle-canvas]",
handle: "[data-puzzle-slider]",
success_marker: None,
},
SliderSelectors {
canvas: ".slider-captcha-canvas",
handle: ".slider-captcha-handle",
success_marker: Some(".slider-captcha-success"),
},
],
}
}
pub fn with_selectors(mut self, selectors: Vec<SliderSelectors>) -> Self {
self.selectors = selectors;
self
}
}
#[derive(Debug, serde::Deserialize)]
struct GapResult {
#[serde(rename = "gapX")]
gap_x: f64,
#[serde(rename = "canvasWidth")]
canvas_width: f64,
}
#[derive(Debug, serde::Deserialize)]
struct Anchors {
#[serde(rename = "canvasLeft")]
canvas_left: f64,
#[serde(rename = "handleX")]
handle_x: f64,
#[serde(rename = "handleY")]
handle_y: f64,
#[serde(rename = "handleWidth")]
handle_width: f64,
#[serde(rename = "canvasWidth")]
canvas_width: f64,
}
#[async_trait]
impl CaptchaSolver for SliderCaptchaSolver {
fn name(&self) -> &'static str {
"SliderCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::BehavioralBypass
}
fn supports(&self, kind: &DetectedCaptcha) -> bool {
match kind {
DetectedCaptcha::SliderCaptcha => true,
DetectedCaptcha::Custom(name) => matches!(
name.as_str(),
"datadome"
| "geetest_v3"
| "geetest_v4"
| "perimeterx_human"
| "aws_waf_captcha"
| "akamai_bot_manager"
),
_ => false,
}
}
async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
for sel in &self.selectors {
let gap_js = format!(
"({})({})",
GAP_DETECT_JS,
serde_json::to_string(sel.canvas).unwrap_or_else(|_| "\"\"".into())
);
let gap_raw = match page.evaluate(gap_js).await {
Ok(r) => r,
Err(_) => continue,
};
let gap: Option<GapResult> = gap_raw.into_value().ok().flatten();
let Some(gap) = gap else { continue };
let anchors_js = format!(
"({})({},{})",
ANCHORS_JS,
serde_json::to_string(sel.canvas).unwrap_or_else(|_| "\"\"".into()),
serde_json::to_string(sel.handle).unwrap_or_else(|_| "\"\"".into())
);
let anchors_raw = match page.evaluate(anchors_js).await {
Ok(r) => r,
Err(_) => continue,
};
let anchors: Option<Anchors> = anchors_raw.into_value().ok().flatten();
let Some(anchors) = anchors else { continue };
let scale = anchors.canvas_width / gap.canvas_width.max(1.0);
let target_x = anchors.canvas_left + gap.gap_x * scale - anchors.handle_width / 2.0;
drag_handle(
page,
anchors.handle_x,
anchors.handle_y,
target_x,
anchors.handle_y,
)
.await?;
if let Some(marker) = sel.success_marker {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
let exists_js = format!(
"(() => !!document.querySelector({}))()",
serde_json::to_string(marker).unwrap_or_else(|_| "\"\"".into())
);
if let Ok(raw) = page.evaluate(exists_js).await {
if raw.into_value::<bool>().unwrap_or(false) {
break;
}
}
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: format!("slider-drag-{}", gap.gap_x as i64),
confidence: 0.8,
method: self.method(),
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
});
}
Ok(CaptchaSolveResult::failure(
self.method(),
t0.elapsed().as_millis() as u64,
))
}
}
async fn drag_handle(page: &Page, x0: f64, y0: f64, x1: f64, y1: f64) -> Result<()> {
let mut rng = rand::rngs::StdRng::from_entropy();
let _ = page
.evaluate(format!(
"(() => {{ const e = new MouseEvent('mousedown', {{ \
bubbles: true, button: 0, clientX: {x}, clientY: {y} \
}}); document.elementFromPoint({x}, {y})?.dispatchEvent(e); }})()",
x = x0,
y = y0
))
.await;
let overshoot = rng.gen_range(8.0..18.0);
let cx1 = x0 + (x1 - x0) * 0.3 + rng.gen_range(-15.0..15.0);
let cy1 = y0 + rng.gen_range(-10.0..10.0);
let cx2 = x0 + (x1 - x0) * 0.7 + rng.gen_range(-15.0..15.0);
let cy2 = y1 + rng.gen_range(-10.0..10.0);
let steps = 28;
for i in 0..=steps {
let t = i as f64 / steps as f64;
let omt = 1.0 - t;
let bx = omt.powi(3) * x0
+ 3.0 * omt.powi(2) * t * cx1
+ 3.0 * omt * t.powi(2) * cx2
+ t.powi(3) * (x1 + overshoot);
let by = omt.powi(3) * y0
+ 3.0 * omt.powi(2) * t * cy1
+ 3.0 * omt * t.powi(2) * cy2
+ t.powi(3) * y1;
let _ = page
.evaluate(format!(
"(() => {{ const e = new MouseEvent('mousemove', {{ \
bubbles: true, button: 0, clientX: {x}, clientY: {y} \
}}); document.elementFromPoint({x}, {y})?.dispatchEvent(e); }})()",
x = bx,
y = by
))
.await;
tokio::time::sleep(Duration::from_millis(rng.gen_range(8..18))).await;
}
for i in 0..6 {
let t = i as f64 / 5.0;
let bx = x1 + overshoot * (1.0 - t);
let _ = page
.evaluate(format!(
"(() => {{ const e = new MouseEvent('mousemove', {{ \
bubbles: true, button: 0, clientX: {x}, clientY: {y} \
}}); document.elementFromPoint({x}, {y})?.dispatchEvent(e); }})()",
x = bx,
y = y1
))
.await;
tokio::time::sleep(Duration::from_millis(rng.gen_range(20..40))).await;
}
let _ = page
.evaluate(format!(
"(() => {{ const e = new MouseEvent('mouseup', {{ \
bubbles: true, button: 0, clientX: {x}, clientY: {y} \
}}); document.elementFromPoint({x}, {y})?.dispatchEvent(e); }})()",
x = x1,
y = y1
))
.await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_cover_major_vendors() {
let s = SliderCaptchaSolver::new();
let canvases: Vec<_> = s.selectors.iter().map(|x| x.canvas).collect();
for needle in [
".geetest_canvas_slice",
"#captcha__puzzle",
".px-captcha-puzzle",
"[data-puzzle-canvas]",
] {
assert!(
canvases.contains(&needle),
"default selectors must include: {needle}"
);
}
}
#[test]
fn supports_slider_and_known_vendors() {
let s = SliderCaptchaSolver::new();
assert!(s.supports(&DetectedCaptcha::SliderCaptcha));
assert!(s.supports(&DetectedCaptcha::Custom("datadome".into())));
assert!(s.supports(&DetectedCaptcha::Custom("geetest_v3".into())));
assert!(s.supports(&DetectedCaptcha::Custom("perimeterx_human".into())));
assert!(s.supports(&DetectedCaptcha::Custom("aws_waf_captcha".into())));
assert!(!s.supports(&DetectedCaptcha::Custom("friendly_captcha".into())));
assert!(!s.supports(&DetectedCaptcha::Turnstile));
}
#[test]
fn name_and_method_stable() {
let s = SliderCaptchaSolver::new();
assert_eq!(s.name(), "SliderCaptchaSolver");
assert_eq!(s.method(), SolveMethod::BehavioralBypass);
}
#[test]
fn with_selectors_overrides_default() {
let custom = SliderSelectors {
canvas: ".my-canvas",
handle: ".my-handle",
success_marker: None,
};
let s = SliderCaptchaSolver::new().with_selectors(vec![custom]);
assert_eq!(s.selectors.len(), 1);
assert_eq!(s.selectors[0].canvas, ".my-canvas");
}
#[test]
fn gap_detect_js_uses_canvas_imagedata() {
assert!(GAP_DETECT_JS.contains("getImageData"));
assert!(GAP_DETECT_JS.contains("HTMLCanvasElement"));
}
#[test]
fn anchors_js_returns_handle_and_canvas_geometry() {
assert!(ANCHORS_JS.contains("getBoundingClientRect"));
assert!(ANCHORS_JS.contains("handleX"));
assert!(ANCHORS_JS.contains("canvasLeft"));
}
}