use super::super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::{Duration, Instant};
const DEFAULT_MAX_WAIT_MS: u64 = 12_000;
pub struct GeeTestSolver {
max_wait_ms: u64,
}
impl Default for GeeTestSolver {
fn default() -> Self {
Self::new()
}
}
impl GeeTestSolver {
pub fn new() -> Self {
Self {
max_wait_ms: DEFAULT_MAX_WAIT_MS,
}
}
pub fn with_max_wait_ms(mut self, ms: u64) -> Self {
self.max_wait_ms = ms;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeeTestProtocol {
V3,
V4,
}
const PHASE_PROBE_JS: &str = r#"
(() => {
/* v4 token quad, read from window.captcha4Result (custom
integration), hidden inputs (the most common shape), or the
documented onSuccess callback payload that sites usually
stash on window. */
const readField = (sel) => {
const el = document.querySelector(sel);
return el ? (el.value || el.getAttribute('value') || '') : '';
};
const v4 = {
lot_number: readField('input[name="lot_number"], input[name="geetest_lot_number"]') || (window.captcha4Result && window.captcha4Result.lot_number) || '',
pass_token: readField('input[name="pass_token"], input[name="geetest_pass_token"]') || (window.captcha4Result && window.captcha4Result.pass_token) || '',
gen_time: readField('input[name="gen_time"], input[name="geetest_gen_time"]') || (window.captcha4Result && window.captcha4Result.gen_time) || '',
captcha_output: readField('input[name="captcha_output"], input[name="geetest_captcha_output"]') || (window.captcha4Result && window.captcha4Result.captcha_output) || '',
};
if (v4.lot_number && v4.pass_token && v4.captcha_output) {
return { phase: 'v4_passed', quad: v4 };
}
/* v3 token triple, the canonical hidden inputs, also commonly
window.geetest_validate etc. */
const v3 = {
challenge: readField('input[name="geetest_challenge"]') || window.geetest_challenge || '',
validate: readField('input[name="geetest_validate"]') || window.geetest_validate || '',
seccode: readField('input[name="geetest_seccode"]') || window.geetest_seccode || '',
};
if (v3.challenge && v3.validate && v3.seccode) {
return { phase: 'v3_passed', triple: v3 };
}
/* Detect which version is on the page so the host can pick the
right wait/interaction strategy. */
const v4Present =
!!document.querySelector('.geetest_box, .geetest_panel, [data-gt]') ||
typeof window.initGeetest4 !== 'undefined';
if (v4Present) return { phase: 'v4_pending' };
const v3Present =
!!document.querySelector('.geetest_holder, .geetest_radar_btn, .geetest_slider_button') ||
typeof window.initGeetest !== 'undefined';
if (v3Present) return { phase: 'v3_pending' };
return { phase: 'unknown' };
})()
"#;
#[derive(Debug, serde::Deserialize)]
struct PhaseProbe {
phase: String,
#[serde(default)]
triple: Option<V3Triple>,
#[serde(default)]
quad: Option<V4Quad>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
#[allow(non_snake_case)]
struct V3Triple {
challenge: String,
validate: String,
seccode: String,
}
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
#[allow(non_snake_case)]
struct V4Quad {
lot_number: String,
pass_token: String,
gen_time: String,
captcha_output: String,
}
pub fn is_valid_v3_triple(challenge: &str, validate: &str, seccode: &str) -> bool {
if challenge.is_empty() || validate.is_empty() || seccode.is_empty() {
return false;
}
seccode.contains('|') || seccode.chars().filter(|c| c.is_ascii_hexdigit()).count() >= 32
}
pub fn is_valid_v4_quad(
lot_number: &str,
pass_token: &str,
gen_time: &str,
captcha_output: &str,
) -> bool {
if lot_number.is_empty()
|| pass_token.is_empty()
|| gen_time.is_empty()
|| captcha_output.is_empty()
{
return false;
}
let Ok(t) = gen_time.trim().parse::<u64>() else {
return false;
};
(1_577_836_800..4_102_444_800).contains(&t)
}
#[async_trait]
impl CaptchaSolver for GeeTestSolver {
fn name(&self) -> &'static str {
"GeeTestSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::AutoPass
}
fn supports(&self, kind: &DetectedCaptcha) -> bool {
matches!(
kind,
DetectedCaptcha::Custom(_) | DetectedCaptcha::SliderCaptcha
)
}
async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
let t0 = Instant::now();
let deadline = t0 + Duration::from_millis(self.max_wait_ms);
loop {
let raw = page
.evaluate(PHASE_PROBE_JS)
.await
.map_err(|e| anyhow!("GeeTest probe failed: {e}"))?;
let probe: PhaseProbe = match raw.into_value() {
Ok(p) => p,
Err(_) => PhaseProbe {
phase: "unknown".into(),
triple: None,
quad: None,
},
};
match probe.phase.as_str() {
"v3_passed" => {
if let Some(t) = probe.triple.as_ref() {
if is_valid_v3_triple(&t.challenge, &t.validate, &t.seccode) {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: serde_json::to_string(t).unwrap_or_default(),
confidence: 1.0,
method: SolveMethod::AutoPass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
}
}
"v4_passed" => {
if let Some(q) = probe.quad.as_ref() {
if is_valid_v4_quad(
&q.lot_number,
&q.pass_token,
&q.gen_time,
&q.captcha_output,
) {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: serde_json::to_string(q).unwrap_or_default(),
confidence: 1.0,
method: SolveMethod::AutoPass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
}
}
_ => {} }
if Instant::now() >= deadline {
return Ok(CaptchaSolveResult::failure(
SolveMethod::AutoPass,
t0.elapsed().as_millis() as u64,
));
}
tokio::time::sleep(Duration::from_millis(400)).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_sane() {
assert_eq!(GeeTestSolver::new().max_wait_ms, 12_000);
}
#[test]
fn builders_override() {
assert_eq!(
GeeTestSolver::new().with_max_wait_ms(5_000).max_wait_ms,
5_000
);
}
#[test]
fn name_method_stable() {
let s = GeeTestSolver::new();
assert_eq!(s.name(), "GeeTestSolver");
assert_eq!(s.method(), SolveMethod::AutoPass);
}
#[test]
fn supports_custom_and_slider() {
let s = GeeTestSolver::new();
assert!(s.supports(&DetectedCaptcha::Custom("geetest_v3".into())));
assert!(s.supports(&DetectedCaptcha::Custom("geetest_v4".into())));
assert!(s.supports(&DetectedCaptcha::SliderCaptcha));
assert!(!s.supports(&DetectedCaptcha::Turnstile));
assert!(!s.supports(&DetectedCaptcha::HCaptcha));
assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
}
#[test]
fn is_valid_v3_triple_rejects_empty() {
assert!(!is_valid_v3_triple("", "v", "abc|jordan"));
assert!(!is_valid_v3_triple("c", "", "abc|jordan"));
assert!(!is_valid_v3_triple("c", "v", ""));
}
#[test]
fn is_valid_v3_triple_accepts_pipe_seccode() {
assert!(is_valid_v3_triple(
"c",
"v",
"0123456789abcdef0123456789abcdef|jordan"
));
}
#[test]
fn is_valid_v3_triple_accepts_hex_only_seccode() {
assert!(is_valid_v3_triple(
"c",
"v",
"0123456789abcdef0123456789abcdef"
));
}
#[test]
fn is_valid_v3_triple_rejects_non_hex_short_seccode() {
assert!(!is_valid_v3_triple("c", "v", "deadbeef"));
}
#[test]
fn is_valid_v4_quad_rejects_empty() {
assert!(!is_valid_v4_quad("", "p", "1736899200", "{}"));
assert!(!is_valid_v4_quad("l", "", "1736899200", "{}"));
assert!(!is_valid_v4_quad("l", "p", "", "{}"));
assert!(!is_valid_v4_quad("l", "p", "1736899200", ""));
}
#[test]
fn is_valid_v4_quad_rejects_unparseable_gen_time() {
assert!(!is_valid_v4_quad("l", "p", "not-a-timestamp", "{}"));
assert!(!is_valid_v4_quad("l", "p", "abc", "{}"));
}
#[test]
fn is_valid_v4_quad_rejects_out_of_range_gen_time() {
assert!(!is_valid_v4_quad("l", "p", "1000000000", "{}")); assert!(!is_valid_v4_quad("l", "p", "5000000000", "{}")); }
#[test]
fn is_valid_v4_quad_accepts_real_shape() {
assert!(is_valid_v4_quad(
"ed8a6a87aaad4d3582a5af33d8a89aef",
"f01d6049ce17ce0b08fa5b3c1c5d3a8c",
"1736899200",
"{\"captcha_id\":\"f01d6049ce17ce0b08fa5b3c1c5d3a8c\"}",
));
}
#[test]
fn phase_probe_js_covers_documented_signals() {
for needle in [
"geetest_challenge",
"geetest_validate",
"geetest_seccode",
"lot_number",
"pass_token",
"gen_time",
"captcha_output",
"geetest_box",
"geetest_holder",
"initgeetest",
] {
assert!(
PHASE_PROBE_JS.to_lowercase().contains(needle),
"PHASE_PROBE_JS must reference: {needle}"
);
}
}
#[test]
fn phase_probe_js_emits_canonical_phases() {
for phase in [
"v3_passed",
"v4_passed",
"v3_pending",
"v4_pending",
"unknown",
] {
assert!(
PHASE_PROBE_JS.contains(&format!("'{phase}'")),
"PHASE_PROBE_JS must emit phase: {phase}"
);
}
}
}