use super::super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::{Duration, Instant};
const DEFAULT_MAX_WAIT_MS: u64 = 12_000;
pub struct ArkoseSolver {
max_wait_ms: u64,
}
impl Default for ArkoseSolver {
fn default() -> Self {
Self::new()
}
}
impl ArkoseSolver {
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 is_valid_arkose_token(value: &str) -> bool {
if value.len() < 30 {
return false;
}
let trimmed = value.trim_matches('"');
if let Some((a, rest)) = trimmed.split_once('.') {
if let Some((b, c)) = rest.split_once('.') {
if !a.is_empty() && !c.is_empty() {
if let Ok(t) = b.parse::<u64>() {
if (1_577_836_800..4_102_444_800).contains(&t) {
return true;
}
}
}
}
}
if trimmed.contains("|r=") && trimmed.contains('|') {
return true;
}
false
}
const PHASE_PROBE_JS: &str = r#"
(() => {
const readField = (sel) => {
const el = document.querySelector(sel);
return el ? (el.value || el.getAttribute('value') || '') : '';
};
/* Token surfaces on a hidden form input (canonical) OR a
window global (some custom integrations). */
const token =
readField('input[name="verification-token"], input[name="fc-token"], input[name="arkose-token"]') ||
(window.arkoseToken || '') ||
(window.fcToken || '');
if (token) return { phase: 'passed', token };
/* Visible challenge iframe — yield to VLM. */
if (document.querySelector('iframe[src*="arkoselabs.com"], iframe[src*="funcaptcha.com"], #funcaptcha, .arkose-iframe')) {
return { phase: 'puzzle' };
}
/* SDK loaded but nothing surfaced yet. */
const sdkPresent =
!!document.querySelector('script[src*="client-api.arkoselabs.com"], script[src*="funcaptcha.com"], [data-pkey]') ||
typeof window.ArkoseEnforcement !== 'undefined' ||
typeof window.FunCaptcha !== 'undefined';
if (sdkPresent) return { phase: 'interstitial' };
return { phase: 'unknown' };
})()
"#;
#[derive(Debug, serde::Deserialize)]
struct PhaseProbe {
phase: String,
#[serde(default)]
token: String,
}
#[async_trait]
impl CaptchaSolver for ArkoseSolver {
fn name(&self) -> &'static str {
"ArkoseSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::AutoPass
}
fn supports(&self, kind: &DetectedCaptcha) -> bool {
matches!(
kind,
DetectedCaptcha::Custom(_) | DetectedCaptcha::ImageCaptcha
)
}
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!("Arkose probe failed: {e}"))?;
let probe: PhaseProbe = match raw.into_value() {
Ok(p) => p,
Err(_) => PhaseProbe {
phase: "interstitial".into(),
token: String::new(),
},
};
match probe.phase.as_str() {
"passed" if is_valid_arkose_token(&probe.token) => {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: probe.token,
confidence: 0.95,
method: SolveMethod::AutoPass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
"passed" => {
}
"puzzle" => {
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,
});
}
_ => {} }
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!(ArkoseSolver::new().max_wait_ms, 12_000);
}
#[test]
fn builders_override() {
assert_eq!(
ArkoseSolver::new().with_max_wait_ms(5_000).max_wait_ms,
5_000
);
}
#[test]
fn name_method_stable() {
let s = ArkoseSolver::new();
assert_eq!(s.name(), "ArkoseSolver");
assert_eq!(s.method(), SolveMethod::AutoPass);
}
#[test]
fn supports_custom_and_image_captcha() {
let s = ArkoseSolver::new();
assert!(s.supports(&DetectedCaptcha::Custom("arkose_funcaptcha".into())));
assert!(s.supports(&DetectedCaptcha::ImageCaptcha));
assert!(!s.supports(&DetectedCaptcha::Turnstile));
assert!(!s.supports(&DetectedCaptcha::HCaptcha));
assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
}
#[test]
fn token_rejects_short_or_empty() {
assert!(!is_valid_arkose_token(""));
assert!(!is_valid_arkose_token("short"));
assert!(!is_valid_arkose_token(&"a".repeat(29))); }
#[test]
fn token_accepts_new_sdk_shape() {
let new = "abcdef0123456789abcdef0123456789.1736899200.deadbeefdeadbeef";
assert!(is_valid_arkose_token(new));
}
#[test]
fn token_rejects_new_sdk_with_unparseable_timestamp() {
let bad = "abcdef0123456789abcdef0123456789.NOT-A-TIME.deadbeefdeadbeef";
assert!(!is_valid_arkose_token(bad));
}
#[test]
fn token_rejects_new_sdk_with_out_of_range_timestamp() {
let too_old = "abcdef0123456789abcdef0123456789.1000000000.deadbeefdeadbeef"; let too_new = "abcdef0123456789abcdef0123456789.5000000000.deadbeefdeadbeef"; assert!(!is_valid_arkose_token(too_old));
assert!(!is_valid_arkose_token(too_new));
}
#[test]
fn token_accepts_legacy_fc_token() {
let legacy = "abc123|r=us-east-1|metabgclr=transparent|guitextcolor=ffffff";
assert!(is_valid_arkose_token(legacy));
}
#[test]
fn token_rejects_legacy_without_region_segment() {
let bad = "abc123|metabgclr=transparent|guitextcolor=ffffff|extra=xx";
assert!(!is_valid_arkose_token(bad));
}
#[test]
fn token_accepts_quoted_value() {
let real = "\"abcdef0123456789abcdef0123456789.1736899200.deadbeef\"";
assert!(is_valid_arkose_token(real));
}
#[test]
fn phase_probe_js_covers_documented_signals() {
for needle in [
"verification-token",
"fc-token",
"arkose-token",
"arkoselabs.com",
"funcaptcha.com",
"client-api.arkoselabs.com",
"data-pkey",
"arkoseenforcement",
"funcaptcha",
] {
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 ["passed", "puzzle", "interstitial", "unknown"] {
assert!(
PHASE_PROBE_JS.contains(&format!("'{phase}'")),
"PHASE_PROBE_JS must emit phase: {phase}"
);
}
}
}