use super::super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::{Duration, Instant};
const DEFAULT_MAX_WAIT_MS: u64 = 20_000;
pub struct AkamaiInterstitialSolver {
max_wait_ms: u64,
}
impl Default for AkamaiInterstitialSolver {
fn default() -> Self {
Self::new()
}
}
impl AkamaiInterstitialSolver {
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_abck(value: &str) -> AbckValidity {
if value.is_empty() {
return AbckValidity::Pending;
}
let segments: Vec<&str> = value.split('~').collect();
let Some(flag) = segments.get(1) else {
return AbckValidity::Pending;
};
match flag.trim() {
"0" => AbckValidity::Pending,
"-1" => AbckValidity::Valid,
s if s.parse::<i32>().map(|n| n > 0).unwrap_or(false) => AbckValidity::Suspicious,
_ => AbckValidity::Pending,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AbckValidity {
Pending,
Valid,
Suspicious,
}
const PHASE_PROBE_JS: &str = r#"
(() => {
const title = (document.title || '').toLowerCase();
const body = document.body ? (document.body.innerText || '').toLowerCase() : '';
if (title.includes('access denied') || title.includes('reference #') ||
body.includes('reference #') || body.includes('access denied')) {
return { phase: 'blocked' };
}
/* Surface the abck cookie value if present so the host classifier
can decide. */
let abck = '';
for (const c of (document.cookie || '').split(';')) {
const t = c.trim();
if (t.startsWith('_abck=')) {
abck = t.slice('_abck='.length);
break;
}
}
if (abck) return { phase: 'passed', abck };
/* Detect interstitial — the script tag, the bmak global, or the
canonical "Press & Hold" challenge that Akamai emits when its
confidence is borderline. */
const sensorPresent =
!!document.querySelector('script[src*="akam"], script[src*="_bm/"], script[src*="_sec/"]') ||
typeof window.bmak !== 'undefined' ||
body.includes('press & hold') ||
body.includes('press and hold');
if (sensorPresent) return { phase: 'interstitial' };
/* Nothing Akamai-shaped on the page — caller decides whether
this is "passed (no cookie yet)" or "wrong vendor". */
return { phase: 'unknown' };
})()
"#;
#[derive(Debug, serde::Deserialize)]
struct PhaseProbe {
phase: String,
#[serde(default)]
abck: String,
}
#[async_trait]
impl CaptchaSolver for AkamaiInterstitialSolver {
fn name(&self) -> &'static str {
"AkamaiInterstitialSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::AutoPass
}
fn supports(&self, kind: &DetectedCaptcha) -> bool {
matches!(
kind,
DetectedCaptcha::Custom(_) | DetectedCaptcha::MultiStepCaptcha
)
}
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!("Akamai interstitial probe failed: {e}"))?;
let probe: PhaseProbe = match raw.into_value() {
Ok(p) => p,
Err(_) => PhaseProbe {
phase: "interstitial".to_string(),
abck: String::new(),
},
};
match probe.phase.as_str() {
"passed" => match classify_abck(&probe.abck) {
AbckValidity::Valid => {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: "akamai:_abck".into(),
confidence: 1.0,
method: SolveMethod::AutoPass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
AbckValidity::Suspicious => {
return Ok(CaptchaSolveResult::failure(
SolveMethod::AutoPass,
t0.elapsed().as_millis() as u64,
));
}
AbckValidity::Pending => {
}
},
"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() {
let s = AkamaiInterstitialSolver::new();
assert_eq!(s.max_wait_ms, 20_000);
}
#[test]
fn builders_override() {
let s = AkamaiInterstitialSolver::new().with_max_wait_ms(8_000);
assert_eq!(s.max_wait_ms, 8_000);
}
#[test]
fn name_method_stable() {
let s = AkamaiInterstitialSolver::new();
assert_eq!(s.name(), "AkamaiInterstitialSolver");
assert_eq!(s.method(), SolveMethod::AutoPass);
}
#[test]
fn supports_custom_kind() {
let s = AkamaiInterstitialSolver::new();
assert!(s.supports(&DetectedCaptcha::Custom("akamai_bot_manager".into())));
assert!(s.supports(&DetectedCaptcha::MultiStepCaptcha));
assert!(!s.supports(&DetectedCaptcha::Turnstile));
assert!(!s.supports(&DetectedCaptcha::HCaptcha));
assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
}
#[test]
fn classify_abck_empty_is_pending() {
assert_eq!(classify_abck(""), AbckValidity::Pending);
}
#[test]
fn classify_abck_zero_flag_is_pending() {
assert_eq!(classify_abck("ABCDEFGH~0~YAAv12345~~"), AbckValidity::Pending);
}
#[test]
fn classify_abck_minus_one_flag_is_valid() {
assert_eq!(
classify_abck("ABCDEFGH~-1~YAAv12345~~"),
AbckValidity::Valid
);
}
#[test]
fn classify_abck_positive_flag_is_suspicious() {
for flag in ["1", "2", "3", "42", "999"] {
let cookie = format!("ABCDEFGH~{flag}~YAAv12345~~");
assert_eq!(
classify_abck(&cookie),
AbckValidity::Suspicious,
"flag {flag} should be Suspicious"
);
}
}
#[test]
fn classify_abck_garbage_is_pending() {
assert_eq!(classify_abck("malformed"), AbckValidity::Pending);
assert_eq!(classify_abck("a~b~c"), AbckValidity::Pending);
assert_eq!(classify_abck("~~"), AbckValidity::Pending);
}
#[test]
fn classify_abck_segment_with_whitespace() {
assert_eq!(classify_abck("ABC~ -1 ~AAv"), AbckValidity::Valid);
assert_eq!(classify_abck("ABC~ 0 ~AAv"), AbckValidity::Pending);
}
#[test]
fn phase_probe_js_covers_documented_signals() {
for needle in [
"_abck=",
"akam",
"_bm/",
"_sec/",
"press & hold",
"press and hold",
"access denied",
"reference #",
"bmak",
] {
assert!(
PHASE_PROBE_JS.to_lowercase().contains(needle),
"PHASE_PROBE_JS must reference: {needle}"
);
}
}
#[test]
fn phase_probe_js_returns_canonical_phases() {
for phase in ["blocked", "interstitial", "passed", "unknown"] {
assert!(
PHASE_PROBE_JS.contains(&format!("'{phase}'")),
"PHASE_PROBE_JS must emit phase: {phase}"
);
}
}
}