Skip to main content

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}