use std::sync::Arc;
use futures::future::BoxFuture;
use serde_json::json;
use zendriver_transport::SessionHandle;
use crate::detection::DetectionSnapshot;
use crate::error::DataDomeError;
#[derive(Debug, Clone)]
pub struct DataDomeChallenge {
pub captcha_url: String,
pub site_url: String,
pub user_agent: String,
pub cid: Option<String>,
pub hash: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DataDomeSolution {
pub datadome_cookie: String,
}
pub(crate) type CaptchaSolver = dyn Fn(
DataDomeChallenge,
)
-> BoxFuture<'static, Result<DataDomeSolution, Box<dyn std::error::Error + Send + Sync>>>
+ Send
+ Sync;
pub(crate) fn arc_solver<F, Fut>(f: F) -> Arc<CaptchaSolver>
where
F: Fn(DataDomeChallenge) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<
Output = Result<DataDomeSolution, Box<dyn std::error::Error + Send + Sync>>,
> + Send
+ 'static,
{
Arc::new(move |challenge| Box::pin(f(challenge)))
}
pub(crate) async fn build_challenge(
session: &SessionHandle,
snap: &DetectionSnapshot,
) -> Result<DataDomeChallenge, DataDomeError> {
let res = session
.call(
"Runtime.evaluate",
json!({
"expression": "({ url: location.href, ua: navigator.userAgent })",
"returnByValue": true,
}),
)
.await?;
let v = res
.get("result")
.and_then(|r| r.get("value"))
.cloned()
.unwrap_or(serde_json::Value::Null);
let site_url = v
.get("url")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let user_agent = v
.get("ua")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
if site_url.is_empty() || user_agent.is_empty() {
tracing::warn!(
"datadome: build_challenge read empty url/ua — solver may receive an incomplete challenge"
);
}
Ok(DataDomeChallenge {
captcha_url: snap.captcha_url.clone().unwrap_or_default(),
site_url,
user_agent,
cid: snap
.dd
.as_ref()
.and_then(|d| d.cid.clone())
.or_else(|| snap.datadome.clone()),
hash: snap.dd.as_ref().and_then(|d| d.hsh.clone()),
})
}
pub(crate) async fn apply_solution(
session: &SessionHandle,
solution: &DataDomeSolution,
site_url: &str,
) -> Result<(), DataDomeError> {
let domain = cookie_domain(site_url);
let res = session
.call(
"Network.setCookie",
json!({
"name": "datadome",
"value": solution.datadome_cookie,
"domain": domain,
"path": "/",
"secure": true,
"sameSite": "Lax",
}),
)
.await?;
if res.get("success").and_then(|s| s.as_bool()) == Some(false) {
tracing::warn!(
domain = %domain,
"datadome: Network.setCookie reported success=false — the solved cookie was rejected (bad domain?); clearance will likely time out"
);
}
session.call("Page.reload", json!({})).await?;
Ok(())
}
fn cookie_domain(site_url: &str) -> String {
let host = site_url
.split("://")
.nth(1)
.unwrap_or(site_url)
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
let labels: Vec<&str> = host.split('.').collect();
if labels.len() >= 3 {
format!(".{}", labels[labels.len() - 2..].join("."))
} else if labels.len() == 2 {
format!(".{host}")
} else {
host.to_string()
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
use zendriver_transport::testing::MockConnection;
#[test]
fn cookie_domain_derives_etld_plus_one() {
assert_eq!(cookie_domain("https://shop.example.com/x"), ".example.com");
assert_eq!(cookie_domain("https://example.com/"), ".example.com");
assert_eq!(
cookie_domain("https://a.b.example.com/p?q=1"),
".example.com"
);
assert_eq!(cookie_domain("http://localhost:8080/"), "localhost");
assert_eq!(cookie_domain("https://shop.example.co.uk/"), ".co.uk");
}
#[tokio::test]
async fn build_challenge_handles_null_eval_without_panicking() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let snap = crate::detection::DetectionSnapshot {
surface: crate::detection::DataDomeSurface::Captcha,
datadome: Some("CID".into()),
dd: None,
captcha_url: Some("https://geo.captcha-delivery.com/captcha/?cid=CID".into()),
body_clean: false,
};
let fut = tokio::spawn({
let s = sess.clone();
async move { build_challenge(&s, &snap).await }
});
let id = mock.expect_cmd("Runtime.evaluate").await;
mock.reply(id, json!({"result":{"type":"undefined"}})).await; let ch = fut.await.unwrap().unwrap();
assert_eq!(
ch.captcha_url,
"https://geo.captcha-delivery.com/captcha/?cid=CID"
);
assert_eq!(ch.cid.as_deref(), Some("CID")); assert!(ch.site_url.is_empty() && ch.user_agent.is_empty());
conn.shutdown();
}
#[tokio::test]
async fn apply_solution_sets_cookie_then_reloads() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
let sol = DataDomeSolution {
datadome_cookie: "SOLVED_DD".into(),
};
let fut = tokio::spawn({
let s = sess.clone();
async move { apply_solution(&s, &sol, "https://shop.example.com/x").await }
});
let id_cookie = mock.expect_cmd("Network.setCookie").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["name"], "datadome");
assert_eq!(sent["params"]["value"], "SOLVED_DD");
assert_eq!(sent["params"]["domain"], ".example.com");
mock.reply(id_cookie, json!({ "success": true })).await;
let id_reload = mock.expect_cmd("Page.reload").await;
mock.reply(id_reload, json!({})).await;
fut.await.unwrap().unwrap();
conn.shutdown();
}
}