use super::super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::{Duration, Instant};
const DEFAULT_MAX_WAIT_MS: u64 = 20_000;
pub struct PerimeterXSolver {
max_wait_ms: u64,
}
impl Default for PerimeterXSolver {
fn default() -> Self {
Self::new()
}
}
impl PerimeterXSolver {
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
}
}
pub fn classify_px_cookie(value: &str) -> PxCookie {
if value.is_empty() {
return PxCookie::Missing;
}
let trimmed = value.trim_matches('"');
if trimmed.is_empty() {
return PxCookie::Missing;
}
if trimmed.len() < 30 {
return PxCookie::Pending;
}
if !trimmed.contains(':') && !trimmed.contains('_') {
return PxCookie::Pending;
}
PxCookie::Valid
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PxCookie {
Missing,
Pending,
Valid,
}
const PHASE_PROBE_JS: &str = r#"
(() => {
const title = (document.title || '').toLowerCase();
const body = document.body ? (document.body.innerText || '').toLowerCase() : '';
/* Block page detection — PX uses a specific banner with a
reference code formatted "Reference ID:". */
if (
title.includes('access denied') ||
body.includes('blocked') && (body.includes('reference id') || body.includes('px'))
) {
const m = body.match(/reference id[:\s]+([a-z0-9-]+)/i);
return { phase: 'blocked', ref_code: m ? m[1] : '' };
}
/* Press-and-hold widget. PX's distinctive UX. */
if (document.querySelector('#px-captcha, .px-captcha-error-container, [data-px-captcha]')) {
return { phase: 'press_and_hold' };
}
/* Cookie pass — check both v3 and legacy device-history cookies. */
let px3 = '', pxhd = '';
for (const c of (document.cookie || '').split(';')) {
const t = c.trim();
if (t.startsWith('_px3=')) { px3 = t.slice('_px3='.length); }
else if (t.startsWith('_pxhd=')) { pxhd = t.slice('_pxhd='.length); }
}
/* Prefer _px3 (v3 protocol); fall back to _pxhd (device-history,
still issued on legacy tenants). */
const cookie = px3 || pxhd;
if (cookie) return { phase: 'passed', cookie };
/* Sensor script presence — PX has used several CDN domains. */
const sensorPresent =
!!document.querySelector('script[src*="client.px-cdn.net"], script[src*="captcha.px-cdn.net"], script[src*="human-security.com"]') ||
typeof window._pxAppId !== 'undefined' ||
typeof window.PX !== 'undefined' ||
typeof window._humanSecurity !== 'undefined';
if (sensorPresent) return { phase: 'interstitial' };
return { phase: 'unknown' };
})()
"#;
#[derive(Debug, serde::Deserialize)]
struct PhaseProbe {
phase: String,
#[serde(default)]
cookie: String,
#[serde(default)]
#[serde(rename = "ref_code")]
_ref_code: String,
}
#[async_trait]
impl CaptchaSolver for PerimeterXSolver {
fn name(&self) -> &'static str {
"PerimeterXSolver"
}
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!("PerimeterX probe failed: {e}"))?;
let probe: PhaseProbe = match raw.into_value() {
Ok(p) => p,
Err(_) => PhaseProbe {
phase: "interstitial".into(),
cookie: String::new(),
_ref_code: String::new(),
},
};
match probe.phase.as_str() {
"passed" if classify_px_cookie(&probe.cookie) == PxCookie::Valid => {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: "perimeterx:_px3".into(),
confidence: 1.0,
method: SolveMethod::AutoPass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
"passed" => {
}
"press_and_hold" => {
let shot = super::super::screenshot_b64(page).await.ok();
return Ok(CaptchaSolveResult {
solution: String::new(),
confidence: 0.0,
method: SolveMethod::AutoPass,
time_ms: t0.elapsed().as_millis() as u64,
success: false,
screenshot: shot,
cookies: Vec::new(),
verified_outcome: None,
});
}
"blocked" => {
return Ok(CaptchaSolveResult::failure(
SolveMethod::AutoPass,
t0.elapsed().as_millis() as u64,
));
}
_ => {} }
if Instant::now() >= deadline {
return Ok(CaptchaSolveResult::failure(
SolveMethod::AutoPass,
t0.elapsed().as_millis() as u64,
));
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_sane() {
assert_eq!(PerimeterXSolver::new().max_wait_ms, 20_000);
}
#[test]
fn builders_override() {
assert_eq!(
PerimeterXSolver::new().with_max_wait_ms(8_000).max_wait_ms,
8_000
);
}
#[test]
fn name_method_stable() {
let s = PerimeterXSolver::new();
assert_eq!(s.name(), "PerimeterXSolver");
assert_eq!(s.method(), SolveMethod::AutoPass);
}
#[test]
fn supports_custom_and_slider() {
let s = PerimeterXSolver::new();
assert!(s.supports(&DetectedCaptcha::Custom("perimeterx_human".into())));
assert!(s.supports(&DetectedCaptcha::SliderCaptcha));
assert!(!s.supports(&DetectedCaptcha::Turnstile));
assert!(!s.supports(&DetectedCaptcha::HCaptcha));
assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
}
#[test]
fn classify_px_cookie_empty_is_missing() {
assert_eq!(classify_px_cookie(""), PxCookie::Missing);
assert_eq!(classify_px_cookie("\"\""), PxCookie::Missing);
}
#[test]
fn classify_px_cookie_short_is_pending() {
assert_eq!(classify_px_cookie("short"), PxCookie::Pending);
assert_eq!(classify_px_cookie("aaaaaaaaaaaaaaaaaaaaaaaaaaa"), PxCookie::Pending); }
#[test]
fn classify_px_cookie_long_no_separator_is_pending() {
let s = "a".repeat(40);
assert_eq!(classify_px_cookie(&s), PxCookie::Pending);
}
#[test]
fn classify_px_cookie_v3_shape_is_valid() {
let real = "AbCdEfGh:0:abcdefghijklmnopqrstuvwxyz0123456789ABCDEF:";
assert_eq!(classify_px_cookie(real), PxCookie::Valid);
}
#[test]
fn classify_px_cookie_legacy_device_history_is_valid() {
let legacy = "abcdefghijklmnopqrstuvwxyz0123456789_abcdefghijklmn";
assert_eq!(classify_px_cookie(legacy), PxCookie::Valid);
}
#[test]
fn classify_px_cookie_quoted_value() {
let real = "\"AbCdEfGh:0:abcdefghijklmnopqrstuvwxyz0123456789:\"";
assert_eq!(classify_px_cookie(real), PxCookie::Valid);
}
#[test]
fn phase_probe_js_covers_documented_signals() {
for needle in [
"_px3=",
"_pxhd=",
"client.px-cdn.net",
"captcha.px-cdn.net",
"human-security.com",
"_pxappid",
"_humansecurity",
"px-captcha",
"press_and_hold",
] {
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 ["blocked", "press_and_hold", "interstitial", "passed", "unknown"] {
assert!(
PHASE_PROBE_JS.contains(&format!("'{phase}'")),
"PHASE_PROBE_JS must emit phase: {phase}"
);
}
}
}