use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
pub struct ConsentResult {
pub banner_detected: bool,
pub cmp_name: Option<String>,
pub dismissed: bool,
pub status: ConsentHandlingStatus,
pub evidence: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsentHandlingStatus {
NotDetected,
Detected,
Dismissed,
DismissFailed,
Unknown,
}
impl ConsentResult {
pub fn merge(&mut self, other: ConsentResult) {
self.banner_detected |= other.banner_detected;
if self.cmp_name.is_none() {
self.cmp_name = other.cmp_name;
}
self.dismissed |= other.dismissed;
self.evidence.extend(other.evidence);
self.evidence.sort();
self.evidence.dedup();
self.status = if self.dismissed {
ConsentHandlingStatus::Dismissed
} else if self.banner_detected
&& matches!(self.status, ConsentHandlingStatus::DismissFailed)
|| matches!(other.status, ConsentHandlingStatus::DismissFailed)
{
ConsentHandlingStatus::DismissFailed
} else if self.banner_detected {
ConsentHandlingStatus::Detected
} else if matches!(self.status, ConsentHandlingStatus::Unknown)
|| matches!(other.status, ConsentHandlingStatus::Unknown)
{
ConsentHandlingStatus::Unknown
} else {
ConsentHandlingStatus::NotDetected
};
}
}
pub async fn inject_consent_cookies(page: &Page, url: &str) {
use chromiumoxide::cdp::browser_protocol::network::{CookieParam, SetCookiesParams};
let scheme_host = extract_scheme_host(url);
if scheme_host.is_empty() {
return;
}
let cookiebot_value = "{\"stamp\":\"\",\"necessary\":true,\"preferences\":true,\"statistics\":true,\"marketing\":true,\"ver\":1}";
let onetrust_consent = "groups=1%3A1%2C2%3A1%2C3%3A1%2C4%3A1&isGpcEnabled=0&isIABGlobal=false&datestamp=Mon+Jan+01+2024+00%3A00%3A00+GMT%2B0000";
let today = "2024-01-01T00:00:00.000Z";
let make = |name: &str, value: &str| -> CookieParam {
let mut c = CookieParam::new(name.to_string(), value.to_string());
c.url = Some(scheme_host.clone());
c.path = Some("/".to_string());
c
};
let cookies: Vec<CookieParam> = vec![
make("CookieConsent", cookiebot_value),
make("OptanonAlertBoxClosed", today),
make("OptanonConsent", onetrust_consent),
make("uc_user_interaction", "true"),
make("cmp_a", "1"),
make(
"borlabs-cookie",
"%7B%22consents%22%3A%7B%22essential%22%3Atrue%2C%22marketing%22%3Atrue%2C%22statistics%22%3Atrue%7D%7D",
),
make("klaro", "e30K"),
make("euconsent-v2", "accepted"),
make(
"CookieScriptConsent",
"{\"action\":\"accept\",\"categories\":[\"functionality\",\"performance\",\"targeting\"]}",
),
make("db_cc_al", "1"),
make("db_cc_nx", "1"),
make("_sp_v1_consent", "1%3A1%7C2%3A1%7C3%3A1%7C4%3A1"),
];
let count = cookies.len();
if page.execute(SetCookiesParams::new(cookies)).await.is_ok() {
debug!("Injected {} consent cookies for: {}", count, scheme_host);
} else {
debug!("Consent cookie injection failed (continuing without)");
}
}
pub async fn handle_post_navigation(page: &Page, try_dismiss: bool) -> ConsentResult {
let (detected, evidence) = detect_consent_banner(page).await;
if detected == Some(false) {
return ConsentResult {
banner_detected: false,
cmp_name: None,
dismissed: false,
status: ConsentHandlingStatus::NotDetected,
evidence: evidence.into_iter().collect(),
};
}
if detected.is_none() {
return ConsentResult {
banner_detected: false,
cmp_name: None,
dismissed: false,
status: ConsentHandlingStatus::Unknown,
evidence: vec!["Consent detection could not be evaluated".to_string()],
};
}
let cmp_name = identify_cmp(page).await;
info!(
"Consent banner detected (CMP: {})",
cmp_name.as_deref().unwrap_or("unknown")
);
if !try_dismiss {
return ConsentResult {
banner_detected: true,
cmp_name,
dismissed: false,
status: ConsentHandlingStatus::Detected,
evidence: evidence.into_iter().collect(),
};
}
let dismissed = dismiss_banner_js(page).await;
if dismissed {
tokio::time::sleep(tokio::time::Duration::from_millis(600)).await;
info!("Consent banner dismissed via JS click");
} else {
debug!("JS dismiss attempted but no button found");
}
ConsentResult {
banner_detected: true,
cmp_name,
dismissed,
status: if dismissed {
ConsentHandlingStatus::Dismissed
} else {
ConsentHandlingStatus::DismissFailed
},
evidence: evidence.into_iter().collect(),
}
}
async fn detect_consent_banner(page: &Page) -> (Option<bool>, Option<String>) {
let js = r#"
(function() {
// Check visible role=dialog elements with consent-related content
const dialogs = document.querySelectorAll('[role="dialog"],[role="alertdialog"]');
for (const d of dialogs) {
const rect = d.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const text = (d.textContent || '').toLowerCase();
if (text.includes('cookie') || text.includes('consent') || text.includes('datenschutz') ||
text.includes('privacy') || text.includes('daten') || text.includes('accept') ||
text.includes('akzeptieren') || text.includes('zustimmen')) {
return { detected: true, evidence: 'visible consent-related dialog' };
}
}
}
// Known CMP container IDs and classes
const selectors = [
'#CybotCookiebotDialog',
'#onetrust-banner-sdk',
'#onetrust-consent-sdk',
'#usercentrics-root',
'#cmp-app-container',
'#gdpr-consent-tool-wrapper',
'#klaro',
'.cookie-consent-banner',
'.cookie-banner',
'.cookie-notice',
'#cookie-law-info-bar',
'#cookiebanner',
'#cookieNotice',
'.cc-window',
'[id*="cookie"][id*="banner"]',
'[class*="consent-banner"]',
'[id*="consent-banner"]'
];
for (const sel of selectors) {
try {
const el = document.querySelector(sel);
if (el) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) return { detected: true, evidence: 'visible CMP or consent container: ' + sel };
}
} catch(e) {}
}
return { detected: false, evidence: 'no visible allowlisted or heuristic consent surface' };
})()
"#;
let value = page
.evaluate(js)
.await
.ok()
.and_then(|result| result.value().cloned());
(
value
.as_ref()
.and_then(|value| value.get("detected"))
.and_then(|value| value.as_bool()),
value
.as_ref()
.and_then(|value| value.get("evidence"))
.and_then(|value| value.as_str())
.map(str::to_string),
)
}
async fn identify_cmp(page: &Page) -> Option<String> {
let js = r#"
(function() {
if (document.querySelector('#CybotCookiebotDialog,#CybotCookiebotDialogBody')) return 'cookiebot';
if (document.querySelector('#onetrust-banner-sdk,#onetrust-consent-sdk')) return 'onetrust';
if (document.querySelector('#usercentrics-root')) return 'usercentrics';
if (document.querySelector('#klaro,.klaro')) return 'klaro';
if (document.querySelector('#cmp-app-container')) return 'consentmanager';
if (document.querySelector('.cc-window,.cc-banner')) return 'cookieconsent';
if (document.querySelector('[id*="borlabs"]')) return 'borlabs';
if (document.querySelector('#cookie-law-info-bar,[id*="cli_"]')) return 'cookie-law-info';
return null;
})()
"#;
page.evaluate(js)
.await
.ok()
.and_then(|r| r.value().and_then(|v| v.as_str().map(|s| s.to_owned())))
}
async fn dismiss_banner_js(page: &Page) -> bool {
let js = r#"
(function() {
const selectors = [
// Cookiebot
'#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
'#CybotCookiebotDialogBodyButtonAccept',
// OneTrust
'#onetrust-accept-btn-handler',
'.onetrust-accept-btn-handler',
// Usercentrics
'[data-testid="uc-accept-all-button"]',
// Consentmanager
'#cmp_btn-acceptall',
'.cmp_btn-acceptall',
// Klaro
'.klaro .cm-btn-accept-all',
// Cookie Consent (osano)
'.cc-accept',
'.cc-btn.cc-allow',
// Borlabs
'.borlabs-cookie__btn--accept',
// Generic patterns (English)
'button[id*="accept-all" i]',
'button[id*="acceptall" i]',
'button[id*="accept_all" i]',
'button[class*="accept-all" i]',
'button[class*="acceptall" i]',
'a[id*="accept-all" i]',
'a[class*="accept-all" i]',
// Generic patterns (German)
'button[id*="zustimmen" i]',
'button[class*="zustimmen" i]',
'button[id*="akzeptieren" i]',
'button[class*="akzeptieren" i]',
'button[id*="alle-akzeptieren" i]',
// Data attributes
'[data-accept-all]',
'[data-consent="accept"]',
// Last resort: any button with accept-like text in a dialog
'[role="dialog"] button',
];
for (const sel of selectors) {
try {
const btns = document.querySelectorAll(sel);
for (const btn of btns) {
const rect = btn.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
if (sel === '[role="dialog"] button') {
const text = (btn.textContent || '').toLowerCase().trim();
const isAccept = text.includes('accept') || text.includes('allow') ||
text.includes('agree') || text.includes('akzeptieren') ||
text.includes('zustimmen') || text.includes('alle erlauben') ||
text.includes('alle akzeptieren');
if (!isAccept) continue;
}
btn.click();
return true;
}
}
} catch(e) {}
}
return false;
})()
"#;
page.evaluate(js)
.await
.ok()
.and_then(|r| r.value().and_then(|v| v.as_bool()))
.unwrap_or(false)
}
fn extract_scheme_host(url: &str) -> String {
let after_scheme = if let Some(rest) = url.strip_prefix("https://") {
format!("https://{}", rest.split('/').next().unwrap_or(""))
} else if let Some(rest) = url.strip_prefix("http://") {
format!("http://{}", rest.split('/').next().unwrap_or(""))
} else {
return String::new();
};
after_scheme
}