use crate::detect::{detect, is_captcha, CaptchaInfo, DetectedCaptcha};
use crate::solver::CaptchaSolveResult;
use crate::Page;
use anyhow::Result;
use std::future::Future;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct PlannerConfig {
pub max_hops: u32,
pub total_budget: Duration,
pub inter_hop_pause: Duration,
pub stuck_threshold: u32,
}
impl Default for PlannerConfig {
fn default() -> Self {
Self {
max_hops: 5,
total_budget: Duration::from_secs(120),
inter_hop_pause: Duration::from_millis(500),
stuck_threshold: 2,
}
}
}
#[derive(Debug, Clone)]
pub struct PlannerOutcome {
pub hops: Vec<HopOutcome>,
pub terminal: PlannerTerminal,
pub elapsed: Duration,
}
impl PlannerOutcome {
pub fn fully_succeeded(&self) -> bool {
matches!(self.terminal, PlannerTerminal::PageCaptchaFree)
&& self.hops.iter().all(|h| h.solve.success)
}
pub fn any_solve_succeeded(&self) -> bool {
self.hops.iter().any(|h| h.solve.success)
}
}
#[derive(Debug, Clone)]
pub struct HopOutcome {
pub hop_index: u32,
pub detected: DetectedCaptcha,
pub solve: CaptchaSolveResult,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlannerTerminal {
PageCaptchaFree,
HopBudgetExhausted,
TimeBudgetExhausted,
Stuck { kind: DetectedCaptcha, hops: u32 },
HardBlocked,
}
pub struct MultiHopPlanner {
config: PlannerConfig,
}
impl MultiHopPlanner {
pub fn new() -> Self {
Self::with_config(PlannerConfig::default())
}
pub fn with_config(config: PlannerConfig) -> Self {
Self { config }
}
pub async fn run<F, Fut>(&self, page: &Page, mut solver: F) -> Result<PlannerOutcome>
where
F: FnMut(&Page, CaptchaInfo) -> Fut,
Fut: Future<Output = CaptchaSolveResult>,
{
let started = Instant::now();
let mut hops: Vec<HopOutcome> = Vec::with_capacity(self.config.max_hops as usize);
let mut consecutive_same: u32 = 0;
let mut last_detected: Option<DetectedCaptcha> = None;
for hop_index in 1..=self.config.max_hops {
if started.elapsed() >= self.config.total_budget {
return Ok(PlannerOutcome {
hops,
terminal: PlannerTerminal::TimeBudgetExhausted,
elapsed: started.elapsed(),
});
}
let info = detect(page).await?;
if !is_captcha(&info) {
return Ok(PlannerOutcome {
hops,
terminal: PlannerTerminal::PageCaptchaFree,
elapsed: started.elapsed(),
});
}
if is_hard_block(&info.kind) {
return Ok(PlannerOutcome {
hops,
terminal: PlannerTerminal::HardBlocked,
elapsed: started.elapsed(),
});
}
if last_detected.as_ref() == Some(&info.kind) {
consecutive_same += 1;
if consecutive_same >= self.config.stuck_threshold {
return Ok(PlannerOutcome {
hops,
terminal: PlannerTerminal::Stuck {
kind: info.kind.clone(),
hops: consecutive_same,
},
elapsed: started.elapsed(),
});
}
} else {
consecutive_same = 1;
}
last_detected = Some(info.kind.clone());
let solve = solver(page, info.clone()).await;
hops.push(HopOutcome {
hop_index,
detected: info.kind.clone(),
solve,
});
tokio::time::sleep(self.config.inter_hop_pause).await;
}
Ok(PlannerOutcome {
hops,
terminal: PlannerTerminal::HopBudgetExhausted,
elapsed: started.elapsed(),
})
}
}
impl Default for MultiHopPlanner {
fn default() -> Self {
Self::new()
}
}
fn is_hard_block(kind: &DetectedCaptcha) -> bool {
use DetectedCaptcha::Custom;
if let Custom(name) = kind {
return matches!(
name.as_str(),
"akamai_reference_block"
| "imperva_block_text"
| "fly_io_block"
| "linode_block"
| "digitalocean_block"
| "hetzner_block"
| "scaleway_block"
| "vultr_block"
| "render_block"
| "kinsta_block"
);
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detect::CaptchaInfo;
use crate::solver::SolveMethod;
fn solve_ok() -> CaptchaSolveResult {
CaptchaSolveResult {
success: true,
confidence: 1.0,
method: SolveMethod::BehavioralBypass,
time_ms: 100,
solution: "ok".into(),
screenshot: None,
cookies: Vec::new(),
verified_outcome: None,
}
}
fn solve_fail() -> CaptchaSolveResult {
CaptchaSolveResult {
success: false,
confidence: 0.0,
method: SolveMethod::CrowdSourced,
time_ms: 50,
solution: String::new(),
screenshot: None,
cookies: Vec::new(),
verified_outcome: None,
}
}
#[test]
fn default_config_has_reasonable_bounds() {
let c = PlannerConfig::default();
assert!(
c.max_hops >= 3,
"must allow at least 3 hops for stacked WAF + Turnstile + recheck flows"
);
assert!(c.total_budget.as_secs() >= 60);
assert!(c.stuck_threshold >= 2);
}
#[test]
fn fully_succeeded_requires_terminal_plus_all_hops_ok() {
let mut o = PlannerOutcome {
hops: vec![HopOutcome {
hop_index: 1,
detected: DetectedCaptcha::Turnstile,
solve: solve_ok(),
}],
terminal: PlannerTerminal::PageCaptchaFree,
elapsed: Duration::from_millis(100),
};
assert!(o.fully_succeeded());
o.hops.push(HopOutcome {
hop_index: 2,
detected: DetectedCaptcha::HCaptcha,
solve: solve_fail(),
});
assert!(!o.fully_succeeded());
}
#[test]
fn any_solve_succeeded_picks_up_partial_progress() {
let o = PlannerOutcome {
hops: vec![
HopOutcome {
hop_index: 1,
detected: DetectedCaptcha::Turnstile,
solve: solve_ok(),
},
HopOutcome {
hop_index: 2,
detected: DetectedCaptcha::HCaptcha,
solve: solve_fail(),
},
],
terminal: PlannerTerminal::HopBudgetExhausted,
elapsed: Duration::from_secs(60),
};
assert!(o.any_solve_succeeded());
assert!(!o.fully_succeeded());
}
#[test]
fn empty_outcome_does_not_falsely_report_success() {
let o = PlannerOutcome {
hops: vec![],
terminal: PlannerTerminal::PageCaptchaFree,
elapsed: Duration::ZERO,
};
assert!(o.fully_succeeded());
assert!(!o.any_solve_succeeded());
}
#[test]
fn is_hard_block_recognises_known_block_screens() {
assert!(is_hard_block(&DetectedCaptcha::Custom(
"akamai_reference_block".into()
)));
assert!(is_hard_block(&DetectedCaptcha::Custom(
"fly_io_block".into()
)));
assert!(is_hard_block(&DetectedCaptcha::Custom(
"digitalocean_block".into()
)));
}
#[test]
fn is_hard_block_rejects_unknown_custom_names() {
assert!(!is_hard_block(&DetectedCaptcha::Custom(
"some_random_vendor".into()
)));
}
#[test]
fn is_hard_block_rejects_built_in_kinds() {
assert!(!is_hard_block(&DetectedCaptcha::Turnstile));
assert!(!is_hard_block(&DetectedCaptcha::HCaptcha));
assert!(!is_hard_block(&DetectedCaptcha::RecaptchaV2));
assert!(!is_hard_block(&DetectedCaptcha::SliderCaptcha));
}
#[test]
fn planner_terminal_variants_round_trip_via_pattern_match() {
let cases = [
PlannerTerminal::PageCaptchaFree,
PlannerTerminal::HopBudgetExhausted,
PlannerTerminal::TimeBudgetExhausted,
PlannerTerminal::Stuck {
kind: DetectedCaptcha::Turnstile,
hops: 2,
},
PlannerTerminal::HardBlocked,
];
for t in cases {
match &t {
PlannerTerminal::PageCaptchaFree => {}
PlannerTerminal::HopBudgetExhausted => {}
PlannerTerminal::TimeBudgetExhausted => {}
PlannerTerminal::Stuck { kind: _, hops: _ } => {}
PlannerTerminal::HardBlocked => {}
}
}
}
#[test]
fn smoke_test_imports() {
let _: Option<CaptchaInfo> = None;
let _ = SolveMethod::BehavioralBypass;
}
}