1use std::fmt;
14use std::time::Duration;
15
16use crate::challenge::ChallengeKind;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ScraperEvent<'a> {
24 ChallengeDetected {
26 host: Option<&'a str>,
28 kind: ChallengeKind,
30 },
31 Waiting {
33 host: Option<&'a str>,
35 kind: ChallengeKind,
37 delay: Duration,
39 },
40 ProxyRotated {
42 host: Option<&'a str>,
44 upstream: Option<&'a str>,
46 },
47 ProfileRotated {
49 user_agent: &'a str,
51 },
52 SolveSucceeded {
54 host: Option<&'a str>,
56 attempts: u32,
58 challenged: bool,
60 },
61 SolveFailed {
63 host: Option<&'a str>,
65 kind: ChallengeKind,
67 reason: &'a str,
69 },
70}
71
72impl fmt::Display for ScraperEvent<'_> {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 let host = |h: &Option<&str>| h.unwrap_or("<unknown>").to_string();
75 match self {
76 ScraperEvent::ChallengeDetected { host: h, kind } => {
77 write!(f, "challenge detected on {}: {kind:?}", host(h))
78 }
79 ScraperEvent::Waiting {
80 host: h,
81 kind,
82 delay,
83 } => {
84 write!(f, "waiting {delay:?} for {kind:?} on {}", host(h))
85 }
86 ScraperEvent::ProxyRotated { host: h, upstream } => {
87 write!(
88 f,
89 "rotated egress proxy to {} for {}",
90 upstream.unwrap_or("<none>"),
91 host(h)
92 )
93 }
94 ScraperEvent::ProfileRotated { user_agent } => {
95 write!(f, "rotated browser profile (user-agent: {user_agent})")
96 }
97 ScraperEvent::SolveSucceeded {
98 host: h,
99 attempts,
100 challenged,
101 } => write!(
102 f,
103 "solve succeeded on {} (attempts={attempts}, challenged={challenged})",
104 host(h)
105 ),
106 ScraperEvent::SolveFailed {
107 host: h,
108 kind,
109 reason,
110 } => write!(f, "solve failed on {} ({kind:?}): {reason}", host(h)),
111 }
112 }
113}
114
115pub trait EventSink: Send + Sync {
119 fn emit(&self, event: &ScraperEvent<'_>);
121}
122
123#[derive(Debug, Default, Clone, Copy)]
125pub struct NoopEventSink;
126
127impl EventSink for NoopEventSink {
128 fn emit(&self, _event: &ScraperEvent<'_>) {}
129}
130
131#[derive(Debug, Default, Clone, Copy)]
133pub struct LogEventSink;
134
135impl EventSink for LogEventSink {
136 fn emit(&self, event: &ScraperEvent<'_>) {
137 match event {
138 ScraperEvent::SolveFailed { .. } => log::warn!("{event}"),
139 ScraperEvent::ChallengeDetected { .. }
140 | ScraperEvent::ProxyRotated { .. }
141 | ScraperEvent::ProfileRotated { .. }
142 | ScraperEvent::SolveSucceeded { .. } => log::info!("{event}"),
143 ScraperEvent::Waiting { .. } => log::debug!("{event}"),
144 }
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use std::sync::Mutex;
152
153 #[derive(Default)]
154 struct RecordingSink {
155 events: Mutex<Vec<String>>,
156 }
157
158 impl EventSink for RecordingSink {
159 fn emit(&self, event: &ScraperEvent<'_>) {
160 self.events.lock().unwrap().push(event.to_string());
161 }
162 }
163
164 #[test]
165 fn recording_sink_captures_emitted_events() {
166 let sink = RecordingSink::default();
167 sink.emit(&ScraperEvent::ChallengeDetected {
168 host: Some("example.com"),
169 kind: ChallengeKind::Turnstile,
170 });
171 sink.emit(&ScraperEvent::SolveSucceeded {
172 host: Some("example.com"),
173 attempts: 2,
174 challenged: true,
175 });
176
177 let recorded = sink.events.lock().unwrap();
178 assert_eq!(recorded.len(), 2);
179 assert!(recorded[0].contains("challenge detected on example.com: Turnstile"));
180 assert!(recorded[1].contains("attempts=2, challenged=true"));
181 }
182
183 #[test]
184 fn display_handles_unknown_host() {
185 let ev = ScraperEvent::SolveFailed {
186 host: None,
187 kind: ChallengeKind::AccessDenied,
188 reason: "blocked",
189 };
190 assert_eq!(
191 ev.to_string(),
192 "solve failed on <unknown> (AccessDenied): blocked"
193 );
194 }
195
196 #[test]
197 fn noop_and_log_sinks_do_not_panic() {
198 let ev = ScraperEvent::ProxyRotated {
199 host: Some("h"),
200 upstream: Some("http://p:1"),
201 };
202 NoopEventSink.emit(&ev);
203 LogEventSink.emit(&ev);
204 }
205
206 #[test]
207 fn every_variant_renders_and_logs() {
208 let events = [
209 ScraperEvent::ChallengeDetected {
210 host: None,
211 kind: ChallengeKind::JsChallenge,
212 },
213 ScraperEvent::Waiting {
214 host: Some("h"),
215 kind: ChallengeKind::IuamV1,
216 delay: Duration::from_secs(2),
217 },
218 ScraperEvent::ProxyRotated {
219 host: None,
220 upstream: None,
221 },
222 ScraperEvent::SolveSucceeded {
223 host: Some("h"),
224 attempts: 0,
225 challenged: false,
226 },
227 ScraperEvent::SolveFailed {
228 host: Some("h"),
229 kind: ChallengeKind::AccessDenied,
230 reason: "blocked",
231 },
232 ScraperEvent::ProfileRotated { user_agent: "UA" },
233 ];
234 for ev in &events {
235 assert!(!ev.to_string().is_empty());
236 LogEventSink.emit(ev);
237 NoopEventSink.emit(ev);
238 }
239
240 assert!(
241 ScraperEvent::Waiting {
242 host: None,
243 kind: ChallengeKind::IuamV1,
244 delay: Duration::from_secs(2),
245 }
246 .to_string()
247 .starts_with("waiting")
248 );
249 assert!(
250 ScraperEvent::ProxyRotated {
251 host: None,
252 upstream: None
253 }
254 .to_string()
255 .contains("<none>")
256 );
257 }
258
259 #[test]
260 fn profile_rotated_display() {
261 let ev = ScraperEvent::ProfileRotated {
262 user_agent: "Mozilla/5.0 Test",
263 };
264 assert_eq!(
265 ev.to_string(),
266 "rotated browser profile (user-agent: Mozilla/5.0 Test)"
267 );
268 LogEventSink.emit(&ev);
269 }
270}