use chromiumoxide::Page;
use serde::Deserialize;
const BLOCKING_STATUS: &[u16] = &[401, 403, 407, 429];
const CHALLENGE_TITLES: &[&str] = &[
"Client Challenge", "Just a moment...", "Attention Required! | Cloudflare", "Access to this page has been denied", "Pardon Our Interruption", ];
const CHALLENGE_PATHS: &[&str] = &[
"/_fs-ch-", "/cdn-cgi/challenge-platform/", "captcha-delivery.com", "/_Incapsula_Resource", "px-captcha", "/_sec/cp_challenge/", ];
const THIN_PAGE_ELEMENTS: u32 = 300;
#[derive(Debug, Default, Deserialize)]
pub(super) struct PageSignals {
pub status: Option<u16>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub elements: u32,
#[serde(default)]
pub resources: Vec<String>,
}
pub(super) fn classify(signals: &PageSignals) -> Option<String> {
if let Some(reason) = blocking_status(signals.status) {
return Some(reason);
}
let title = signals.title.trim();
if CHALLENGE_TITLES.contains(&title) {
return Some(format!("the page is a bot challenge (\"{title}\")"));
}
if signals.elements < THIN_PAGE_ELEMENTS {
if let Some(path) = CHALLENGE_PATHS
.iter()
.find(|path| signals.resources.iter().any(|r| r.contains(*path)))
{
return Some(format!("the page is a bot challenge (loads {path})"));
}
}
None
}
pub(super) fn blocking_status(status: Option<u16>) -> Option<String> {
status
.filter(|s| BLOCKING_STATUS.contains(s))
.map(|s| format!("the server answered HTTP {s}"))
}
const SIGNALS_JS: &str = r#"(() => {
const nav = performance.getEntriesByType('navigation')[0];
const resources = [];
for (const el of document.querySelectorAll('script[src], iframe[src], link[href]')) {
resources.push(el.getAttribute('src') || el.getAttribute('href') || '');
}
for (const entry of performance.getEntriesByType('resource')) resources.push(entry.name);
return {
status: nav && Number.isFinite(nav.responseStatus) && nav.responseStatus > 0 ? nav.responseStatus : null,
title: document.title || '',
elements: document.getElementsByTagName('*').length,
resources: resources.slice(0, 500),
};
})()"#;
pub(super) async fn detect(page: &Page) -> Option<String> {
for attempt in 0..3 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
let signals = page
.evaluate(SIGNALS_JS)
.await
.ok()
.and_then(|result| result.value().cloned())
.and_then(|value| serde_json::from_value::<PageSignals>(value).ok());
if let Some(signals) = signals {
return classify(&signals);
}
tracing::debug!("Access-block signals unavailable (attempt {})", attempt + 1);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn page(status: u16, title: &str, elements: u32, resources: &[&str]) -> PageSignals {
PageSignals {
status: Some(status),
title: title.to_string(),
elements,
resources: resources.iter().map(|r| r.to_string()).collect(),
}
}
#[test]
fn akamai_access_denied_is_blocked_by_status() {
let douglas = page(403, "Access Denied", 12, &[]);
assert_eq!(
classify(&douglas).as_deref(),
Some("the server answered HTTP 403")
);
}
#[test]
fn fastly_client_challenge_is_blocked_by_title_and_path() {
let hornbach = page(
200,
"Client Challenge",
40,
&["/_fs-ch-1T1wmsGaOgGaSxcX/script.js?reload=true"],
);
assert!(classify(&hornbach).unwrap().contains("Client Challenge"));
let untitled = page(200, "", 40, &["https://x.de/_fs-ch-1T1wms/errors.js"]);
assert!(classify(&untitled).unwrap().contains("/_fs-ch-"));
}
#[test]
fn challenge_scripts_on_a_full_page_do_not_block() {
let regular = page(
200,
"Startseite",
2400,
&["https://x.de/cdn-cgi/challenge-platform/scripts/jsd/main.js"],
);
assert_eq!(classify(®ular), None);
}
#[test]
fn regular_and_not_found_pages_are_not_blocked() {
assert_eq!(classify(&page(200, "Home", 20, &[])), None);
assert_eq!(classify(&page(404, "Not found", 80, &[])), None);
assert_eq!(classify(&PageSignals::default()), None);
}
}