use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PageSnapshot {
pub url: String,
pub title: String,
pub body_excerpt: String,
pub captcha_present: bool,
pub cookie_names: Vec<String>,
}
impl PageSnapshot {
pub fn empty() -> Self {
Self {
url: String::new(),
title: String::new(),
body_excerpt: String::new(),
captcha_present: false,
cookie_names: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum OutcomeClassification {
Advanced,
Recycled,
HardBlock,
SilentFail,
Unknown,
}
pub const BLOCK_PHRASES: &[&str] = &[
"access denied",
"sorry, you have been blocked",
"request blocked",
"pardon our interruption",
"your request has been blocked",
"this request has been blocked",
"you have been rate limited",
"rate limit exceeded",
"the owner of this website",
"blocked by",
"security policy",
"permission denied",
"forbidden",
"request unsuccessful",
"you don't have permission",
"automated access",
"bot detected",
"suspicious activity",
];
pub fn classify(before: &PageSnapshot, after: &PageSnapshot) -> OutcomeClassification {
if after.url.is_empty() && after.title.is_empty() && after.body_excerpt.is_empty() {
return OutcomeClassification::Unknown;
}
let title_lower = after.title.to_lowercase();
if BLOCK_PHRASES
.iter()
.any(|p| title_lower.contains(p) || after.body_excerpt.contains(p))
{
return OutcomeClassification::HardBlock;
}
if after.captcha_present {
return OutcomeClassification::Recycled;
}
let url_changed = before.url != after.url;
let title_changed = before.title != after.title;
let new_cookies = after
.cookie_names
.iter()
.any(|c| !before.cookie_names.contains(c));
if url_changed || title_changed || new_cookies {
OutcomeClassification::Advanced
} else {
OutcomeClassification::SilentFail
}
}
pub async fn take_snapshot(page: &Page) -> PageSnapshot {
let url = page.url().await.ok().flatten().unwrap_or_default();
let title = page
.evaluate("document.title || ''")
.await
.ok()
.and_then(|v| v.into_value::<String>().ok())
.unwrap_or_default();
let body_excerpt = page
.evaluate(
r#"(() => {
const t = (document.body && document.body.innerText) || '';
return t.slice(0, 4096).toLowerCase().replace(/\s+/g, ' ').trim();
})()"#,
)
.await
.ok()
.and_then(|v| v.into_value::<String>().ok())
.unwrap_or_default();
let captcha_present = page
.evaluate(
r#"(() => {
const sel = [
'iframe[src*="challenges.cloudflare.com"]',
'iframe[src*="recaptcha"]',
'iframe[src*="hcaptcha"]',
'iframe[src*="arkoselabs"]',
'iframe[src*="datadome"]',
'iframe[src*="geetest"]',
'iframe[src*="perimeterx"]',
'iframe[src*="kasada"]',
'iframe[src*="incapsula"]',
'.cf-turnstile',
'.h-captcha',
'.g-recaptcha',
'#challenge-form',
'#challenge-stage',
'#cf-please-wait',
'#px-captcha',
'[id^="captcha"]',
'[class*="captcha" i]',
'[class*="challenge" i]'
];
return sel.some(s => document.querySelector(s) !== null);
})()"#,
)
.await
.ok()
.and_then(|v| v.into_value::<bool>().ok())
.unwrap_or(false);
let cookie_names = page
.get_cookies()
.await
.ok()
.map(|cs| cs.into_iter().map(|c| c.name).collect())
.unwrap_or_default();
PageSnapshot {
url,
title,
body_excerpt,
captcha_present,
cookie_names,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(url: &str, title: &str, body: &str, captcha: bool, cookies: &[&str]) -> PageSnapshot {
PageSnapshot {
url: url.into(),
title: title.into(),
body_excerpt: body.into(),
captcha_present: captcha,
cookie_names: cookies.iter().map(|s| (*s).to_string()).collect(),
}
}
#[test]
fn classify_advanced_url_change() {
let before = snap("/login", "Verify", "check", true, &[]);
let after = snap("/dashboard", "Verify", "welcome", false, &[]);
assert_eq!(classify(&before, &after), OutcomeClassification::Advanced);
}
#[test]
fn classify_advanced_new_session_cookie() {
let before = snap("/x", "T", "b", true, &["__cf_bm"]);
let after = snap("/x", "T", "b", false, &["__cf_bm", "session"]);
assert_eq!(classify(&before, &after), OutcomeClassification::Advanced);
}
#[test]
fn classify_advanced_title_change() {
let before = snap("/x", "Verify you are human", "challenge", true, &[]);
let after = snap("/x", "Welcome", "home", false, &[]);
assert_eq!(classify(&before, &after), OutcomeClassification::Advanced);
}
#[test]
fn classify_recycled_captcha_still_present() {
let before = snap("/x", "T", "b", true, &[]);
let after = snap("/x", "T", "b", true, &[]);
assert_eq!(classify(&before, &after), OutcomeClassification::Recycled);
}
#[test]
fn classify_hard_block_body_marker() {
let before = snap("/x", "Verify", "check", true, &[]);
let after = snap("/x", "Blocked", "sorry, you have been blocked", false, &[]);
assert_eq!(classify(&before, &after), OutcomeClassification::HardBlock);
}
#[test]
fn classify_hard_block_title_marker() {
let before = snap("/x", "Verify", "check", true, &[]);
let after = snap("/x", "Access Denied", "we apologize", false, &[]);
assert_eq!(classify(&before, &after), OutcomeClassification::HardBlock);
}
#[test]
fn classify_hard_block_beats_advanced() {
let before = snap("/login", "Verify", "check", true, &[]);
let after = snap(
"/blocked",
"Request blocked",
"your request has been blocked",
false,
&["session"],
);
assert_eq!(classify(&before, &after), OutcomeClassification::HardBlock);
}
#[test]
fn classify_silent_fail_no_movement() {
let before = snap("/x", "T", "b", true, &["a"]);
let after = snap("/x", "T", "b", false, &["a"]);
assert_eq!(classify(&before, &after), OutcomeClassification::SilentFail);
}
#[test]
fn classify_unknown_for_empty_after() {
let before = snap("/x", "T", "b", true, &[]);
let after = PageSnapshot::empty();
assert_eq!(classify(&before, &after), OutcomeClassification::Unknown);
}
#[test]
fn classify_does_not_trigger_block_on_phrase_in_before() {
let before = snap("/x", "T", "access denied previously", true, &[]);
let after = snap("/x", "T", "all clear now", false, &["session"]);
assert_eq!(classify(&before, &after), OutcomeClassification::Advanced);
}
#[test]
fn classify_advanced_when_old_cookies_subset_of_new() {
let before = snap("/x", "T", "b", true, &["__cf_bm", "datr"]);
let after = snap("/x", "T", "b", false, &["__cf_bm", "datr", "sessionid"]);
assert_eq!(classify(&before, &after), OutcomeClassification::Advanced);
}
#[test]
fn empty_snapshot_constructor_yields_unknown_classification() {
let before = PageSnapshot::empty();
let after = PageSnapshot::empty();
assert_eq!(classify(&before, &after), OutcomeClassification::Unknown);
}
}