use std::fmt;
use std::time::Duration;
use crate::challenge::ChallengeKind;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScraperEvent<'a> {
ChallengeDetected {
host: Option<&'a str>,
kind: ChallengeKind,
},
Waiting {
host: Option<&'a str>,
kind: ChallengeKind,
delay: Duration,
},
ProxyRotated {
host: Option<&'a str>,
upstream: Option<&'a str>,
},
ProfileRotated {
user_agent: &'a str,
},
SolveSucceeded {
host: Option<&'a str>,
attempts: u32,
challenged: bool,
},
SolveFailed {
host: Option<&'a str>,
kind: ChallengeKind,
reason: &'a str,
},
}
impl fmt::Display for ScraperEvent<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let host = |h: &Option<&str>| h.unwrap_or("<unknown>").to_string();
match self {
ScraperEvent::ChallengeDetected { host: h, kind } => {
write!(f, "challenge detected on {}: {kind:?}", host(h))
}
ScraperEvent::Waiting {
host: h,
kind,
delay,
} => {
write!(f, "waiting {delay:?} for {kind:?} on {}", host(h))
}
ScraperEvent::ProxyRotated { host: h, upstream } => {
write!(
f,
"rotated egress proxy to {} for {}",
upstream.unwrap_or("<none>"),
host(h)
)
}
ScraperEvent::ProfileRotated { user_agent } => {
write!(f, "rotated browser profile (user-agent: {user_agent})")
}
ScraperEvent::SolveSucceeded {
host: h,
attempts,
challenged,
} => write!(
f,
"solve succeeded on {} (attempts={attempts}, challenged={challenged})",
host(h)
),
ScraperEvent::SolveFailed {
host: h,
kind,
reason,
} => write!(f, "solve failed on {} ({kind:?}): {reason}", host(h)),
}
}
}
pub trait EventSink: Send + Sync {
fn emit(&self, event: &ScraperEvent<'_>);
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopEventSink;
impl EventSink for NoopEventSink {
fn emit(&self, _event: &ScraperEvent<'_>) {}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LogEventSink;
impl EventSink for LogEventSink {
fn emit(&self, event: &ScraperEvent<'_>) {
match event {
ScraperEvent::SolveFailed { .. } => log::warn!("{event}"),
ScraperEvent::ChallengeDetected { .. }
| ScraperEvent::ProxyRotated { .. }
| ScraperEvent::ProfileRotated { .. }
| ScraperEvent::SolveSucceeded { .. } => log::info!("{event}"),
ScraperEvent::Waiting { .. } => log::debug!("{event}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct RecordingSink {
events: Mutex<Vec<String>>,
}
impl EventSink for RecordingSink {
fn emit(&self, event: &ScraperEvent<'_>) {
self.events.lock().unwrap().push(event.to_string());
}
}
#[test]
fn recording_sink_captures_emitted_events() {
let sink = RecordingSink::default();
sink.emit(&ScraperEvent::ChallengeDetected {
host: Some("example.com"),
kind: ChallengeKind::Turnstile,
});
sink.emit(&ScraperEvent::SolveSucceeded {
host: Some("example.com"),
attempts: 2,
challenged: true,
});
let recorded = sink.events.lock().unwrap();
assert_eq!(recorded.len(), 2);
assert!(recorded[0].contains("challenge detected on example.com: Turnstile"));
assert!(recorded[1].contains("attempts=2, challenged=true"));
}
#[test]
fn display_handles_unknown_host() {
let ev = ScraperEvent::SolveFailed {
host: None,
kind: ChallengeKind::AccessDenied,
reason: "blocked",
};
assert_eq!(
ev.to_string(),
"solve failed on <unknown> (AccessDenied): blocked"
);
}
#[test]
fn noop_and_log_sinks_do_not_panic() {
let ev = ScraperEvent::ProxyRotated {
host: Some("h"),
upstream: Some("http://p:1"),
};
NoopEventSink.emit(&ev);
LogEventSink.emit(&ev);
}
#[test]
fn every_variant_renders_and_logs() {
let events = [
ScraperEvent::ChallengeDetected {
host: None,
kind: ChallengeKind::JsChallenge,
},
ScraperEvent::Waiting {
host: Some("h"),
kind: ChallengeKind::IuamV1,
delay: Duration::from_secs(2),
},
ScraperEvent::ProxyRotated {
host: None,
upstream: None,
},
ScraperEvent::SolveSucceeded {
host: Some("h"),
attempts: 0,
challenged: false,
},
ScraperEvent::SolveFailed {
host: Some("h"),
kind: ChallengeKind::AccessDenied,
reason: "blocked",
},
ScraperEvent::ProfileRotated { user_agent: "UA" },
];
for ev in &events {
assert!(!ev.to_string().is_empty());
LogEventSink.emit(ev);
NoopEventSink.emit(ev);
}
assert!(
ScraperEvent::Waiting {
host: None,
kind: ChallengeKind::IuamV1,
delay: Duration::from_secs(2),
}
.to_string()
.starts_with("waiting")
);
assert!(
ScraperEvent::ProxyRotated {
host: None,
upstream: None
}
.to_string()
.contains("<none>")
);
}
#[test]
fn profile_rotated_display() {
let ev = ScraperEvent::ProfileRotated {
user_agent: "Mozilla/5.0 Test",
};
assert_eq!(
ev.to_string(),
"rotated browser profile (user-agent: Mozilla/5.0 Test)"
);
LogEventSink.emit(&ev);
}
}