1use std::fmt;
4
5use millipede_core::{errors::AntiBotTech, request::Request};
6use millipede_html::{SynchronizedHtml, scraper::Selector};
7
8#[non_exhaustive]
10pub struct HttpAttemptSnapshot<'a> {
11 pub request: &'a Request,
13 pub status: http::StatusCode,
15 pub headers: &'a http::HeaderMap,
17 pub body: &'a [u8],
19 pub final_url: &'a url::Url,
21 pub html: Option<&'a SynchronizedHtml>,
23}
24
25impl<'a> HttpAttemptSnapshot<'a> {
26 pub(crate) fn new(
27 request: &'a Request,
28 status: http::StatusCode,
29 headers: &'a http::HeaderMap,
30 body: &'a [u8],
31 final_url: &'a url::Url,
32 html: Option<&'a SynchronizedHtml>,
33 ) -> Self {
34 Self {
35 request,
36 status,
37 headers,
38 body,
39 final_url,
40 html,
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq)]
47#[non_exhaustive]
48pub enum PromotionReason {
49 EmptyBodyLikelyJs,
51 KnownAntiBot(AntiBotTech),
53 SelectorMissing {
55 selector: String,
57 },
58 StatusPromoted {
60 status: u16,
62 },
63 Custom(String),
65}
66
67impl fmt::Display for PromotionReason {
68 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::EmptyBodyLikelyJs => {
71 formatter.write_str("successful page is likely a JavaScript shell")
72 }
73 Self::KnownAntiBot(tech) => write!(formatter, "known anti-bot interstitial: {tech:?}"),
74 Self::SelectorMissing { selector } => {
75 write!(formatter, "required selector is missing: {selector}")
76 }
77 Self::StatusPromoted { status } => {
78 write!(formatter, "configured HTTP status promoted: {status}")
79 }
80 Self::Custom(reason) => formatter.write_str(reason),
81 }
82 }
83}
84
85pub trait BrowserPromotionDetector: Send + Sync + 'static {
87 fn should_promote(&self, attempt: &HttpAttemptSnapshot<'_>) -> Option<PromotionReason>;
89}
90
91#[derive(Debug, Clone)]
95#[must_use = "detector configuration does nothing unless the detector is installed"]
96pub struct DefaultPromotionDetector {
97 required_selector: Option<String>,
98 min_visible_text: usize,
99 anti_bot: std::sync::Arc<dyn millipede_core::antibot::AntiBotDetector>,
100}
101
102impl DefaultPromotionDetector {
103 pub fn new() -> Self {
105 Self::default()
106 }
107
108 pub fn with_required_selector(mut self, selector: impl Into<String>) -> Self {
110 self.required_selector = Some(selector.into());
111 self
112 }
113
114 pub fn with_min_visible_text(mut self, minimum: usize) -> Self {
116 self.min_visible_text = minimum;
117 self
118 }
119
120 pub fn with_anti_bot_detector(
122 mut self,
123 detector: std::sync::Arc<dyn millipede_core::antibot::AntiBotDetector>,
124 ) -> Self {
125 self.anti_bot = detector;
126 self
127 }
128
129 fn inspect_html(
130 &self,
131 status: http::StatusCode,
132 document: &millipede_html::scraper::Html,
133 ) -> Option<PromotionReason> {
134 if let Some(selector_text) = &self.required_selector {
135 if let Ok(selector) = Selector::parse(selector_text) {
136 if document.select(&selector).next().is_none() {
137 return Some(PromotionReason::SelectorMissing {
138 selector: selector_text.clone(),
139 });
140 }
141 }
142 }
143
144 if status.is_success() {
145 let body_selector = Selector::parse("body").expect("static body selector is valid");
146 let script_selector =
147 Selector::parse("script").expect("static script selector is valid");
148 let visible_text_len = document
149 .select(&body_selector)
150 .next()
151 .map(|body| body.text().collect::<Vec<_>>().join(" ").trim().len())
152 .unwrap_or(0);
153 let has_script = document.select(&script_selector).next().is_some();
154 if visible_text_len < self.min_visible_text && has_script {
155 return Some(PromotionReason::EmptyBodyLikelyJs);
156 }
157 }
158
159 None
160 }
161}
162
163impl Default for DefaultPromotionDetector {
164 fn default() -> Self {
165 Self {
166 required_selector: None,
167 min_visible_text: 40,
168 anti_bot: std::sync::Arc::new(millipede_core::antibot::DefaultAntiBotDetector::new()),
169 }
170 }
171}
172
173impl BrowserPromotionDetector for DefaultPromotionDetector {
174 fn should_promote(&self, attempt: &HttpAttemptSnapshot<'_>) -> Option<PromotionReason> {
175 let signals = millipede_core::antibot::AntiBotSignals::new(
176 attempt.status,
177 attempt.headers,
178 attempt.body,
179 attempt.final_url,
180 );
181 if let Some(tech) = self.anti_bot.detect(&signals) {
182 return Some(PromotionReason::KnownAntiBot(tech));
183 }
184
185 if let Some(html) = attempt.html {
186 if let Some(reason) =
187 html.with_html(|document| self.inspect_html(attempt.status, document))
188 {
189 return Some(reason);
190 }
191 }
192
193 None
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use millipede_core::request::Request;
201
202 fn snapshot<'a>(
203 request: &'a Request,
204 headers: &'a http::HeaderMap,
205 body: &'a [u8],
206 status: http::StatusCode,
207 ) -> HttpAttemptSnapshot<'a> {
208 HttpAttemptSnapshot::new(request, status, headers, body, &request.url, None)
209 }
210
211 fn request() -> Request {
212 Request::get("https://example.com/")
213 .build()
214 .expect("valid test request")
215 }
216
217 fn html(source: &str) -> millipede_html::scraper::Html {
218 millipede_html::scraper::Html::parse_document(source)
219 }
220
221 #[test]
222 fn cloudflare_marker_is_promoted() {
223 let request = request();
224 let headers = http::HeaderMap::new();
225 assert_eq!(
226 DefaultPromotionDetector::new().should_promote(&snapshot(
227 &request,
228 &headers,
229 b"<html><body>Just a moment...</body></html>",
230 http::StatusCode::OK,
231 )),
232 Some(PromotionReason::KnownAntiBot(AntiBotTech::Cloudflare))
233 );
234 }
235
236 #[test]
237 fn contentful_page_stays_http() {
238 let document = html(
239 "<html><body>A long, genuinely contentful page with enough visible text for the conservative detector.</body></html>",
240 );
241 assert_eq!(
242 DefaultPromotionDetector::new().inspect_html(http::StatusCode::OK, &document),
243 None
244 );
245 }
246
247 #[test]
248 fn javascript_shell_is_promoted() {
249 let document = html("<html><body><script src=\"app.js\"></script></body></html>");
250 assert_eq!(
251 DefaultPromotionDetector::new().inspect_html(http::StatusCode::OK, &document),
252 Some(PromotionReason::EmptyBodyLikelyJs)
253 );
254 }
255
256 #[test]
257 fn required_selector_only_promotes_when_missing() {
258 let missing = html("<html><body><main>content</main></body></html>");
259 let present = html("<html><body><main id=\"app\">content</main></body></html>");
260 let detector = DefaultPromotionDetector::new().with_required_selector("#app");
261 assert_eq!(
262 detector.inspect_html(http::StatusCode::OK, &missing),
263 Some(PromotionReason::SelectorMissing {
264 selector: "#app".to_owned(),
265 })
266 );
267 assert_eq!(detector.inspect_html(http::StatusCode::OK, &present), None);
268 }
269
270 #[test]
271 fn markers_after_inspection_cap_are_ignored() {
272 let request = request();
273 let headers = http::HeaderMap::new();
274 let mut body =
276 vec![b'x'; millipede_core::antibot::DefaultAntiBotDetector::DEFAULT_INSPECTION_LIMIT];
277 body.extend_from_slice(b"just a moment");
278 assert_eq!(
279 DefaultPromotionDetector::new().should_promote(&snapshot(
280 &request,
281 &headers,
282 &body,
283 http::StatusCode::OK,
284 )),
285 None
286 );
287 }
288
289 #[test]
290 fn perimeterx_fixture_is_promoted() {
291 let body = include_str!(concat!(
292 env!("CARGO_MANIFEST_DIR"),
293 "/../millipede-core/tests/fixtures/antibot/perimeterx.html"
294 ));
295 let request = request();
296 let headers = http::HeaderMap::new();
297 assert_eq!(
298 DefaultPromotionDetector::new().should_promote(&snapshot(
299 &request,
300 &headers,
301 body.as_bytes(),
302 http::StatusCode::OK,
303 )),
304 Some(PromotionReason::KnownAntiBot(AntiBotTech::PerimeterX))
305 );
306 }
307
308 #[test]
309 fn imperva_fixture_is_promoted() {
310 let body = include_str!(concat!(
311 env!("CARGO_MANIFEST_DIR"),
312 "/../millipede-core/tests/fixtures/antibot/imperva.html"
313 ));
314 let request = request();
315 let headers = http::HeaderMap::new();
316 assert_eq!(
317 DefaultPromotionDetector::new().should_promote(&snapshot(
318 &request,
319 &headers,
320 body.as_bytes(),
321 http::StatusCode::OK,
322 )),
323 Some(PromotionReason::KnownAntiBot(AntiBotTech::Imperva))
324 );
325 }
326
327 #[test]
328 fn cloudflare_fixture_is_promoted() {
329 let body = include_str!(concat!(
330 env!("CARGO_MANIFEST_DIR"),
331 "/../millipede-core/tests/fixtures/antibot/cloudflare.html"
332 ));
333 let request = request();
334 let headers = http::HeaderMap::new();
335 assert_eq!(
336 DefaultPromotionDetector::new().should_promote(&snapshot(
337 &request,
338 &headers,
339 body.as_bytes(),
340 http::StatusCode::OK,
341 )),
342 Some(PromotionReason::KnownAntiBot(AntiBotTech::Cloudflare))
343 );
344 }
345
346 macro_rules! shared_vendor_fixture_test {
347 ($name:ident, $fixture:literal, $expected:expr) => {
348 #[test]
349 fn $name() {
350 let body = include_str!(concat!(
351 env!("CARGO_MANIFEST_DIR"),
352 "/../millipede-core/tests/fixtures/antibot/",
353 $fixture
354 ));
355 let request = request();
356 let headers = http::HeaderMap::new();
357 assert_eq!(
358 DefaultPromotionDetector::new().should_promote(&snapshot(
359 &request,
360 &headers,
361 body.as_bytes(),
362 http::StatusCode::OK,
363 )),
364 Some(PromotionReason::KnownAntiBot($expected))
365 );
366 }
367 };
368 }
369
370 shared_vendor_fixture_test!(
371 datadome_fixture_is_promoted,
372 "datadome.html",
373 AntiBotTech::DataDome
374 );
375 shared_vendor_fixture_test!(
376 kasada_fixture_is_promoted,
377 "kasada.html",
378 AntiBotTech::Kasada
379 );
380 shared_vendor_fixture_test!(
381 akamai_fixture_is_promoted,
382 "akamai.html",
383 AntiBotTech::Akamai
384 );
385 shared_vendor_fixture_test!(
386 unknown_fixture_is_promoted,
387 "unknown.html",
388 AntiBotTech::Unknown
389 );
390
391 #[test]
392 fn benign_contentful_fixture_stays_http() {
393 let body = include_str!(concat!(
394 env!("CARGO_MANIFEST_DIR"),
395 "/../millipede-core/tests/fixtures/antibot/benign_contentful.html"
396 ));
397 let request = request();
398 let headers = http::HeaderMap::new();
399 assert_eq!(
400 DefaultPromotionDetector::new().should_promote(&snapshot(
401 &request,
402 &headers,
403 body.as_bytes(),
404 http::StatusCode::OK,
405 )),
406 None
407 );
408 }
409
410 #[test]
411 fn contentful_forbidden_page_does_not_trip_body_detector() {
412 let document = html(
413 "<html><body>A long forbidden response with meaningful content and no JavaScript shell markers.</body></html>",
414 );
415 assert_eq!(
416 DefaultPromotionDetector::new().inspect_html(http::StatusCode::FORBIDDEN, &document),
417 None
418 );
419 }
420}