use std::collections::HashMap;
use std::sync::Arc;
use crate::cdp::{MouseButton, MouseEventType, Session};
use crate::error::{Error, Result};
use crate::keyboard::{key_to_codes, parse_key_combo};
use crate::session::{BrowserState, SessionCookie};
use crate::stealth::Human;
use crate::StealthConfig;
pub use crate::element::{BoundingBox, Element};
const POLL_INTERVAL_MS: u64 = 100;
const SETTLE_MS: u64 = 100;
pub(crate) const INTERACTION_DELAY_MS: u64 = 50;
pub(crate) async fn sleep_ms(ms: u64) {
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
}
pub(crate) fn escape_js_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'"' => out.push_str("\\\""),
'`' => out.push_str("\\`"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\0' => out.push_str("\\x00"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
'$' if chars.peek() == Some(&'{') => {
out.push_str("\\${");
chars.next();
}
_ => out.push(ch),
}
}
out
}
fn is_missing_node_message(message: &str) -> bool {
message.contains("Could not find node") || message.contains("No node with given id")
}
fn is_stale_node_error(e: &Error) -> bool {
matches!(e, Error::Cdp { message, .. } if is_missing_node_message(message))
}
fn is_element_cdp_error(e: &Error) -> bool {
match e {
Error::ElementNotFound(_) | Error::ElementNotVisible { .. } => true,
Error::Cdp { message, .. } => {
message.contains("box model")
|| is_missing_node_message(message)
|| message.contains("Node is not an element")
}
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextMatch {
Exact,
#[default]
Contains,
StartsWith,
EndsWith,
}
#[derive(Clone)]
pub struct Page {
pub(crate) session: Session,
config: Arc<StealthConfig>,
root_node: Arc<std::sync::Mutex<Option<i32>>>,
net_idle_key: String,
}
impl Page {
pub(crate) fn new(session: Session, config: Arc<StealthConfig>) -> Self {
let net_idle_key = format!("_{:012x}", fastrand::u64(..) & 0xffff_ffff_ffff);
Self {
session,
config,
root_node: Arc::new(std::sync::Mutex::new(None)),
net_idle_key,
}
}
async fn document_node(&self) -> Result<i32> {
if let Some(id) = *self.root_node.lock().unwrap() {
return Ok(id);
}
let doc = self.session.get_document(Some(0)).await?;
*self.root_node.lock().unwrap() = Some(doc.node_id);
Ok(doc.node_id)
}
fn invalidate_document(&self) {
*self.root_node.lock().unwrap() = None;
}
async fn with_document<T, F, Fut>(&self, op: F) -> Result<T>
where
F: Fn(i32) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let doc = self.document_node().await?;
match op(doc).await {
Err(ref e) if is_stale_node_error(e) => {
self.invalidate_document();
let doc = self.document_node().await?;
op(doc).await
}
other => other,
}
}
pub fn session(&self) -> &Session {
&self.session
}
pub fn target_id(&self) -> &str {
self.session.target_id()
}
fn check_nav_result(result: &crate::cdp::types::PageNavigateResult) -> Result<()> {
if let Some(ref error) = result.error_text {
if error != "net::ERR_HTTP_RESPONSE_CODE_FAILURE" {
return Err(Error::Navigation(error.clone()));
}
}
Ok(())
}
async fn cookie_url(&self) -> Result<Option<String>> {
let url = self.url().await?;
Ok(if url == "about:blank" {
None
} else {
Some(url)
})
}
pub async fn goto(&self, url: &str) -> Result<()> {
self.navigate_impl(url, None).await
}
pub async fn reload(&self) -> Result<()> {
self.session.reload(false).await?;
self.invalidate_document();
Ok(())
}
pub async fn back(&self) -> Result<()> {
self.session.go_back().await?;
self.invalidate_document();
Ok(())
}
pub async fn forward(&self) -> Result<()> {
self.session.go_forward().await?;
self.invalidate_document();
Ok(())
}
pub async fn url(&self) -> Result<String> {
let frame_tree = self.session.get_frame_tree().await?;
Ok(frame_tree.frame.url)
}
pub async fn title(&self) -> Result<String> {
self.evaluate_sync("document.title || ''").await
}
pub async fn content(&self) -> Result<String> {
self.evaluate_sync("document.documentElement.outerHTML")
.await
}
pub async fn text(&self) -> Result<String> {
self.evaluate_sync("document.body?.innerText || ''").await
}
pub async fn screenshot(&self) -> Result<Vec<u8>> {
self.session.capture_screenshot(Some("png"), None).await
}
pub async fn screenshot_jpeg(&self, quality: u8) -> Result<Vec<u8>> {
self.session
.capture_screenshot(Some("jpeg"), Some(quality))
.await
}
pub async fn find(&self, selector: &str) -> Result<Element> {
let node_id = self
.with_document(|doc| self.session.query_selector(doc, selector))
.await?;
if node_id == 0 {
return Err(Error::ElementNotFound(selector.to_string()));
}
Ok(Element {
page: self.clone(),
node_id,
})
}
pub async fn find_all(&self, selector: &str) -> Result<Vec<Element>> {
let node_ids = self
.with_document(|doc| self.session.query_selector_all(doc, selector))
.await?;
Ok(node_ids
.into_iter()
.filter(|&id| id != 0)
.map(|node_id| Element {
page: self.clone(),
node_id,
})
.collect())
}
#[must_use = "returns true if element exists"]
pub async fn exists(&self, selector: &str) -> bool {
self.find(selector).await.is_ok()
}
pub async fn find_by_text(&self, text: &str) -> Result<Element> {
self.find_by_text_match(text, TextMatch::Contains).await
}
pub async fn find_by_text_match(&self, text: &str, match_type: TextMatch) -> Result<Element> {
let escaped_text = escape_js_string(text);
let match_js = match match_type {
TextMatch::Exact => format!("t.trim() === '{}'", escaped_text),
TextMatch::Contains => format!(
"t.toLowerCase().includes('{}')",
escaped_text.to_lowercase()
),
TextMatch::StartsWith => format!(
"t.toLowerCase().startsWith('{}')",
escaped_text.to_lowercase()
),
TextMatch::EndsWith => format!(
"t.toLowerCase().endsWith('{}')",
escaped_text.to_lowercase()
),
};
let js = format!(
r#"
(() => {{
const interactive = 'a, button, input[type="submit"], input[type="button"], [role="button"], [onclick]';
for (const el of document.querySelectorAll(interactive)) {{
const t = el.innerText || el.textContent || el.value || '';
if ({match_js}) return el;
}}
const secondary = 'label, span, div, p, h1, h2, h3, h4, h5, h6, li, td, th';
for (const el of document.querySelectorAll(secondary)) {{
const t = el.innerText || el.textContent || el.value || '';
if ({match_js}) return el;
}}
return null;
}})()
"#,
);
let result = self.session.evaluate_for_remote_object(&js).await?;
let remote = self.check_js_result(result)?;
if remote.subtype.as_deref() == Some("null") {
return Err(Error::ElementNotFound(format!("text: {}", text)));
}
let object_id = remote
.object_id
.ok_or_else(|| Error::ElementNotFound(format!("text: {}", text)))?;
self.document_node().await?;
let node_id = self.session.request_node(&object_id).await?;
if node_id == 0 {
return Err(Error::ElementNotFound(format!("text: {}", text)));
}
Ok(Element {
page: self.clone(),
node_id,
})
}
pub async fn find_all_by_text(&self, text: &str) -> Result<Vec<Element>> {
let escaped_text = escape_js_string(text).to_lowercase();
let js = format!(
r#"
(() => {{
const selectors = 'a, button, input, label, span, div, p, h1, h2, h3, h4, h5, h6, li, td, th';
const elements = document.querySelectorAll(selectors);
const matches = [];
for (const el of elements) {{
const t = (el.innerText || el.textContent || el.value || '').toLowerCase();
if (t.includes('{escaped_text}')) {{
matches.push(el);
}}
}}
return matches;
}})()
"#,
);
let result = self.session.evaluate_for_remote_object(&js).await?;
let remote = self.check_js_result(result)?;
let array_object_id = match &remote.object_id {
Some(id) => id.clone(),
None => return Ok(Vec::new()),
};
let properties = self.session.get_properties(&array_object_id).await?;
self.document_node().await?;
let mut elements = Vec::new();
for prop in &properties {
if prop.name.parse::<usize>().is_err() {
continue;
}
if let Some(ref obj_id) = prop.value.as_ref().and_then(|v| v.object_id.clone()) {
if let Ok(node_id) = self.session.request_node(obj_id).await {
if node_id != 0 {
elements.push(Element {
page: self.clone(),
node_id,
});
}
}
}
}
Ok(elements)
}
#[must_use = "returns true if text exists on page"]
pub async fn text_exists(&self, text: &str) -> bool {
self.find_by_text(text).await.is_ok()
}
pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
self.session
.dispatch_mouse_event(
MouseEventType::MousePressed,
x,
y,
Some(MouseButton::Left),
Some(1),
)
.await?;
sleep_ms(INTERACTION_DELAY_MS).await;
self.session
.dispatch_mouse_event(
MouseEventType::MouseReleased,
x,
y,
Some(MouseButton::Left),
Some(1),
)
.await?;
Ok(())
}
pub async fn click(&self, selector: &str) -> Result<()> {
let element = self.find(selector).await?;
element.click().await
}
pub async fn type_text(&self, text: &str) -> Result<()> {
self.session.insert_text(text).await
}
pub async fn type_into(&self, selector: &str, text: &str) -> Result<()> {
let element = self.find(selector).await?;
element.click().await?;
sleep_ms(INTERACTION_DELAY_MS).await;
self.session.insert_text(text).await
}
pub async fn click_by_text(&self, text: &str) -> Result<()> {
let element = self.find_by_text(text).await?;
element.click().await
}
#[must_use = "returns true if clicked, false if not found/visible"]
pub async fn try_click(&self, selector: &str) -> Result<bool> {
self.try_click_impl(self.find(selector).await).await
}
#[must_use = "returns true if clicked, false if not found/visible"]
pub async fn try_click_by_text(&self, text: &str) -> Result<bool> {
self.try_click_impl(self.find_by_text(text).await).await
}
async fn try_click_impl(&self, find_result: Result<Element>) -> Result<bool> {
match find_result {
Ok(element) => match element.click().await {
Ok(()) => Ok(true),
Err(e) if is_element_cdp_error(&e) => Ok(false),
Err(e) => Err(e),
},
Err(e) if is_element_cdp_error(&e) => Ok(false),
Err(e) => Err(e),
}
}
pub async fn fill(&self, selector: &str, value: &str) -> Result<()> {
let element = self.find(selector).await?;
element.click().await?;
sleep_ms(INTERACTION_DELAY_MS).await;
let escaped = escape_js_string(selector);
self.execute(&format!(
"(() => {{ const el = document.querySelector('{}'); if (el) {{ el.focus(); el.select(); }} }})()",
escaped
)).await?;
self.session.insert_text("").await?;
self.session.insert_text(value).await
}
pub fn human(&self) -> Human<'_> {
Human::new(&self.session)
}
pub async fn human_click(&self, selector: &str) -> Result<()> {
let element = self.find(selector).await?;
let (x, y) = element.center().await?;
self.human_click_at_center_xy(x, y).await
}
pub async fn human_type(&self, selector: &str, text: &str) -> Result<()> {
self.human_click(selector).await?;
sleep_ms(SETTLE_MS).await;
self.human_type_text(text).await
}
pub async fn human_click_by_text(&self, text: &str) -> Result<()> {
let element = self.find_by_text(text).await?;
let (x, y) = element.center().await?;
self.human_click_at_center_xy(x, y).await
}
#[must_use = "returns true if clicked, false if not found/visible"]
pub async fn try_human_click(&self, selector: &str) -> Result<bool> {
self.try_human_click_impl(self.find(selector).await).await
}
#[must_use = "returns true if clicked, false if not found/visible"]
pub async fn try_human_click_by_text(&self, text: &str) -> Result<bool> {
self.try_human_click_impl(self.find_by_text(text).await)
.await
}
pub async fn human_fill(&self, selector: &str, value: &str) -> Result<()> {
self.human_click(selector).await?;
sleep_ms(SETTLE_MS).await;
let escaped = escape_js_string(selector);
let select_js = format!(
"(() => {{ const el = document.querySelector('{escaped}'); \
if (el) {{ el.focus(); if (typeof el.select === 'function') el.select(); }} }})()"
);
self.execute(&select_js).await?;
sleep_ms(INTERACTION_DELAY_MS).await;
self.human_type_text(value).await
}
async fn human_type_text(&self, text: &str) -> Result<()> {
if self.config.human_typing {
self.human().type_text(text).await
} else {
self.session.insert_text(text).await
}
}
async fn human_click_at_center_xy(&self, x: f64, y: f64) -> Result<()> {
if self.config.human_mouse {
self.human().move_and_click(x, y).await
} else {
self.click_at(x, y).await
}
}
async fn try_human_click_impl(&self, find_result: Result<Element>) -> Result<bool> {
match find_result {
Ok(element) => match element.center().await {
Ok((x, y)) => {
self.human_click_at_center_xy(x, y).await?;
Ok(true)
}
Err(e) if is_element_cdp_error(&e) => Ok(false),
Err(e) => Err(e),
},
Err(e) if is_element_cdp_error(&e) => Ok(false),
Err(e) => Err(e),
}
}
pub async fn evaluate<T: serde::de::DeserializeOwned>(&self, expression: &str) -> Result<T> {
self.eval_impl(self.session.evaluate(expression).await?)
}
pub async fn evaluate_sync<T: serde::de::DeserializeOwned>(
&self,
expression: &str,
) -> Result<T> {
self.eval_impl(self.session.evaluate_sync(expression).await?)
}
fn eval_impl<T: serde::de::DeserializeOwned>(
&self,
result: crate::cdp::types::RuntimeEvaluateResult,
) -> Result<T> {
let remote = self.check_js_result(result)?;
let value = remote
.value
.ok_or_else(|| Error::cdp_msg("No value returned from evaluate"))?;
Ok(serde_json::from_value(value)?)
}
pub async fn execute(&self, expression: &str) -> Result<()> {
self.check_js_result(self.session.evaluate(expression).await?)?;
Ok(())
}
pub async fn execute_sync(&self, expression: &str) -> Result<()> {
self.check_js_result(self.session.evaluate_sync(expression).await?)?;
Ok(())
}
fn check_js_result(
&self,
result: crate::cdp::types::RuntimeEvaluateResult,
) -> Result<crate::cdp::types::RemoteObject> {
if let Some(exception) = result.exception_details {
return Err(Error::cdp_msg(format!(
"JavaScript error: {} at {}:{}",
exception.text, exception.line_number, exception.column_number
)));
}
Ok(result.result)
}
pub async fn cookies(&self) -> Result<Vec<SessionCookie>> {
let cookies = self.session.get_cookies(None).await?;
Ok(cookies
.into_iter()
.map(|c| SessionCookie {
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.secure,
http_only: c.http_only,
same_site: c.same_site,
expires: if c.session { None } else { Some(c.expires) },
})
.collect())
}
pub async fn set_cookie(
&self,
name: &str,
value: &str,
domain: Option<&str>,
path: Option<&str>,
) -> Result<()> {
let url = self.cookie_url().await?;
let success = self
.session
.set_cookie(name, value, url.as_deref(), domain, path)
.await?;
if !success {
return Err(Error::cdp_msg("Failed to set cookie"));
}
Ok(())
}
pub async fn delete_cookie(&self, name: &str, domain: Option<&str>) -> Result<()> {
let url = self.cookie_url().await?;
self.session
.delete_cookies(name, url.as_deref(), domain)
.await
}
pub async fn clear_all_cookies(&self) -> Result<()> {
self.session.clear_all_cookies().await
}
pub async fn set_cookies_bulk(&self, cookies: Vec<SessionCookie>) -> Result<()> {
let cdp_cookies = cookies
.into_iter()
.map(|c| crate::cdp::types::NetworkSetCookie {
name: c.name,
value: c.value,
url: None,
domain: Some(c.domain),
path: Some(c.path),
secure: Some(c.secure),
http_only: Some(c.http_only),
same_site: c.same_site,
expires: c.expires,
})
.collect();
self.session.set_cookies(cdp_cookies).await
}
pub async fn capture_state(&self) -> Result<BrowserState> {
let cookies = self.cookies().await?;
let local_storage = self
.evaluate("Object.fromEntries(Object.entries(localStorage))")
.await?;
let session_storage = self
.evaluate("Object.fromEntries(Object.entries(sessionStorage))")
.await?;
let user_agent = self.evaluate("navigator.userAgent").await?;
let url = self.url().await?;
Ok(BrowserState {
cookies,
local_storage,
session_storage,
user_agent,
url,
})
}
pub async fn restore_state(&self, state: &BrowserState) -> Result<()> {
self.set_user_agent(&state.user_agent).await?;
self.goto(&state.url).await?;
self.clear_all_cookies().await?;
self.set_cookies_bulk(state.cookies.clone()).await?;
let storage = serde_json::to_string(&serde_json::json!({
"localStorage": state.local_storage,
"sessionStorage": state.session_storage,
}))?;
self.execute(&format!(
"(() => {{ const state = {storage}; localStorage.clear(); sessionStorage.clear(); for (const [key, value] of Object.entries(state.localStorage)) localStorage.setItem(key, value); for (const [key, value] of Object.entries(state.sessionStorage)) sessionStorage.setItem(key, value); }})()"
))
.await?;
self.goto(&state.url).await
}
pub async fn set_extra_headers(&self, headers: HashMap<String, String>) -> Result<()> {
self.session.set_extra_headers(headers).await
}
pub async fn clear_extra_headers(&self) -> Result<()> {
self.session.clear_extra_headers().await
}
pub async fn goto_with_headers(
&self,
url: &str,
headers: HashMap<String, String>,
) -> Result<()> {
self.session.set_extra_headers(headers).await?;
let result = self.goto(url).await;
let _ = self.session.clear_extra_headers().await;
result
}
pub async fn goto_with_referrer(&self, url: &str, referrer: &str) -> Result<()> {
self.navigate_impl(url, Some(referrer)).await
}
async fn navigate_impl(&self, url: &str, referrer: Option<&str>) -> Result<()> {
let result = self.session.navigate(url, referrer).await?;
Self::check_nav_result(&result)?;
self.invalidate_document();
self.wait_for_load(self.config.cdp_timeout.saturating_mul(1000))
.await;
Ok(())
}
async fn wait_for_load(&self, timeout_ms: u64) {
let start = std::time::Instant::now();
loop {
let state: String = self
.evaluate_sync("document.readyState")
.await
.unwrap_or_default();
if state == "complete" || state == "interactive" {
break;
}
if start.elapsed().as_millis() as u64 >= timeout_ms {
break;
}
sleep_ms(POLL_INTERVAL_MS).await;
}
sleep_ms(SETTLE_MS).await;
}
pub async fn set_bypass_csp(&self, enabled: bool) -> Result<()> {
self.session.set_bypass_csp(enabled).await
}
pub async fn set_user_agent(&self, user_agent: &str) -> Result<()> {
self.session.set_user_agent(user_agent, None).await
}
pub async fn ignore_cert_errors(&self, ignore: bool) -> Result<()> {
self.session.set_ignore_cert_errors(ignore).await
}
pub async fn accept_dialog(&self, prompt_text: Option<&str>) -> Result<()> {
self.session.handle_dialog(true, prompt_text).await
}
pub async fn dismiss_dialog(&self) -> Result<()> {
self.session.handle_dialog(false, None).await
}
async fn poll_until<T, F, Fut>(&self, timeout_ms: u64, error_msg: String, check: F) -> Result<T>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Option<T>>,
{
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_millis(timeout_ms);
loop {
if let Some(val) = check().await {
return Ok(val);
}
if start.elapsed() > timeout {
return Err(Error::Timeout(error_msg));
}
sleep_ms(POLL_INTERVAL_MS).await;
}
}
pub async fn wait_for(&self, selector: &str, timeout_ms: u64) -> Result<Element> {
self.poll_until(
timeout_ms,
format!("Element '{}' not found within {}ms", selector, timeout_ms),
|| async { self.find(selector).await.ok() },
)
.await
}
pub async fn wait_for_visible(&self, selector: &str, timeout_ms: u64) -> Result<Element> {
self.poll_until(
timeout_ms,
format!("Element '{}' not visible within {}ms", selector, timeout_ms),
|| async {
if let Ok(elem) = self.find(selector).await {
if elem.center().await.is_ok() {
return Some(elem);
}
}
None
},
)
.await
}
pub async fn wait_for_hidden(&self, selector: &str, timeout_ms: u64) -> Result<()> {
self.poll_until(
timeout_ms,
format!(
"Element '{}' still visible after {}ms",
selector, timeout_ms
),
|| async {
match self.find(selector).await {
Err(Error::ElementNotFound(_)) => Some(()),
Ok(el) => match el.is_visible().await {
Ok(false) => Some(()),
_ => None,
},
Err(_) => None,
}
},
)
.await
}
pub async fn wait(&self, ms: u64) {
sleep_ms(ms).await;
}
pub async fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<Element> {
self.poll_until(
timeout_ms,
format!(
"Element with text '{}' not found within {}ms",
text, timeout_ms
),
|| async { self.find_by_text(text).await.ok() },
)
.await
}
pub async fn wait_for_url_contains(&self, pattern: &str, timeout_ms: u64) -> Result<()> {
self.poll_until(
timeout_ms,
format!("URL did not contain '{}' within {}ms", pattern, timeout_ms),
|| async {
if let Ok(url) = self.url().await {
if url.contains(pattern) {
return Some(());
}
}
None
},
)
.await
}
pub async fn wait_for_url_change(&self, timeout_ms: u64) -> Result<String> {
let original_url = self.url().await?;
self.poll_until(
timeout_ms,
format!(
"URL did not change from '{}' within {}ms",
original_url, timeout_ms
),
|| async {
if let Ok(url) = self.url().await {
if url != original_url {
return Some(url);
}
}
None
},
)
.await
}
pub async fn enable_request_capture(&self) -> Result<()> {
self.session.network_enable().await
}
pub async fn disable_request_capture(&self) -> Result<()> {
self.session.network_disable().await
}
pub async fn get_response_body(&self, request_id: &str) -> Result<ResponseBody> {
let (body, base64_encoded) = self.session.get_response_body(request_id).await?;
if base64_encoded {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(&body)
.map_err(|e| Error::Decode(e.to_string()))?;
Ok(ResponseBody::Binary(bytes))
} else {
Ok(ResponseBody::Text(body))
}
}
pub async fn find_any(&self, selectors: &[&str]) -> Result<Element> {
for selector in selectors {
if let Ok(element) = self.find(selector).await {
return Ok(element);
}
}
Err(Error::ElementNotFound(format!(
"None of selectors found: {:?}",
selectors
)))
}
pub async fn wait_for_any(&self, selectors: &[&str], timeout_ms: u64) -> Result<Element> {
self.poll_until(
timeout_ms,
format!(
"None of selectors found within {}ms: {:?}",
timeout_ms, selectors
),
|| async { self.find_any(selectors).await.ok() },
)
.await
}
pub async fn wait_for_network_idle(&self, idle_time_ms: u64, timeout_ms: u64) -> Result<()> {
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_millis(timeout_ms);
let idle_duration = std::time::Duration::from_millis(idle_time_ms);
let key = &self.net_idle_key;
let check_idle_js = format!(
r#"
(() => {{
var K = '{key}';
if (window[K] === undefined) {{
Object.defineProperty(window, K, {{
value: {{ n: 0 }}, writable: true, enumerable: false, configurable: true
}});
var st = window[K];
const of = window.fetch;
const wf = function fetch(...a) {{
st.n++;
return of.apply(this, a).finally(() => {{ st.n--; }});
}};
try {{ wf.toString = () => 'function fetch() {{ [native code] }}'; }} catch(e) {{}}
window.fetch = wf;
const oo = XMLHttpRequest.prototype.open;
const os = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(...a) {{ this[K] = true; return oo.apply(this, a); }};
XMLHttpRequest.prototype.send = function(...a) {{
if (this[K]) {{
st.n++;
this.addEventListener('loadend', () => {{ st.n--; }});
}}
return os.apply(this, a);
}};
}}
var pend = window[K].n;
return (document.readyState === 'complete') ? pend : -1;
}})()
"#
);
let mut idle_start: Option<std::time::Instant> = None;
loop {
let pending: i32 = self.evaluate_sync(&check_idle_js).await.unwrap_or(-1);
if pending == 0 {
match idle_start {
Some(start) if start.elapsed() >= idle_duration => {
return Ok(());
}
None => {
idle_start = Some(std::time::Instant::now());
}
_ => {}
}
} else {
idle_start = None;
}
if start.elapsed() > timeout {
tracing::warn!(
"wait_for_network_idle timed out after {}ms with {} pending request(s)",
timeout_ms,
pending
);
return Err(Error::Timeout(format!(
"Network not idle after {}ms ({} pending requests)",
timeout_ms, pending
)));
}
sleep_ms(INTERACTION_DELAY_MS).await;
}
}
pub async fn frames(&self) -> Result<Vec<FrameInfo>> {
let frame_tree = self.session.get_frame_tree().await?;
let mut frames = vec![FrameInfo {
id: frame_tree.frame.id.clone(),
url: frame_tree.frame.url.clone(),
name: frame_tree.frame.name.clone(),
}];
fn collect_frames(children: &[crate::cdp::types::FrameTree], frames: &mut Vec<FrameInfo>) {
for child in children {
frames.push(FrameInfo {
id: child.frame.id.clone(),
url: child.frame.url.clone(),
name: child.frame.name.clone(),
});
collect_frames(&child.child_frames, frames);
}
}
collect_frames(&frame_tree.child_frames, &mut frames);
Ok(frames)
}
pub async fn evaluate_in_frame<T: serde::de::DeserializeOwned>(
&self,
frame_selector: &str,
expression: &str,
) -> Result<T> {
let escaped_frame = escape_js_string(frame_selector);
let escaped_expr = escape_js_string(expression);
let js = format!(
r#"
(() => {{
const iframe = document.querySelector('{escaped_frame}');
if (!iframe || !iframe.contentWindow) throw new Error('Frame not found: {escaped_frame}');
const _exec = new iframe.contentWindow.Function('return (' + '{escaped_expr}' + ')');
return _exec.call(iframe.contentWindow);
}})()
"#,
);
self.evaluate(&js).await
}
pub async fn with_retry<F, Fut, T>(
&self,
attempts: u32,
delay_ms: u64,
operation: F,
) -> Result<T>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let attempts = attempts.max(1);
let mut last_error = String::new();
for attempt in 1..=attempts {
match operation().await {
Ok(result) => return Ok(result),
Err(e) => {
last_error = e.to_string();
if attempt < attempts {
sleep_ms(delay_ms).await;
}
}
}
}
Err(Error::RetryExhausted {
attempts,
last_error,
})
}
pub async fn debug_screenshot(&self, prefix: &str) -> Result<String> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let filename = match &self.config.debug_dir {
Some(dir) => {
std::fs::create_dir_all(dir)?;
format!("{}/{}_{}.png", dir, prefix, timestamp)
}
None => format!("{}_{}.png", prefix, timestamp),
};
let screenshot = self.screenshot().await?;
std::fs::write(&filename, screenshot)?;
Ok(filename)
}
pub async fn debug_state(&self) -> Result<PageState> {
let state: PageState = self
.evaluate(
r#"({
url: location.href,
title: document.title,
input_count: document.querySelectorAll('input').length,
button_count: document.querySelectorAll('button').length,
link_count: document.querySelectorAll('a').length,
form_count: document.querySelectorAll('form').length
})"#,
)
.await
.unwrap_or_else(|_| PageState {
url: "unknown".to_string(),
title: "unknown".to_string(),
input_count: 0,
button_count: 0,
link_count: 0,
form_count: 0,
});
Ok(state)
}
pub async fn upload_file(&self, selector: &str, path: &str) -> Result<()> {
self.upload_files(selector, &[path]).await
}
pub async fn upload_files(&self, selector: &str, paths: &[&str]) -> Result<()> {
let element = self.find(selector).await?;
self.session
.set_file_input_files(
element.node_id,
paths.iter().map(|p| p.to_string()).collect(),
)
.await
}
pub async fn select(&self, selector: &str, value: &str) -> Result<()> {
let (sel, val) = (escape_js_string(selector), escape_js_string(value));
self.execute(&format!(
r#"(()=>{{const el=document.querySelector('{sel}');if(!el)throw new Error('Select not found');const opt=[...el.options].find(o=>o.value==='{val}');if(!opt)throw new Error('Option not found: {val}');el.value='{val}';el.dispatchEvent(new Event('change',{{bubbles:true}}))}})()"#
)).await
}
pub async fn select_by_text(&self, selector: &str, text: &str) -> Result<()> {
let (sel, txt) = (escape_js_string(selector), escape_js_string(text));
self.execute(&format!(
r#"(()=>{{const el=document.querySelector('{sel}');if(!el)throw new Error('Select not found');const opt=[...el.options].find(o=>o.text.trim()==='{txt}');if(!opt)throw new Error('Option not found: {txt}');el.value=opt.value;el.dispatchEvent(new Event('change',{{bubbles:true}}))}})()"#
)).await
}
pub async fn select_multiple(&self, selector: &str, values: &[&str]) -> Result<()> {
let sel = escape_js_string(selector);
let vals = serde_json::to_string(values).unwrap_or_else(|_| "[]".into());
self.execute(&format!(
r#"(()=>{{const el=document.querySelector('{sel}');if(!el)throw new Error('Select not found');const v={vals};for(const o of el.options)o.selected=v.includes(o.value);el.dispatchEvent(new Event('change',{{bubbles:true}}))}})()"#
)).await
}
pub async fn hover(&self, selector: &str) -> Result<()> {
let (x, y) = self.find(selector).await?.center().await?;
self.session
.dispatch_mouse_event(MouseEventType::MouseMoved, x, y, None, None)
.await
}
pub async fn human_hover(&self, selector: &str) -> Result<()> {
let element = self.find(selector).await?;
element.scroll_into_view().await?;
let (x, y) = element.center().await?;
Human::new(&self.session).move_to(x, y).await?;
sleep_ms(SETTLE_MS).await;
Ok(())
}
pub async fn press_key(&self, key: &str) -> Result<()> {
use crate::cdp::types::{InputDispatchKeyEventFull, KeyEventType};
let (mods, key_name) = parse_key_combo(key);
let (key_str, code_str, vk) = key_to_codes(key_name);
let modifiers = if mods != 0 { Some(mods) } else { None };
let make_event = |event_type| InputDispatchKeyEventFull {
r#type: event_type,
modifiers,
key: Some(key_str.into()),
code: Some(code_str.into()),
windows_virtual_key_code: vk,
native_virtual_key_code: vk,
..Default::default()
};
self.session
.dispatch_key_event_full(make_event(KeyEventType::KeyDown))
.await?;
sleep_ms(INTERACTION_DELAY_MS).await;
self.session
.dispatch_key_event_full(make_event(KeyEventType::KeyUp))
.await
}
pub async fn select_all(&self) -> Result<()> {
self.press_key(if cfg!(target_os = "macos") {
"Cmd+A"
} else {
"Ctrl+A"
})
.await
}
pub async fn copy(&self) -> Result<()> {
self.press_key(if cfg!(target_os = "macos") {
"Cmd+C"
} else {
"Ctrl+C"
})
.await
}
pub async fn paste(&self) -> Result<()> {
self.press_key(if cfg!(target_os = "macos") {
"Cmd+V"
} else {
"Ctrl+V"
})
.await
}
}
#[derive(Debug, Clone)]
pub struct CapturedRequest {
pub request_id: String,
pub url: String,
pub method: String,
pub headers: HashMap<String, String>,
pub post_data: Option<String>,
pub resource_type: Option<String>,
pub status: Option<i32>,
pub status_text: Option<String>,
pub response_headers: Option<HashMap<String, String>>,
pub mime_type: Option<String>,
pub timestamp: f64,
pub complete: bool,
}
#[derive(Debug)]
pub enum ResponseBody {
Text(String),
Binary(Vec<u8>),
}
impl ResponseBody {
pub fn as_text(&self) -> Option<&str> {
match self {
ResponseBody::Text(s) => Some(s),
ResponseBody::Binary(_) => None,
}
}
pub fn as_bytes(&self) -> &[u8] {
match self {
ResponseBody::Text(s) => s.as_bytes(),
ResponseBody::Binary(b) => b,
}
}
}
#[derive(Debug, Clone)]
pub struct FrameInfo {
pub id: String,
pub url: String,
pub name: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct PageState {
pub url: String,
pub title: String,
pub input_count: u32,
pub button_count: u32,
pub link_count: u32,
pub form_count: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_js_string() {
assert_eq!(escape_js_string("hello"), "hello");
assert_eq!(escape_js_string("it's"), "it\\'s");
assert_eq!(escape_js_string("line1\nline2"), "line1\\nline2");
assert_eq!(escape_js_string("back\\slash"), "back\\\\slash");
assert_eq!(escape_js_string("${var}"), "\\${var}");
assert_eq!(escape_js_string("a\0b"), "a\\x00b");
assert_eq!(escape_js_string("a\0"), "a\\x00");
assert_eq!(escape_js_string("a\u{0}1"), "a\\x001");
assert_eq!(escape_js_string("a\u{2028}b"), "a\\u2028b");
assert_eq!(escape_js_string("a\u{2029}b"), "a\\u2029b");
}
#[test]
fn test_escape_js_string_null_is_hex_not_octal() {
assert_eq!(escape_js_string("\0"), "\\x00");
assert_ne!(escape_js_string("\0"), "\\0");
assert_eq!(escape_js_string("a\u{0}1"), "a\\x001");
}
#[test]
fn test_escape_js_string_all_special_chars() {
assert_eq!(escape_js_string("'"), "\\'");
assert_eq!(escape_js_string("\""), "\\\"");
assert_eq!(escape_js_string("`"), "\\`");
assert_eq!(escape_js_string("\\"), "\\\\");
assert_eq!(escape_js_string("\n"), "\\n");
assert_eq!(escape_js_string("\r"), "\\r");
assert_eq!(escape_js_string("${"), "\\${");
assert_eq!(escape_js_string("\u{2028}"), "\\u2028");
assert_eq!(escape_js_string("\u{2029}"), "\\u2029");
}
#[test]
fn test_stale_node_error_detection() {
let stale = Error::cdp(
"DOM.resolveNode",
-32000,
"Could not find node with given id",
);
assert!(is_stale_node_error(&stale));
assert!(is_element_cdp_error(&stale));
}
#[test]
fn test_box_model_error_is_element_but_not_stale() {
let box_model = Error::cdp("DOM.getBoxModel", -32000, "box model could not be computed");
assert!(is_element_cdp_error(&box_model));
assert!(!is_stale_node_error(&box_model));
}
#[test]
fn test_element_not_found_is_element_but_not_stale() {
let not_found = Error::ElementNotFound("x".into());
assert!(is_element_cdp_error(¬_found));
assert!(!is_stale_node_error(¬_found));
}
#[test]
fn test_missing_node_message_variants() {
assert!(is_missing_node_message("Could not find node with given id"));
assert!(is_missing_node_message("No node with given id found"));
assert!(!is_missing_node_message("box model could not be computed"));
}
}