1030 lines
33 KiB
Rust
1030 lines
33 KiB
Rust
use std::net::TcpStream;
|
|
use std::process::Stdio;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Context;
|
|
use async_trait::async_trait;
|
|
use base64::Engine;
|
|
use fantoccini::actions::{InputSource, MouseActions, PointerAction};
|
|
use fantoccini::key::Key;
|
|
use fantoccini::{Client, ClientBuilder, Locator};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Map, Value, json};
|
|
use tracing;
|
|
|
|
use crate::config::BrowserConfig;
|
|
use crate::tools::traits::{Tool, ToolResult};
|
|
|
|
const CHROME_CANDIDATES: &[&str] = &[
|
|
"google-chrome",
|
|
"chromium-browser",
|
|
"chromium",
|
|
"google-chrome-stable",
|
|
"chrome",
|
|
];
|
|
|
|
const CHROMEDRIVER_CANDIDATES: &[&str] = &["chromedriver"];
|
|
|
|
pub struct BrowserTool {
|
|
webdriver_url: String,
|
|
headless: bool,
|
|
chrome_path: Option<String>,
|
|
state: tokio::sync::Mutex<BrowserState>,
|
|
driver: std::sync::Mutex<Option<tokio::process::Child>>,
|
|
}
|
|
|
|
struct BrowserState {
|
|
client: Option<Client>,
|
|
}
|
|
|
|
impl Drop for BrowserTool {
|
|
fn drop(&mut self) {
|
|
if let Ok(mut driver) = self.driver.lock() {
|
|
if let Some(ref mut child) = driver.take() {
|
|
tracing::debug!("Stopping chromedriver process");
|
|
let _ = child.start_kill();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl BrowserTool {
|
|
pub fn new(config: &BrowserConfig) -> Self {
|
|
Self {
|
|
webdriver_url: config.webdriver_url.clone(),
|
|
headless: config.headless,
|
|
chrome_path: config.chrome_path.clone(),
|
|
state: tokio::sync::Mutex::new(BrowserState { client: None }),
|
|
driver: std::sync::Mutex::new(None),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum BrowserAction {
|
|
Open { url: String },
|
|
Snapshot {
|
|
#[serde(default)]
|
|
interactive_only: bool,
|
|
#[serde(default)]
|
|
compact: bool,
|
|
#[serde(default)]
|
|
depth: Option<u32>,
|
|
},
|
|
Click { selector: String },
|
|
Fill { selector: String, value: String },
|
|
Type { selector: String, text: String },
|
|
GetText { selector: String },
|
|
GetTitle,
|
|
GetUrl,
|
|
Screenshot {
|
|
#[serde(default)]
|
|
path: Option<String>,
|
|
#[serde(default)]
|
|
full_page: bool,
|
|
},
|
|
Wait {
|
|
#[serde(default)]
|
|
selector: Option<String>,
|
|
#[serde(default)]
|
|
ms: Option<u64>,
|
|
#[serde(default)]
|
|
text: Option<String>,
|
|
},
|
|
Press { key: String },
|
|
Hover { selector: String },
|
|
Scroll {
|
|
direction: String,
|
|
#[serde(default)]
|
|
pixels: Option<u32>,
|
|
},
|
|
Close,
|
|
}
|
|
|
|
fn parse_browser_action(action_str: &str, args: &Value) -> anyhow::Result<BrowserAction> {
|
|
match action_str {
|
|
"open" => {
|
|
let url = args
|
|
.get("url")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing 'url' for open action"))?;
|
|
Ok(BrowserAction::Open {
|
|
url: url.to_string(),
|
|
})
|
|
}
|
|
"snapshot" => Ok(BrowserAction::Snapshot {
|
|
interactive_only: args
|
|
.get("interactive_only")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(true),
|
|
compact: args
|
|
.get("compact")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(true),
|
|
depth: args
|
|
.get("depth")
|
|
.and_then(|v| v.as_u64())
|
|
.map(|d| u32::try_from(d).unwrap_or(u32::MAX)),
|
|
}),
|
|
"click" => {
|
|
let selector = args
|
|
.get("selector")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for click"))?;
|
|
Ok(BrowserAction::Click {
|
|
selector: selector.to_string(),
|
|
})
|
|
}
|
|
"fill" => {
|
|
let selector = args
|
|
.get("selector")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for fill"))?;
|
|
let value = args
|
|
.get("value")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing 'value' for fill"))?;
|
|
Ok(BrowserAction::Fill {
|
|
selector: selector.to_string(),
|
|
value: value.to_string(),
|
|
})
|
|
}
|
|
"type" => {
|
|
let selector = args
|
|
.get("selector")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for type"))?;
|
|
let text = args
|
|
.get("text")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing 'text' for type"))?;
|
|
Ok(BrowserAction::Type {
|
|
selector: selector.to_string(),
|
|
text: text.to_string(),
|
|
})
|
|
}
|
|
"get_text" => {
|
|
let selector = args
|
|
.get("selector")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for get_text"))?;
|
|
Ok(BrowserAction::GetText {
|
|
selector: selector.to_string(),
|
|
})
|
|
}
|
|
"get_title" => Ok(BrowserAction::GetTitle),
|
|
"get_url" => Ok(BrowserAction::GetUrl),
|
|
"screenshot" => Ok(BrowserAction::Screenshot {
|
|
path: args.get("path").and_then(|v| v.as_str()).map(String::from),
|
|
full_page: args
|
|
.get("full_page")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false),
|
|
}),
|
|
"wait" => Ok(BrowserAction::Wait {
|
|
selector: args
|
|
.get("selector")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from),
|
|
ms: args.get("ms").and_then(|v| v.as_u64()),
|
|
text: args
|
|
.get("text")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from),
|
|
}),
|
|
"press" => {
|
|
let key = args
|
|
.get("key")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing 'key' for press"))?;
|
|
Ok(BrowserAction::Press {
|
|
key: key.to_string(),
|
|
})
|
|
}
|
|
"hover" => {
|
|
let selector = args
|
|
.get("selector")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing 'selector' for hover"))?;
|
|
Ok(BrowserAction::Hover {
|
|
selector: selector.to_string(),
|
|
})
|
|
}
|
|
"scroll" => {
|
|
let direction = args
|
|
.get("direction")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing 'direction' for scroll"))?;
|
|
Ok(BrowserAction::Scroll {
|
|
direction: direction.to_string(),
|
|
pixels: args
|
|
.get("pixels")
|
|
.and_then(|v| v.as_u64())
|
|
.map(|p| u32::try_from(p).unwrap_or(u32::MAX)),
|
|
})
|
|
}
|
|
"close" => Ok(BrowserAction::Close),
|
|
other => anyhow::bail!("Unsupported browser action: {}", other),
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for BrowserTool {
|
|
fn name(&self) -> &str {
|
|
"browser"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Automate browser interactions using WebDriver. \
|
|
First call open to navigate to a URL, then use other actions. \
|
|
Use snapshot to get an accessibility tree of the page. \
|
|
Selectors can be CSS, @e1 refs (from snapshot), text=..., or label=..."
|
|
}
|
|
|
|
fn parameters_schema(&self) -> Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"description": "Browser action to perform",
|
|
"enum": [
|
|
"open", "snapshot", "click", "fill", "type",
|
|
"get_text", "get_title", "get_url", "screenshot",
|
|
"wait", "press", "hover", "scroll", "close"
|
|
]
|
|
},
|
|
"url": {
|
|
"type": "string",
|
|
"description": "URL to navigate to (required for open)"
|
|
},
|
|
"selector": {
|
|
"type": "string",
|
|
"description": "CSS selector, @e1 ref, text=..., or label=..."
|
|
},
|
|
"value": {
|
|
"type": "string",
|
|
"description": "Value to fill into a form field"
|
|
},
|
|
"text": {
|
|
"type": "string",
|
|
"description": "Text to type or wait for"
|
|
},
|
|
"key": {
|
|
"type": "string",
|
|
"description": "Key to press (Enter, Tab, Escape, Backspace, Delete, ArrowUp, ArrowDown, etc.)"
|
|
},
|
|
"direction": {
|
|
"type": "string",
|
|
"description": "Scroll direction",
|
|
"enum": ["up", "down", "left", "right"]
|
|
},
|
|
"pixels": {
|
|
"type": "integer",
|
|
"description": "Pixels to scroll (default 600)"
|
|
},
|
|
"ms": {
|
|
"type": "integer",
|
|
"description": "Milliseconds to wait (for wait action)"
|
|
},
|
|
"path": {
|
|
"type": "string",
|
|
"description": "File path to save screenshot to. If omitted, returns base64."
|
|
},
|
|
"full_page": {
|
|
"type": "boolean",
|
|
"description": "Take full-page screenshot"
|
|
},
|
|
"interactive_only": {
|
|
"type": "boolean",
|
|
"description": "Only show interactive elements in snapshot (default true)"
|
|
},
|
|
"compact": {
|
|
"type": "boolean",
|
|
"description": "Compact snapshot output (default true)"
|
|
},
|
|
"depth": {
|
|
"type": "integer",
|
|
"description": "Max depth for snapshot traversal"
|
|
}
|
|
},
|
|
"required": ["action"]
|
|
})
|
|
}
|
|
|
|
fn exclusive(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
|
let action_str = args
|
|
.get("action")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: action"))?;
|
|
|
|
let action = parse_browser_action(action_str, &args)?;
|
|
|
|
let mut state = self.state.lock().await;
|
|
state
|
|
.execute_action(
|
|
action,
|
|
self.headless,
|
|
&self.webdriver_url,
|
|
self.chrome_path.as_deref(),
|
|
&self.driver,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
impl BrowserState {
|
|
async fn execute_action(
|
|
&mut self,
|
|
action: BrowserAction,
|
|
headless: bool,
|
|
webdriver_url: &str,
|
|
chrome_path: Option<&str>,
|
|
driver: &std::sync::Mutex<Option<tokio::process::Child>>,
|
|
) -> anyhow::Result<ToolResult> {
|
|
let action_clone = action.clone();
|
|
let result = self
|
|
.try_execute(action, headless, webdriver_url, chrome_path, driver)
|
|
.await;
|
|
|
|
match result {
|
|
Ok(r) => Ok(r),
|
|
Err(e) => {
|
|
if is_recoverable_error(&e) {
|
|
tracing::warn!("Recoverable browser session error, retrying: {:#}", e);
|
|
self.reset_session(driver).await;
|
|
self.try_execute(action_clone, headless, webdriver_url, chrome_path, driver)
|
|
.await
|
|
} else {
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn try_execute(
|
|
&mut self,
|
|
action: BrowserAction,
|
|
headless: bool,
|
|
webdriver_url: &str,
|
|
chrome_path: Option<&str>,
|
|
driver: &std::sync::Mutex<Option<tokio::process::Child>>,
|
|
) -> anyhow::Result<ToolResult> {
|
|
match action {
|
|
BrowserAction::Open { url } => {
|
|
self.ensure_session(headless, webdriver_url, chrome_path, driver)
|
|
.await?;
|
|
let client = self.active_client()?;
|
|
client.goto(&url).await?;
|
|
let current = client.current_url().await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("Opened {}", current),
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::Snapshot {
|
|
interactive_only,
|
|
compact,
|
|
depth,
|
|
} => {
|
|
let client = self.active_client()?;
|
|
let result: Value = client
|
|
.execute(
|
|
&snapshot_script(interactive_only, compact, depth.map(i64::from)),
|
|
vec![],
|
|
)
|
|
.await?;
|
|
let output = serde_json::to_string_pretty(&result)?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output,
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::Click { selector } => {
|
|
let client = self.active_client()?;
|
|
find_element(client, &selector).await?.click().await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("Clicked {}", selector),
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::Fill { selector, value } => {
|
|
let client = self.active_client()?;
|
|
let el = find_element(client, &selector).await?;
|
|
let _ = el.clear().await;
|
|
el.send_keys(&value).await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("Filled {} with {}", selector, value),
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::Type { selector, text } => {
|
|
let client = self.active_client()?;
|
|
find_element(client, &selector)
|
|
.await?
|
|
.send_keys(&text)
|
|
.await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("Typed {} chars into {}", text.len(), selector),
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::GetText { selector } => {
|
|
let client = self.active_client()?;
|
|
let text = find_element(client, &selector).await?.text().await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: text,
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::GetTitle => {
|
|
let client = self.active_client()?;
|
|
let title = client.title().await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: title,
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::GetUrl => {
|
|
let client = self.active_client()?;
|
|
let url = client.current_url().await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: url.to_string(),
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::Screenshot { path, full_page } => {
|
|
let client = self.active_client()?;
|
|
let png = client.screenshot().await?;
|
|
let _ = full_page;
|
|
|
|
match path {
|
|
Some(p) => {
|
|
tokio::fs::write(&p, &png).await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("Screenshot saved to {}", p),
|
|
error: None,
|
|
})
|
|
}
|
|
None => {
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("data:image/png;base64,{}", b64),
|
|
error: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
BrowserAction::Wait {
|
|
selector,
|
|
ms,
|
|
text,
|
|
} => {
|
|
if let Some(sel) = selector {
|
|
let client = self.active_client()?;
|
|
wait_for_selector(client, &sel).await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("Element found: {}", sel),
|
|
error: None,
|
|
})
|
|
} else if let Some(duration_ms) = ms {
|
|
tokio::time::sleep(Duration::from_millis(duration_ms)).await;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("Waited {} ms", duration_ms),
|
|
error: None,
|
|
})
|
|
} else if let Some(needle) = text {
|
|
let client = self.active_client()?;
|
|
let xpath = xpath_contains_text(&needle);
|
|
client.wait().for_element(Locator::XPath(&xpath)).await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("Text appeared: {}", needle),
|
|
error: None,
|
|
})
|
|
} else {
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: "Waited 250 ms".to_string(),
|
|
error: None,
|
|
})
|
|
}
|
|
}
|
|
BrowserAction::Press { key } => {
|
|
let client = self.active_client()?;
|
|
let key_input = webdriver_key(&key);
|
|
match client.active_element().await {
|
|
Ok(el) => {
|
|
el.send_keys(&key_input).await?;
|
|
}
|
|
Err(_) => {
|
|
find_element(client, "body")
|
|
.await?
|
|
.send_keys(&key_input)
|
|
.await?;
|
|
}
|
|
}
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("Pressed {}", key),
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::Hover { selector } => {
|
|
let client = self.active_client()?;
|
|
let el = find_element(client, &selector).await?;
|
|
hover_element(client, &el).await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!("Hovered {}", selector),
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::Scroll { direction, pixels } => {
|
|
let client = self.active_client()?;
|
|
let amount = i64::from(pixels.unwrap_or(600));
|
|
let (dx, dy) = match direction.as_str() {
|
|
"up" => (0, -amount),
|
|
"down" => (0, amount),
|
|
"left" => (-amount, 0),
|
|
"right" => (amount, 0),
|
|
_ => anyhow::bail!(
|
|
"Unsupported scroll direction '{}'. Use up/down/left/right",
|
|
direction
|
|
),
|
|
};
|
|
let position: Value = client
|
|
.execute(
|
|
"window.scrollBy(arguments[0], arguments[1]); \
|
|
return { x: window.scrollX, y: window.scrollY };",
|
|
vec![json!(dx), json!(dy)],
|
|
)
|
|
.await?;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: format!(
|
|
"Scrolled {} by {} -> {}",
|
|
direction,
|
|
amount,
|
|
serde_json::to_string(&position).unwrap_or_default()
|
|
),
|
|
error: None,
|
|
})
|
|
}
|
|
BrowserAction::Close => {
|
|
self.reset_session(driver).await;
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: "Browser session closed".to_string(),
|
|
error: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn reset_session(
|
|
&mut self,
|
|
driver: &std::sync::Mutex<Option<tokio::process::Child>>,
|
|
) {
|
|
if let Some(client) = self.client.take() {
|
|
let _ = client.close().await;
|
|
}
|
|
if let Ok(mut guard) = driver.lock() {
|
|
if let Some(ref mut child) = guard.take() {
|
|
tracing::debug!("Stopping chromedriver process");
|
|
let _ = child.start_kill();
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn ensure_session(
|
|
&mut self,
|
|
headless: bool,
|
|
webdriver_url: &str,
|
|
chrome_path: Option<&str>,
|
|
driver: &std::sync::Mutex<Option<tokio::process::Child>>,
|
|
) -> anyhow::Result<()> {
|
|
if self.client.is_some() {
|
|
return Ok(());
|
|
}
|
|
|
|
let chrome_binary = match chrome_path {
|
|
Some(path) if !path.trim().is_empty() => Some(path.trim().to_string()),
|
|
_ => {
|
|
verify_chrome_installed()?;
|
|
None
|
|
}
|
|
};
|
|
|
|
let chromedriver_binary = find_chromedriver_binary()?;
|
|
|
|
let port = extract_port(webdriver_url);
|
|
|
|
let launched = if !webdriver_endpoint_reachable(webdriver_url) {
|
|
tracing::info!(
|
|
"chromedriver not running at {}, launching: {} --port={}",
|
|
webdriver_url,
|
|
chromedriver_binary,
|
|
port
|
|
);
|
|
launch_chromedriver(driver, &chromedriver_binary, port)?;
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if launched {
|
|
wait_for_webdriver_ready(webdriver_url).await
|
|
.map_err(|e| {
|
|
kill_driver_guard(driver);
|
|
e.context("chromedriver failed to start. Is Chrome installed? Check browser.chrome_path in config.json")
|
|
})?;
|
|
}
|
|
|
|
let mut capabilities: Map<String, Value> = Map::new();
|
|
let mut chrome_options: Map<String, Value> = Map::new();
|
|
let mut args: Vec<Value> = Vec::new();
|
|
|
|
if headless {
|
|
args.push(Value::String("--headless=new".to_string()));
|
|
args.push(Value::String("--disable-gpu".to_string()));
|
|
args.push(Value::String("--no-sandbox".to_string()));
|
|
args.push(Value::String("--disable-dev-shm-usage".to_string()));
|
|
}
|
|
|
|
args.push(Value::String("--window-size=1280,720".to_string()));
|
|
|
|
if let Some(ref binary) = chrome_binary {
|
|
chrome_options.insert("binary".to_string(), Value::String(binary.clone()));
|
|
}
|
|
|
|
if !args.is_empty() {
|
|
chrome_options.insert("args".to_string(), Value::Array(args));
|
|
}
|
|
|
|
capabilities.insert(
|
|
"goog:chromeOptions".to_string(),
|
|
Value::Object(chrome_options),
|
|
);
|
|
|
|
let client = ClientBuilder::rustls()?
|
|
.capabilities(capabilities)
|
|
.connect(webdriver_url)
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"Failed to connect to WebDriver at {}. \
|
|
Make sure chromedriver is installed and running.",
|
|
webdriver_url
|
|
)
|
|
})?;
|
|
|
|
self.client = Some(client);
|
|
Ok(())
|
|
}
|
|
|
|
fn active_client(&self) -> anyhow::Result<&Client> {
|
|
self.client
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow::anyhow!("No active browser session. Run action='open' first"))
|
|
}
|
|
}
|
|
|
|
fn launch_chromedriver(
|
|
driver: &std::sync::Mutex<Option<tokio::process::Child>>,
|
|
chromedriver_binary: &str,
|
|
port: u16,
|
|
) -> anyhow::Result<()> {
|
|
let child = tokio::process::Command::new(chromedriver_binary)
|
|
.arg(format!("--port={}", port))
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.with_context(|| format!("Failed to launch chromedriver: {}", chromedriver_binary))?;
|
|
|
|
*driver.lock().unwrap() = Some(child);
|
|
Ok(())
|
|
}
|
|
|
|
fn kill_driver_guard(driver: &std::sync::Mutex<Option<tokio::process::Child>>) {
|
|
if let Ok(mut guard) = driver.lock() {
|
|
if let Some(ref mut child) = guard.take() {
|
|
let _ = child.start_kill();
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn wait_for_webdriver_ready(webdriver_url: &str) -> anyhow::Result<()> {
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
|
|
|
loop {
|
|
if webdriver_endpoint_reachable(webdriver_url) {
|
|
return Ok(());
|
|
}
|
|
|
|
if tokio::time::Instant::now() >= deadline {
|
|
anyhow::bail!(
|
|
"Timed out waiting for chromedriver to start listening on {}",
|
|
webdriver_url
|
|
);
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
}
|
|
}
|
|
|
|
fn verify_chrome_installed() -> anyhow::Result<()> {
|
|
for name in CHROME_CANDIDATES {
|
|
if which::which(name).is_ok() {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
anyhow::bail!(
|
|
"Chrome/Chromium not found. Install with:\n\
|
|
Ubuntu/Debian: apt install chromium-browser\n\
|
|
macOS: brew install chromium\n\
|
|
Or set browser.chrome_path in config.json"
|
|
)
|
|
}
|
|
|
|
fn find_chromedriver_binary() -> anyhow::Result<String> {
|
|
for name in CHROMEDRIVER_CANDIDATES {
|
|
if let Ok(path) = which::which(name) {
|
|
return Ok(path.to_string_lossy().to_string());
|
|
}
|
|
}
|
|
|
|
anyhow::bail!(
|
|
"chromedriver not found. Install with:\n\
|
|
Ubuntu/Debian: apt install chromium-chromedriver\n\
|
|
macOS: brew install chromedriver\n\
|
|
Or download from https://chromedriver.chromium.org/"
|
|
)
|
|
}
|
|
|
|
fn extract_port(webdriver_url: &str) -> u16 {
|
|
reqwest::Url::parse(webdriver_url)
|
|
.ok()
|
|
.and_then(|u| u.port())
|
|
.unwrap_or(9515)
|
|
}
|
|
|
|
fn webdriver_endpoint_reachable(webdriver_url: &str) -> bool {
|
|
let parsed = match reqwest::Url::parse(webdriver_url) {
|
|
Ok(url) => url,
|
|
Err(_) => return false,
|
|
};
|
|
|
|
if parsed.scheme() != "http" && parsed.scheme() != "https" {
|
|
return false;
|
|
}
|
|
|
|
let host = match parsed.host_str() {
|
|
Some(h) if !h.is_empty() => h,
|
|
_ => return false,
|
|
};
|
|
|
|
let port = parsed.port_or_known_default().unwrap_or(9515);
|
|
let addr = format!("{}:{}", host, port);
|
|
|
|
TcpStream::connect_timeout(
|
|
&addr
|
|
.parse::<std::net::SocketAddr>()
|
|
.unwrap_or_else(|_| ([127, 0, 0, 1], port).into()),
|
|
Duration::from_millis(500),
|
|
)
|
|
.is_ok()
|
|
}
|
|
|
|
fn is_recoverable_error(err: &anyhow::Error) -> bool {
|
|
let message = format!("{:#}", err).to_ascii_lowercase();
|
|
|
|
message.contains("invalid session id")
|
|
|| message.contains("no such window")
|
|
|| message.contains("session not created")
|
|
|| message.contains("connection reset")
|
|
|| message.contains("broken pipe")
|
|
|| (message.contains("webdriver")
|
|
&& (message.contains("timed out") || message.contains("timeout")))
|
|
}
|
|
|
|
enum SelectorKind {
|
|
Css(String),
|
|
XPath(String),
|
|
}
|
|
|
|
fn parse_selector(selector: &str) -> SelectorKind {
|
|
let trimmed = selector.trim();
|
|
|
|
if let Some(text_query) = trimmed.strip_prefix("text=") {
|
|
return SelectorKind::XPath(xpath_contains_text(text_query));
|
|
}
|
|
|
|
if let Some(label_query) = trimmed.strip_prefix("label=") {
|
|
let literal = xpath_literal(label_query);
|
|
return SelectorKind::XPath(format!(
|
|
"(//label[contains(normalize-space(.), {literal})] \
|
|
/following::*[self::input or self::textarea or self::select][1] \
|
|
| //*[@aria-label and contains(normalize-space(@aria-label), {literal})] \
|
|
| //label[contains(normalize-space(.), {literal})])"
|
|
));
|
|
}
|
|
|
|
if trimmed.starts_with('@') {
|
|
let escaped = css_attr_escape(trimmed);
|
|
return SelectorKind::Css(format!(r#"[data-zc-ref="{escaped}"]"#));
|
|
}
|
|
|
|
SelectorKind::Css(trimmed.to_string())
|
|
}
|
|
|
|
async fn find_element(
|
|
client: &Client,
|
|
selector: &str,
|
|
) -> anyhow::Result<fantoccini::elements::Element> {
|
|
match parse_selector(selector) {
|
|
SelectorKind::Css(css) => Ok(client.find(Locator::Css(&css)).await?),
|
|
SelectorKind::XPath(xpath) => Ok(client.find(Locator::XPath(&xpath)).await?),
|
|
}
|
|
}
|
|
|
|
async fn wait_for_selector(client: &Client, selector: &str) -> anyhow::Result<()> {
|
|
match parse_selector(selector) {
|
|
SelectorKind::Css(css) => {
|
|
client.wait().for_element(Locator::Css(&css)).await?;
|
|
}
|
|
SelectorKind::XPath(xpath) => {
|
|
client.wait().for_element(Locator::XPath(&xpath)).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn hover_element(
|
|
client: &Client,
|
|
element: &fantoccini::elements::Element,
|
|
) -> anyhow::Result<()> {
|
|
let actions = MouseActions::new("mouse".to_string()).then(PointerAction::MoveToElement {
|
|
element: element.clone(),
|
|
duration: Some(Duration::from_millis(150)),
|
|
x: 0.0,
|
|
y: 0.0,
|
|
});
|
|
|
|
client.perform_actions(actions).await?;
|
|
let _ = client.release_actions().await;
|
|
Ok(())
|
|
}
|
|
|
|
fn css_attr_escape(input: &str) -> String {
|
|
input
|
|
.replace('\\', "\\\\")
|
|
.replace('"', "\\\"")
|
|
.replace('\n', " ")
|
|
}
|
|
|
|
fn xpath_contains_text(text: &str) -> String {
|
|
format!(
|
|
"//*[contains(normalize-space(.), {})]",
|
|
xpath_literal(text)
|
|
)
|
|
}
|
|
|
|
fn xpath_literal(input: &str) -> String {
|
|
if !input.contains('"') {
|
|
return format!("\"{}\"", input);
|
|
}
|
|
if !input.contains('\'') {
|
|
return format!("'{}'", input);
|
|
}
|
|
|
|
let segments: Vec<&str> = input.split('"').collect();
|
|
let mut parts: Vec<String> = Vec::new();
|
|
for (index, part) in segments.iter().enumerate() {
|
|
if !part.is_empty() {
|
|
parts.push(format!("\"{}\"", part));
|
|
}
|
|
if index + 1 < segments.len() {
|
|
parts.push("'\"'".to_string());
|
|
}
|
|
}
|
|
|
|
if parts.is_empty() {
|
|
"\"\"".to_string()
|
|
} else {
|
|
format!("concat({})", parts.join(","))
|
|
}
|
|
}
|
|
|
|
fn webdriver_key(key: &str) -> String {
|
|
match key.trim().to_ascii_lowercase().as_str() {
|
|
"enter" => Key::Enter.to_string(),
|
|
"return" => Key::Return.to_string(),
|
|
"tab" => Key::Tab.to_string(),
|
|
"escape" | "esc" => Key::Escape.to_string(),
|
|
"backspace" => Key::Backspace.to_string(),
|
|
"delete" => Key::Delete.to_string(),
|
|
"arrowup" => Key::Up.to_string(),
|
|
"arrowdown" => Key::Down.to_string(),
|
|
"arrowleft" => Key::Left.to_string(),
|
|
"arrowright" => Key::Right.to_string(),
|
|
"home" => Key::Home.to_string(),
|
|
"end" => Key::End.to_string(),
|
|
"pageup" => Key::PageUp.to_string(),
|
|
"pagedown" => Key::PageDown.to_string(),
|
|
"space" => " ".to_string(),
|
|
other => other.to_string(),
|
|
}
|
|
}
|
|
|
|
fn snapshot_script(interactive_only: bool, compact: bool, depth: Option<i64>) -> String {
|
|
let depth_literal = depth
|
|
.map(|level| level.to_string())
|
|
.unwrap_or_else(|| "null".to_string());
|
|
|
|
format!(
|
|
r#"(() => {{
|
|
const interactiveOnly = {interactive_only};
|
|
const compact = {compact};
|
|
const maxDepth = {depth_literal};
|
|
const nodes = [];
|
|
const root = document.body || document.documentElement;
|
|
let counter = 0;
|
|
|
|
const isVisible = (el) => {{
|
|
const style = window.getComputedStyle(el);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || 1) === 0) {{
|
|
return false;
|
|
}}
|
|
const rect = el.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
}};
|
|
|
|
const isInteractive = (el) => {{
|
|
if (el.matches('a,button,input,select,textarea,summary,[role],*[tabindex]')) return true;
|
|
return typeof el.onclick === 'function';
|
|
}};
|
|
|
|
const describe = (el, depth) => {{
|
|
const interactive = isInteractive(el);
|
|
const text = (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 140);
|
|
if (interactiveOnly && !interactive) return;
|
|
if (compact && !interactive && !text) return;
|
|
|
|
const ref = '@e' + (++counter);
|
|
el.setAttribute('data-zc-ref', ref);
|
|
nodes.push({{
|
|
ref,
|
|
depth,
|
|
tag: el.tagName.toLowerCase(),
|
|
id: el.id || null,
|
|
role: el.getAttribute('role'),
|
|
text,
|
|
interactive,
|
|
}});
|
|
}};
|
|
|
|
const walk = (el, depth) => {{
|
|
if (!(el instanceof Element)) return;
|
|
if (maxDepth !== null && depth > maxDepth) return;
|
|
if (isVisible(el)) {{
|
|
describe(el, depth);
|
|
}}
|
|
for (const child of el.children) {{
|
|
walk(child, depth + 1);
|
|
if (nodes.length >= 400) return;
|
|
}}
|
|
}};
|
|
|
|
if (root) walk(root, 0);
|
|
|
|
return JSON.stringify({{
|
|
title: document.title,
|
|
url: window.location.href,
|
|
count: nodes.length,
|
|
nodes,
|
|
}});
|
|
}})();"#
|
|
)
|
|
}
|