use std::time::Duration;
use serde::Deserialize;
use serde_json::{Value, json};
use tokio::time::{Instant, Interval, MissedTickBehavior};
use zendriver_transport::SessionHandle;
use crate::click::click_at;
use crate::detection::detect_challenge;
use crate::error::CloudflareError;
#[derive(Debug, Clone)]
pub enum ClearanceOutcome {
TokenAcquired(String),
ChallengeGone,
}
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(500);
#[derive(Debug)]
pub struct CloudflareBypass<'a> {
pub(crate) session: &'a SessionHandle,
pub(crate) poll_interval: Duration,
}
impl<'a> CloudflareBypass<'a> {
pub fn new(session: &'a SessionHandle) -> Self {
Self {
session,
poll_interval: DEFAULT_POLL_INTERVAL,
}
}
#[must_use]
pub fn poll_interval(mut self, dur: Duration) -> Self {
self.poll_interval = dur;
self
}
pub async fn wait_for_clearance(
self,
timeout: Duration,
) -> Result<ClearanceOutcome, CloudflareError> {
let bbox = detect_challenge(self.session)
.await?
.ok_or(CloudflareError::NoChallenge)?;
let click_x = bbox.x + bbox.width * 0.15;
let click_y = bbox.y + bbox.height * 0.50;
click_at(self.session, click_x, click_y).await?;
let deadline = Instant::now() + timeout;
let mut ticker: Interval = tokio::time::interval(self.poll_interval);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
if Instant::now() >= deadline {
return Err(CloudflareError::ClearanceTimeout);
}
tokio::select! {
_ = ticker.tick() => {}
() = tokio::time::sleep_until(deadline) => {
return Err(CloudflareError::ClearanceTimeout);
}
}
match poll_once(self.session).await? {
PollResult {
done: true,
token: Some(t),
} => {
return Ok(ClearanceOutcome::TokenAcquired(t));
}
PollResult {
done: true,
token: None,
} => {
return Ok(ClearanceOutcome::ChallengeGone);
}
_ => continue,
}
}
}
}
#[derive(Debug, Deserialize)]
struct PollResult {
done: bool,
#[serde(default)]
token: Option<String>,
}
const POLL_JS: &str = r#"
(function () {
var input =
document.querySelector('[name="cf-turnstile-response"]') ||
document.querySelector('[name="cf_challenge_response"]');
if (input && input.value) {
return { done: true, token: input.value };
}
function findIframe(root) {
var iframes = root.querySelectorAll ? root.querySelectorAll("iframe") : [];
for (var i = 0; i < iframes.length; i++) {
if (iframes[i].src && iframes[i].src.includes("challenges.cloudflare.com")) {
return true;
}
}
var all = root.querySelectorAll ? root.querySelectorAll("*") : [];
for (var j = 0; j < all.length; j++) {
if (all[j].shadowRoot && findIframe(all[j].shadowRoot)) {
return true;
}
}
return false;
}
if (!findIframe(document)) {
return { done: true, token: null };
}
return { done: false, token: null };
})()
"#;
async fn poll_once(session: &SessionHandle) -> Result<PollResult, CloudflareError> {
let res = session
.call(
"Runtime.evaluate",
json!({
"expression": POLL_JS,
"returnByValue": true,
"awaitPromise": true,
}),
)
.await?;
if let Some(details) = res.get("exceptionDetails") {
let msg = details
.get("exception")
.and_then(|e| e.get("description"))
.and_then(|d| d.as_str())
.unwrap_or("unknown")
.to_string();
return Err(CloudflareError::JsError(msg));
}
let value = res
.get("result")
.and_then(|r| r.get("value"))
.cloned()
.unwrap_or(Value::Null);
serde_json::from_value(value)
.map_err(|e| CloudflareError::JsError(format!("invalid poll payload: {e}")))
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use zendriver_transport::testing::MockConnection;
#[tokio::test]
async fn wait_for_clearance_clicks_at_bbox_offset_then_returns_token() {
let (mut mock, conn) = MockConnection::pair();
let sess = SessionHandle::new(conn.clone(), "S1");
const BBOX_X: f64 = 100.0;
const BBOX_Y: f64 = 200.0;
const BBOX_W: f64 = 60.0;
const BBOX_H: f64 = 40.0;
const EXPECTED_CLICK_X: f64 = BBOX_X + BBOX_W * 0.15;
const EXPECTED_CLICK_Y: f64 = BBOX_Y + BBOX_H * 0.50;
let fut = tokio::spawn({
let s = sess.clone();
async move {
let b = CloudflareBypass::new(&s).poll_interval(Duration::from_millis(1));
b.wait_for_clearance(Duration::from_secs(5)).await
}
});
let id_detect = mock.expect_cmd("Runtime.evaluate").await;
assert!(
mock.last_sent()["params"]["expression"]
.as_str()
.unwrap()
.contains("challenges.cloudflare.com"),
"first eval should be the detect.js shadow-DOM walker"
);
mock.reply(
id_detect,
json!({
"result": {
"type": "object",
"value": { "x": BBOX_X, "y": BBOX_Y, "width": BBOX_W, "height": BBOX_H }
}
}),
)
.await;
let id_move = mock.expect_cmd("Input.dispatchMouseEvent").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["type"], "mouseMoved");
assert_eq!(sent["params"]["x"], EXPECTED_CLICK_X);
assert_eq!(sent["params"]["y"], EXPECTED_CLICK_Y);
mock.reply(id_move, json!({})).await;
let id_press = mock.expect_cmd("Input.dispatchMouseEvent").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["type"], "mousePressed");
assert_eq!(sent["params"]["button"], "left");
assert_eq!(sent["params"]["clickCount"], 1);
assert_eq!(sent["params"]["x"], EXPECTED_CLICK_X);
assert_eq!(sent["params"]["y"], EXPECTED_CLICK_Y);
mock.reply(id_press, json!({})).await;
let id_rel = mock.expect_cmd("Input.dispatchMouseEvent").await;
let sent = mock.last_sent();
assert_eq!(sent["params"]["type"], "mouseReleased");
assert_eq!(sent["params"]["button"], "left");
assert_eq!(sent["params"]["clickCount"], 1);
mock.reply(id_rel, json!({})).await;
let id_poll = mock.expect_cmd("Runtime.evaluate").await;
assert!(
mock.last_sent()["params"]["expression"]
.as_str()
.unwrap()
.contains("cf-turnstile-response"),
"poll eval should look at the cf-turnstile-response input"
);
mock.reply(
id_poll,
json!({
"result": {
"type": "object",
"value": { "done": true, "token": "TOKEN_XYZ" }
}
}),
)
.await;
let outcome = fut.await.unwrap().unwrap();
match outcome {
ClearanceOutcome::TokenAcquired(t) => assert_eq!(t, "TOKEN_XYZ"),
other => panic!("expected TokenAcquired, got {other:?}"),
}
conn.shutdown();
}
}