use crate::browser::Page;
use anyhow::Result;
use std::time::{Duration, Instant};
pub const DEFAULT_FRAME_RETRY_INTERVAL: Duration = Duration::from_millis(100);
pub const DEFAULT_FRAME_RETRY_TIMEOUT: Duration = Duration::from_secs(8);
fn next_poll_sleep(now: Instant, deadline: Instant, interval: Duration) -> Option<Duration> {
if now >= deadline {
return None;
}
let remaining = deadline.saturating_duration_since(now);
Some(remaining.min(interval))
}
pub(crate) fn escape_js_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\0' => out.push_str("\\0"),
c => out.push(c),
}
}
out
}
fn lookup_iframe_offset(
iframe_offsets: &[(usize, String, String, f64, f64)],
url: &str,
iframe_idx: i64,
) -> (f64, f64) {
if iframe_idx >= 0 {
iframe_offsets
.iter()
.find(|(idx, src, id, _, _)| *idx == iframe_idx as usize && (src == url || id == url))
.map(|(_, _, _, x, y)| (*x, *y))
} else {
iframe_offsets
.iter()
.find(|(_, src, id, _, _)| src == url || id == url)
.map(|(_, _, _, x, y)| (*x, *y))
}
.unwrap_or((0.0, 0.0))
}
pub async fn evaluate_in_all_frames<T>(page: &Page, expression: &str) -> Result<Vec<T>>
where
T: serde::de::DeserializeOwned,
{
let frame_ids = page.frames().await?;
let mut out = Vec::with_capacity(frame_ids.len());
for fid in frame_ids {
match page.evaluate_in_context(expression, &fid).await {
Ok(eval) => {
if let Ok(v) = eval.into_value::<T>() {
out.push(v);
}
}
Err(e) => {
tracing::debug!("frame {:?} disappeared during batch eval: {}", fid, e);
}
}
}
Ok(out)
}
pub async fn evaluate_in_frames_first<T, F>(
page: &Page,
expression: &str,
filter: F,
default: T,
) -> Result<T>
where
T: serde::de::DeserializeOwned + Clone,
F: Fn(&T) -> bool,
{
let all = evaluate_in_all_frames::<T>(page, expression).await?;
Ok(all.into_iter().find(filter).unwrap_or(default))
}
async fn collect_iframe_offsets(
page: &Page,
frame: &crate::FrameId,
) -> Result<Vec<(usize, String, String, f64, f64)>> {
let mut iframe_offsets: Vec<(usize, String, String, f64, f64)> = Vec::new();
let js = r#"
(function() {
const out = [];
const frames = document.querySelectorAll('iframe');
for (let i = 0; i < frames.length; i++) {
const f = frames[i];
const r = f.getBoundingClientRect();
out.push({ idx: i, src: f.src, id: f.id, x: r.left, y: r.top });
}
return out;
})()
"#;
let eval = page.evaluate_in_context(js, frame).await?;
if let Ok(vals) = eval.into_value::<Vec<serde_json::Value>>() {
for v in vals {
if let (Some(idx), Some(x), Some(y)) =
(v["idx"].as_u64(), v["x"].as_f64(), v["y"].as_f64())
{
let src = v["src"].as_str().unwrap_or("").to_string();
let id = v["id"].as_str().unwrap_or("").to_string();
iframe_offsets.push((idx as usize, src, id, x, y));
}
}
}
Ok(iframe_offsets)
}
async fn frame_self_index(page: &Page, frame: &crate::FrameId) -> i64 {
let js = r#"(function() {
try {
const fr = window.parent.frames;
for (let i = 0; i < fr.length; i++) { if (fr[i] === window) return i; }
} catch (e) {}
return -1;
})()"#;
page.evaluate_in_context(js, frame)
.await
.ok()
.and_then(|e| e.into_value::<i64>().ok())
.unwrap_or(-1)
}
async fn frame_viewport_offset(page: &Page, target: &crate::FrameId) -> Result<(f64, f64)> {
use std::collections::HashMap;
let tree = page.frame_tree().await?;
let by_id: HashMap<&str, &crate::browser::FrameTreeNode> =
tree.iter().map(|n| (n.id.inner().as_str(), n)).collect();
let mut chain: Vec<&crate::browser::FrameTreeNode> = Vec::new();
let mut cur = by_id.get(target.inner().as_str()).copied();
while let Some(node) = cur {
chain.push(node);
cur = node
.parent
.as_ref()
.and_then(|p| by_id.get(p.inner().as_str()).copied());
}
let mut ox = 0.0;
let mut oy = 0.0;
for i in 0..chain.len().saturating_sub(1) {
let child = chain[i];
let parent = chain[i + 1];
let kids = collect_iframe_offsets(page, &parent.id).await?;
let idx = frame_self_index(page, &child.id).await;
let off = kids
.iter()
.find(|(kidx, _, _, _, _)| idx >= 0 && *kidx == idx as usize)
.map(|(_, _, _, x, y)| (*x, *y))
.or_else(|| {
let m = lookup_iframe_offset(&kids, &child.url, -1);
(m != (0.0, 0.0)).then_some(m)
});
match off {
Some((x, y)) => {
ox += x;
oy += y;
}
None => tracing::warn!(
"frame_viewport_offset: unresolved iframe edge for {} (idx {idx}) within {}",
child.url,
parent.url
),
}
}
Ok((ox, oy))
}
pub async fn find_element_centre_in_frames(
page: &Page,
selector: &str,
) -> Result<Option<(f64, f64)>> {
let frame_ids = page.frames().await?;
let escaped = escape_js_string(selector);
let js = format!(
r#"(function() {{
const el = document.querySelector('{}');
if (!el) return null;
const r = el.getBoundingClientRect();
return {{ x: r.left + r.width / 2, y: r.top + r.height / 2 }};
}})()"#,
escaped
);
for fid in frame_ids {
match page.evaluate_in_context(&js, &fid).await {
Ok(eval) => {
if let Ok(val) = eval.into_value::<serde_json::Value>() {
if let (Some(x), Some(y)) = (val["x"].as_f64(), val["y"].as_f64()) {
let (offset_x, offset_y) = frame_viewport_offset(page, &fid).await?;
return Ok(Some((x + offset_x, y + offset_y)));
}
}
}
Err(e) => {
tracing::debug!("frame {:?} disappeared during element search: {}", fid, e);
}
}
}
Ok(None)
}
pub async fn find_element_centre_in_frames_retry(
page: &Page,
selector: &str,
timeout: Duration,
interval: Duration,
) -> Result<Option<(f64, f64)>> {
let deadline = Instant::now() + timeout;
loop {
if let Some(centre) = find_element_centre_in_frames(page, selector).await? {
return Ok(Some(centre));
}
match next_poll_sleep(Instant::now(), deadline, interval) {
Some(d) => tokio::time::sleep(d).await,
None => return Ok(None),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FrameTile {
pub index: usize,
pub left: f64,
pub top: f64,
pub width: f64,
pub height: f64,
}
impl FrameTile {
pub fn centre(&self) -> (f64, f64) {
(self.left + self.width / 2.0, self.top + self.height / 2.0)
}
}
pub async fn find_tiles_in_frames(page: &Page, selector: &str) -> Result<Vec<FrameTile>> {
let frame_ids = page.frames().await?;
let escaped = escape_js_string(selector);
let js = format!(
r#"(function() {{
const els = document.querySelectorAll('{}');
if (!els || els.length === 0) return null;
const tiles = [];
for (let i = 0; i < els.length; i++) {{
const r = els[i].getBoundingClientRect();
tiles.push({{ index: i, left: r.left, top: r.top, width: r.width, height: r.height }});
}}
return {{ tiles: tiles }};
}})()"#,
escaped
);
for fid in frame_ids {
let eval = match page.evaluate_in_context(&js, &fid).await {
Ok(e) => e,
Err(e) => {
tracing::debug!("frame {:?} disappeared during tile search: {}", fid, e);
continue;
}
};
let Ok(val) = eval.into_value::<serde_json::Value>() else {
continue;
};
let Some(raw_tiles) = val["tiles"].as_array() else {
continue;
};
if raw_tiles.is_empty() {
continue;
}
let (offset_x, offset_y) = frame_viewport_offset(page, &fid).await?;
let mut out = Vec::with_capacity(raw_tiles.len());
for t in raw_tiles {
if let (Some(index), Some(left), Some(top), Some(width), Some(height)) = (
t["index"].as_u64(),
t["left"].as_f64(),
t["top"].as_f64(),
t["width"].as_f64(),
t["height"].as_f64(),
) {
out.push(FrameTile {
index: index as usize,
left: left + offset_x,
top: top + offset_y,
width,
height,
});
}
}
if !out.is_empty() {
return Ok(out);
}
}
Ok(Vec::new())
}
pub async fn harvest_token_in_frames_retry(
page: &Page,
token_input_name: &str,
timeout: Duration,
interval: Duration,
) -> Result<Option<String>> {
let deadline = Instant::now() + timeout;
loop {
if let Some(tok) = harvest_token_in_frames(page, token_input_name).await? {
return Ok(Some(tok));
}
match next_poll_sleep(Instant::now(), deadline, interval) {
Some(d) => tokio::time::sleep(d).await,
None => return Ok(None),
}
}
}
pub async fn find_iframe_rect_by_src(
page: &Page,
pattern: &str,
) -> Result<Option<(f64, f64, f64, f64)>> {
let escaped = escape_js_string(pattern);
let js = format!(
r#"(() => {{
const frames = document.querySelectorAll('iframe');
for (const f of frames) {{
if (f.src && f.src.includes('{}')) {{
const r = f.getBoundingClientRect();
return {{ left: r.left, top: r.top, width: r.width, height: r.height }};
}}
}}
return null;
}})()"#,
escaped
);
let v = page.evaluate(js.as_str()).await?;
let val = v
.into_value::<serde_json::Value>()
.unwrap_or(serde_json::Value::Null);
if let (Some(l), Some(t), Some(w), Some(h)) = (
val["left"].as_f64(),
val["top"].as_f64(),
val["width"].as_f64(),
val["height"].as_f64(),
) {
Ok(Some((l, t, w, h)))
} else {
Ok(None)
}
}
pub async fn verify_any_token_in_frames(page: &Page) -> Result<bool> {
const ANY_TOKEN_JS: &str = r#"(() => {
const sels = [
'[name="cf-turnstile-response"]',
'[name="g-recaptcha-response"]',
'#g-recaptcha-response',
'[name="h-captcha-response"]',
'[name="captchaToken"]',
'[name="frc-captcha-solution"]',
'[name="altcha"]',
'[name="mcaptcha__token"]',
'[name="cap_token"]',
];
for (const sel of sels) {
try {
const els = document.querySelectorAll(sel);
for (const el of els) {
const v = (el.value || el.textContent || '').trim();
if (v) return true;
}
} catch (_) { /* keep going */ }
}
return false;
})()"#;
let results = evaluate_in_all_frames::<bool>(page, ANY_TOKEN_JS).await?;
Ok(results.into_iter().any(|v| v))
}
pub async fn verify_token_in_frames(page: &Page, token_input_name: &str) -> Result<bool> {
Ok(harvest_token_in_frames(page, token_input_name)
.await?
.is_some())
}
pub async fn harvest_token_in_frames(
page: &Page,
token_input_name: &str,
) -> Result<Option<String>> {
let escaped = escape_js_string(token_input_name);
let js = format!(
r#"(() => {{
const els = document.querySelectorAll('input[name="{0}"], textarea[name="{0}"], #{0}');
for (const el of els) {{
const v = (el.value || el.textContent || '').trim();
if (v) return v;
}}
return null;
}})()"#,
escaped
);
let results = evaluate_in_all_frames::<Option<String>>(page, &js).await?;
Ok(results.into_iter().flatten().find(|v| !v.is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_js_string_all_special_chars() {
let input = "\\'\"\n\r\t\0";
assert_eq!(escape_js_string(input), "\\\\\\\'\\\"\\n\\r\\t\\0");
}
#[test]
fn escape_js_string_backslash() {
assert_eq!(escape_js_string(r"\"), "\\\\");
}
#[test]
fn escape_js_string_single_quote() {
assert_eq!(escape_js_string("'"), "\\'");
}
#[test]
fn escape_js_string_double_quote() {
assert_eq!(escape_js_string("\""), "\\\"");
}
#[test]
fn escape_js_string_newline() {
assert_eq!(escape_js_string("a\nb"), "a\\nb");
}
#[test]
fn escape_js_string_carriage_return() {
assert_eq!(escape_js_string("a\rb"), "a\\rb");
}
#[test]
fn escape_js_string_tab() {
assert_eq!(escape_js_string("a\tb"), "a\\tb");
}
#[test]
fn escape_js_string_null_byte() {
assert_eq!(escape_js_string("a\0b"), "a\\0b");
}
#[test]
fn escape_js_string_mixed() {
let input = "line1\nline2\tcol\0end\\\"'";
assert_eq!(
escape_js_string(input),
"line1\\nline2\\tcol\\0end\\\\\\\"\\'"
);
}
#[test]
fn escape_js_string_no_special_chars() {
assert_eq!(escape_js_string("#simple-id"), "#simple-id");
}
#[test]
fn lookup_iframe_offset_by_index_and_url() {
let offsets = vec![
(0, "a.html".into(), "".into(), 10.0, 20.0),
(1, "b.html".into(), "".into(), 30.0, 40.0),
];
assert_eq!(lookup_iframe_offset(&offsets, "a.html", 0), (10.0, 20.0));
assert_eq!(lookup_iframe_offset(&offsets, "b.html", 1), (30.0, 40.0));
}
#[test]
fn lookup_iframe_offset_fallback_when_index_missing() {
let offsets = vec![(0, "a.html".into(), "".into(), 10.0, 20.0)];
assert_eq!(lookup_iframe_offset(&offsets, "a.html", -1), (10.0, 20.0));
}
#[test]
fn lookup_iframe_offset_disambiguates_duplicate_src() {
let offsets = vec![
(0, "same.html".into(), "".into(), 10.0, 20.0),
(1, "same.html".into(), "".into(), 30.0, 40.0),
];
assert_eq!(lookup_iframe_offset(&offsets, "same.html", 0), (10.0, 20.0));
assert_eq!(lookup_iframe_offset(&offsets, "same.html", 1), (30.0, 40.0));
assert_eq!(
lookup_iframe_offset(&offsets, "same.html", -1),
(10.0, 20.0)
);
}
#[test]
fn lookup_iframe_offset_empty_src_and_id() {
let offsets = vec![
(0, "".into(), "".into(), 5.0, 5.0),
(1, "".into(), "".into(), 15.0, 15.0),
];
assert_eq!(lookup_iframe_offset(&offsets, "", 0), (5.0, 5.0));
assert_eq!(lookup_iframe_offset(&offsets, "", 1), (15.0, 15.0));
}
#[test]
fn lookup_iframe_offset_no_match() {
let offsets = vec![(0, "a.html".into(), "".into(), 10.0, 20.0)];
assert_eq!(
lookup_iframe_offset(&offsets, "missing.html", -1),
(0.0, 0.0)
);
}
#[test]
fn find_element_js_contains_query_selector() {
let selector = "#btn";
let escaped = escape_js_string(selector);
let js = format!(
r#"(function() {{ const el = document.querySelector('{}'); if (!el) return null; const r = el.getBoundingClientRect(); return {{ x: r.left + r.width / 2, y: r.top + r.height / 2, url: window.location.href }}; }})()"#,
escaped
);
assert!(js.contains("document.querySelector"));
assert!(js.contains("getBoundingClientRect"));
}
#[test]
fn frame_tile_centre_is_box_midpoint() {
let t = FrameTile {
index: 4,
left: 100.0,
top: 200.0,
width: 60.0,
height: 40.0,
};
assert_eq!(t.centre(), (130.0, 220.0));
}
#[test]
fn find_tiles_js_collects_all_matches_with_rects() {
let escaped = escape_js_string(".rc-imageselect-tile");
let js = format!(
r#"(function() {{
const els = document.querySelectorAll('{}');
if (!els || els.length === 0) return null;
const tiles = [];
for (let i = 0; i < els.length; i++) {{
const r = els[i].getBoundingClientRect();
tiles.push({{ index: i, left: r.left, top: r.top, width: r.width, height: r.height }});
}}
return {{ tiles: tiles }};
}})()"#,
escaped
);
assert!(js.contains("querySelectorAll"));
assert!(js.contains("getBoundingClientRect"));
assert!(js.contains("width: r.width"));
assert!(js.contains("index: i"));
}
#[test]
fn verify_token_js_contains_input_selector() {
let name = "g-recaptcha-response";
let escaped = escape_js_string(name);
let js = format!(
r#"!!document.querySelector('input[name="{}"][value]:not([value=""])')"#,
escaped
);
assert!(js.contains("input[name="));
assert!(js.contains("value]:not([value=\"\"])"));
}
#[test]
fn verify_token_escapes_quotes() {
let name = r#"token"value"#;
let escaped = escape_js_string(name);
assert!(escaped.contains("\\\""));
for (i, ch) in escaped.char_indices() {
if ch == '"' {
assert!(
i > 0 && escaped.as_bytes()[i - 1] == b'\\',
"quote at {} not escaped",
i
);
}
}
}
#[test]
fn next_poll_sleep_returns_interval_when_deadline_far() {
let now = Instant::now();
let deadline = now + Duration::from_secs(10);
let interval = Duration::from_millis(100);
let s = next_poll_sleep(now, deadline, interval).unwrap();
assert_eq!(s, Duration::from_millis(100));
}
#[test]
fn next_poll_sleep_clamps_to_remaining_when_close_to_deadline() {
let now = Instant::now();
let deadline = now + Duration::from_millis(40);
let interval = Duration::from_millis(100);
let s = next_poll_sleep(now, deadline, interval).unwrap();
assert!(s <= Duration::from_millis(40));
assert!(s >= Duration::from_millis(30));
}
#[test]
fn next_poll_sleep_returns_none_at_deadline() {
let now = Instant::now();
let deadline = now;
assert!(next_poll_sleep(now, deadline, Duration::from_millis(100)).is_none());
}
#[test]
fn next_poll_sleep_returns_none_past_deadline() {
let now = Instant::now();
let deadline = now - Duration::from_millis(1);
assert!(next_poll_sleep(now, deadline, Duration::from_millis(100)).is_none());
}
#[test]
fn next_poll_sleep_zero_interval_still_yields_zero_sleep() {
let now = Instant::now();
let deadline = now + Duration::from_millis(50);
let s = next_poll_sleep(now, deadline, Duration::ZERO).unwrap();
assert_eq!(s, Duration::ZERO);
}
#[test]
fn default_retry_constants_are_sane() {
assert!(DEFAULT_FRAME_RETRY_INTERVAL > Duration::ZERO);
assert!(DEFAULT_FRAME_RETRY_TIMEOUT > DEFAULT_FRAME_RETRY_INTERVAL);
let max_polls =
DEFAULT_FRAME_RETRY_TIMEOUT.as_millis() / DEFAULT_FRAME_RETRY_INTERVAL.as_millis() + 1;
assert!(
max_polls <= 200,
"default retry would issue {max_polls} CDP calls per attempt, too chatty",
);
}
#[test]
fn verify_token_escapes_null_and_newline() {
let name = "token\0value\n";
let escaped = escape_js_string(name);
assert!(escaped.contains("\\0"));
assert!(escaped.contains("\\n"));
assert!(!escaped.contains('\0'));
assert!(!escaped.contains('\n'));
}
#[test]
fn escape_js_string_empty() {
assert_eq!(escape_js_string(""), "");
}
#[test]
fn escape_js_string_unicode_untouched() {
let input = "emoji: 🎉 café ñ";
assert_eq!(escape_js_string(input), input);
}
#[test]
fn escape_js_string_preserves_length_hint() {
let input = "a".repeat(1000);
let out = escape_js_string(&input);
assert_eq!(out, input); }
#[test]
fn lookup_iframe_offset_matches_by_id() {
let offsets = vec![(0, "a.html".into(), "iframe-0".into(), 10.0, 20.0)];
assert_eq!(lookup_iframe_offset(&offsets, "iframe-0", -1), (10.0, 20.0));
}
#[test]
fn lookup_iframe_offset_index_mismatch_falls_back_to_first_match() {
let offsets = vec![
(0, "a.html".into(), "".into(), 10.0, 20.0),
(1, "b.html".into(), "".into(), 30.0, 40.0),
];
assert_eq!(lookup_iframe_offset(&offsets, "a.html", 99), (0.0, 0.0));
assert_eq!(lookup_iframe_offset(&offsets, "a.html", -1), (10.0, 20.0));
}
#[test]
fn lookup_iframe_offset_negative_beyond_minus_one_treated_as_fallback() {
let offsets = vec![(0, "x".into(), "".into(), 5.0, 6.0)];
assert_eq!(lookup_iframe_offset(&offsets, "x", -5), (5.0, 6.0));
}
#[test]
fn next_poll_sleep_interval_larger_than_remaining() {
let now = Instant::now();
let deadline = now + Duration::from_millis(30);
let interval = Duration::from_millis(100);
let s = next_poll_sleep(now, deadline, interval).unwrap();
assert_eq!(s, Duration::from_millis(30));
}
#[test]
fn next_poll_sleep_very_small_remaining() {
let now = Instant::now();
let deadline = now + Duration::from_nanos(1);
let s = next_poll_sleep(now, deadline, Duration::from_millis(100)).unwrap();
assert_eq!(s, Duration::from_nanos(1));
}
}