stealthscraper_rs/challenge/types.rs
1//! Core value types describing a detected bot-protection challenge.
2
3/// The category of bot-protection challenge identified on a response or page.
4///
5/// Variants intentionally stay coarse: distinguishing Cloudflare "managed v2"
6/// from "managed v3" reliably from client-visible markers is not possible, so
7/// non-interactive JavaScript challenges are grouped under [`ChallengeKind::JsChallenge`].
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ChallengeKind {
10 /// No challenge detected; the response looks like normal content.
11 None,
12 /// Legacy Cloudflare "I'm Under Attack Mode" interstitial (JS proof-of-work).
13 IuamV1,
14 /// Cloudflare managed JavaScript challenge (non-interactive).
15 JsChallenge,
16 /// Cloudflare Turnstile interactive widget.
17 Turnstile,
18 /// Hard block / access denied (e.g. Cloudflare error 1020).
19 AccessDenied,
20 /// Rate limited (HTTP 429 / Cloudflare error 1015).
21 RateLimited,
22 /// Response resembles a protection page but could not be classified.
23 Unknown,
24}
25
26/// Confidence attached to a [`ChallengeSignal`].
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
28pub enum Confidence {
29 /// Weak heuristic match (e.g. looks like a protection vendor but no specific marker).
30 Low,
31 /// One corroborating marker.
32 Medium,
33 /// Strong, unambiguous marker (status code or vendor-specific token).
34 High,
35}
36
37/// The result of running [`detect`](crate::challenge::detect) over a response/page.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ChallengeSignal {
40 /// The classified challenge category.
41 pub kind: ChallengeKind,
42 /// How sure the detector is about [`Self::kind`].
43 pub confidence: Confidence,
44 /// Human-readable labels for the markers that matched, for tracing/debugging.
45 pub evidence: Vec<&'static str>,
46}
47
48impl ChallengeSignal {
49 /// A clean "no challenge" signal.
50 pub fn none() -> Self {
51 Self {
52 kind: ChallengeKind::None,
53 confidence: Confidence::High,
54 evidence: Vec::new(),
55 }
56 }
57
58 /// Returns `true` when an actual challenge (anything but [`ChallengeKind::None`]) was detected.
59 pub fn is_challenge(&self) -> bool {
60 !matches!(self.kind, ChallengeKind::None)
61 }
62}
63
64/// Transport-neutral inputs for challenge detection.
65///
66/// Deliberately built from primitives (no `http`/`wreq`/`hyper` types) so the
67/// detector stays in the pure domain layer and can be fed from either the MITM
68/// proxy's upstream response or the rendered DOM of the headless browser.
69#[derive(Debug, Clone, Copy)]
70pub struct DetectionInput<'a> {
71 /// HTTP status code, when known.
72 pub status: Option<u16>,
73 /// Value of the `Server` response header, when present.
74 pub server: Option<&'a str>,
75 /// Value of the `cf-mitigated` response header, when present.
76 pub cf_mitigated: Option<&'a str>,
77 /// Value of the `cf-ray` response header, when present.
78 pub cf_ray: Option<&'a str>,
79 /// Response body or rendered DOM HTML.
80 pub body: &'a str,
81}
82
83impl<'a> DetectionInput<'a> {
84 /// Build an input from just a body/DOM string (no HTTP metadata available).
85 pub fn from_body(body: &'a str) -> Self {
86 Self {
87 status: None,
88 server: None,
89 cf_mitigated: None,
90 cf_ray: None,
91 body,
92 }
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn none_signal_is_clean() {
102 let signal = ChallengeSignal::none();
103 assert_eq!(signal.kind, ChallengeKind::None);
104 assert_eq!(signal.confidence, Confidence::High);
105 assert!(signal.evidence.is_empty());
106 assert!(!signal.is_challenge());
107 }
108
109 #[test]
110 fn populated_signal_is_a_challenge() {
111 let signal = ChallengeSignal {
112 kind: ChallengeKind::Turnstile,
113 confidence: Confidence::High,
114 evidence: vec!["marker"],
115 };
116 assert!(signal.is_challenge());
117 }
118
119 #[test]
120 fn from_body_leaves_http_metadata_unset() {
121 let input = DetectionInput::from_body("<html></html>");
122 assert_eq!(input.body, "<html></html>");
123 assert!(input.status.is_none());
124 assert!(input.server.is_none());
125 assert!(input.cf_mitigated.is_none());
126 assert!(input.cf_ray.is_none());
127 }
128
129 #[test]
130 fn confidence_orders_low_to_high() {
131 assert!(Confidence::Low < Confidence::Medium);
132 assert!(Confidence::Medium < Confidence::High);
133 }
134}