Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a83d1b1df9 | ||
|
|
50b27ac0e0 |
@@ -233,6 +233,22 @@ Positional args fill the adapter's declared args in order; `--key value` overrid
|
||||
by name. Adapters are authored by the bb-sites community and remain their authors'
|
||||
property — chrome-use just runs them.
|
||||
|
||||
**Auto-sync + auto-suggest.** You rarely type `site update` yourself: chrome-use
|
||||
syncs the pack on first use and refreshes it weekly in the background (tune with
|
||||
`AGENT_BROWSER_SITES_TTL_DAYS`, disable with `AGENT_BROWSER_SITES_NO_AUTO_UPDATE=1`).
|
||||
And when you `open`/`snapshot` a page whose domain has adapters, chrome-use surfaces
|
||||
them right in the output — a `💡 site adapters for <domain>` line, plus a
|
||||
`siteAdapters` field under `--json` — so an agent reaches for the structured-data
|
||||
adapter instead of scraping the DOM:
|
||||
|
||||
```text
|
||||
$ chrome-use open https://github.com
|
||||
💡 site adapters for github.com — prefer these for structured data:
|
||||
github/issues, github/me, github/repo, …
|
||||
e.g. chrome-use site github/issues --json
|
||||
✓ GitHub
|
||||
```
|
||||
|
||||
## Automated testing (`chrome-use test`)
|
||||
|
||||
Turn the repetitive "open it, click around, check it's right" work into a
|
||||
|
||||
@@ -188,6 +188,20 @@ chrome-use site bilibili/feed --json # 能用,因为走的是你的
|
||||
位置参数按适配器声明的参数顺序填入;`--key value` 按名覆盖。适配器由 bb-sites 社区编写、
|
||||
版权归各自作者所有 —— chrome-use 只负责运行它们。
|
||||
|
||||
**自动同步 + 自动提示。** 你基本不用手动 `site update`:chrome-use 首次使用时自动拉取,
|
||||
之后每周后台刷新一次(`AGENT_BROWSER_SITES_TTL_DAYS` 调周期,`AGENT_BROWSER_SITES_NO_AUTO_UPDATE=1`
|
||||
关闭)。而当你 `open`/`snapshot` 一个有适配器的域名时,chrome-use 会在输出里直接把可用命令
|
||||
亮出来 —— 一行 `💡 site adapters for <域名>`,`--json` 下则是 `siteAdapters` 字段 —— 这样
|
||||
agent 会直接改用结构化适配器,而不是去扒 DOM:
|
||||
|
||||
```text
|
||||
$ chrome-use open https://github.com
|
||||
💡 site adapters for github.com — prefer these for structured data:
|
||||
github/issues, github/me, github/repo, …
|
||||
e.g. chrome-use site github/issues --json
|
||||
✓ GitHub
|
||||
```
|
||||
|
||||
## 自动化测试(`chrome-use test`)
|
||||
|
||||
把反复的「打开它、点一圈、看对不对」变成**可重跑的测试套件** —— 前端的单元测试。用 YAML 写用例;步骤复用 chrome-use 自己的命令,断言编译成一次检查:
|
||||
|
||||
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.5.19"
|
||||
version = "1.5.21"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrome-use"
|
||||
version = "1.5.19"
|
||||
version = "1.5.21"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+84
-2
@@ -79,6 +79,7 @@ const KNOWN_COMMANDS: &[&str] = &[
|
||||
"dialog",
|
||||
"upload",
|
||||
"site",
|
||||
"box",
|
||||
];
|
||||
|
||||
/// Levenshtein distance, capped — small inputs only (command names).
|
||||
@@ -560,9 +561,37 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
"fill" => {
|
||||
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "fill".to_string(),
|
||||
usage: "fill <selector> <text>",
|
||||
usage: "fill <selector> <text> | fill <selector> --file <path> | fill <selector> --stdin",
|
||||
})?;
|
||||
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": rest[1..].join(" ") }))
|
||||
// Large/multiline content without shell-escaping hell (issue #41):
|
||||
// `fill <sel> --file <path>` reads the value from a UTF-8 file, and
|
||||
// `fill <sel> --stdin` reads it from stdin — sent verbatim, so backticks,
|
||||
// quotes, newlines and non-ASCII pass through untouched.
|
||||
let value = match rest.get(1).copied() {
|
||||
Some("--file") => {
|
||||
let path = rest.get(2).ok_or(ParseError::InvalidValue {
|
||||
message: "fill --file requires a path".to_string(),
|
||||
usage: "fill <selector> --file <path>",
|
||||
})?;
|
||||
std::fs::read_to_string(path).map_err(|e| ParseError::InvalidValue {
|
||||
message: format!("fill --file: cannot read {path}: {e}"),
|
||||
usage: "fill <selector> --file <path>",
|
||||
})?
|
||||
}
|
||||
Some("--stdin") => {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.map_err(|e| ParseError::InvalidValue {
|
||||
message: format!("fill --stdin: {e}"),
|
||||
usage: "fill <selector> --stdin",
|
||||
})?;
|
||||
buf
|
||||
}
|
||||
_ => rest[1..].join(" "),
|
||||
};
|
||||
Ok(json!({ "id": id, "action": "fill", "selector": sel, "value": value }))
|
||||
}
|
||||
"type" => {
|
||||
// `--key-events` (alias `--keys`): send real per-character keystrokes
|
||||
@@ -1006,11 +1035,55 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
// path: file path (contains / or . or ends with known extension)
|
||||
let mut full_page = false;
|
||||
let mut clip: Option<Value> = None;
|
||||
let mut max_width: Option<u32> = None;
|
||||
let mut max_height: Option<u32> = None;
|
||||
let mut scale: Option<f64> = None;
|
||||
let mut positional: Vec<&str> = Vec::new();
|
||||
let mut i = 0;
|
||||
// Parse a numeric value for a downscale flag (issue #42).
|
||||
let parse_num = |i: &mut usize, flag: &str| -> Result<String, ParseError> {
|
||||
let v = rest
|
||||
.get(*i + 1)
|
||||
.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: format!("screenshot {flag}"),
|
||||
usage: "screenshot [--max-width <px>] [--max-height <px>] [--scale <0..1>]",
|
||||
})?;
|
||||
*i += 1;
|
||||
Ok(v.to_string())
|
||||
};
|
||||
while i < rest.len() {
|
||||
match rest[i] {
|
||||
"--full" | "-f" => full_page = true,
|
||||
// Downscale the saved image so retina/full-page shots fit an
|
||||
// agent's image reader and screenshot px line up with click px (#42).
|
||||
"--max-width" => {
|
||||
let v = parse_num(&mut i, "--max-width")?;
|
||||
max_width = Some(v.parse().map_err(|_| ParseError::InvalidValue {
|
||||
message: format!("--max-width expects a number, got '{v}'"),
|
||||
usage: "screenshot --max-width <px>",
|
||||
})?);
|
||||
}
|
||||
"--max-height" => {
|
||||
let v = parse_num(&mut i, "--max-height")?;
|
||||
max_height = Some(v.parse().map_err(|_| ParseError::InvalidValue {
|
||||
message: format!("--max-height expects a number, got '{v}'"),
|
||||
usage: "screenshot --max-height <px>",
|
||||
})?);
|
||||
}
|
||||
"--scale" => {
|
||||
let v = parse_num(&mut i, "--scale")?;
|
||||
let s: f64 = v.parse().map_err(|_| ParseError::InvalidValue {
|
||||
message: format!("--scale expects a number like 0.5, got '{v}'"),
|
||||
usage: "screenshot --scale <0..1>",
|
||||
})?;
|
||||
if s <= 0.0 || s > 1.0 {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!("--scale must be in (0, 1], got '{v}'"),
|
||||
usage: "screenshot --scale <0..1>",
|
||||
});
|
||||
}
|
||||
scale = Some(s);
|
||||
}
|
||||
// `--clip x,y,w,h` captures a pixel region (issue #34).
|
||||
"--clip" => {
|
||||
let raw = rest
|
||||
@@ -1073,6 +1146,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
if let Some(c) = clip {
|
||||
cmd["clip"] = c;
|
||||
}
|
||||
if let Some(w) = max_width {
|
||||
cmd["maxWidth"] = json!(w);
|
||||
}
|
||||
if let Some(h) = max_height {
|
||||
cmd["maxHeight"] = json!(h);
|
||||
}
|
||||
if let Some(s) = scale {
|
||||
cmd["scale"] = json!(s);
|
||||
}
|
||||
if let Some(ref fmt) = flags.screenshot_format {
|
||||
cmd["format"] = json!(fmt);
|
||||
}
|
||||
|
||||
@@ -840,6 +840,27 @@ fn main() {
|
||||
// `info` are CLI-side (download/filesystem); `site <name>/<cmd> [args]` falls
|
||||
// through to the daemon dispatch below (navigate to the adapter's domain + eval).
|
||||
if clean.first().map(|s| s.as_str()) == Some("site") {
|
||||
// Auto-sync the adapter pack on first use and periodically (TTL, default
|
||||
// 7d) so adapters stay fresh without a manual `site update`. Skipped for an
|
||||
// explicit `update` (full sync below). Best-effort: offline → cached pack.
|
||||
// Disable with AGENT_BROWSER_SITES_NO_AUTO_UPDATE=1.
|
||||
if clean.get(1).map(|s| s.as_str()) != Some("update") && site::needs_refresh() {
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
match rt.block_on(site::update()) {
|
||||
Ok(n) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
color::dim(&format!("site: synced {n} adapters (auto)"))
|
||||
)
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"{}",
|
||||
color::dim(&format!(
|
||||
"site: auto-sync skipped ({e}); using cached adapters"
|
||||
))
|
||||
),
|
||||
}
|
||||
}
|
||||
match clean.get(1).map(|s| s.as_str()) {
|
||||
Some("update") => {
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
|
||||
+127
-5
@@ -2478,7 +2478,10 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
||||
wb.navigate(url).await?;
|
||||
let new_url = wb.get_url().await.unwrap_or_else(|_| url.to_string());
|
||||
let title = wb.get_title().await.unwrap_or_default();
|
||||
return Ok(json!({ "url": new_url, "title": title }));
|
||||
return Ok(with_site_hint(
|
||||
json!({ "url": new_url, "title": title }),
|
||||
url,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2545,7 +2548,7 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Ok(Some(switched)) = mgr.reuse_tab_for_url(url).await {
|
||||
return Ok(switched);
|
||||
return Ok(with_site_hint(switched, url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2553,7 +2556,34 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
||||
// Adaptive humanize: sample the freshly loaded page for known behavioural
|
||||
// anti-bot vendors and escalate this session to Human if any are present.
|
||||
detect_and_set_humanize(mgr).await;
|
||||
Ok(result)
|
||||
Ok(with_site_hint(result, url))
|
||||
}
|
||||
|
||||
/// Annotate a navigation/snapshot result with the `site` adapters available for
|
||||
/// the page's domain (auto-trigger): when you land on e.g. github.com, the
|
||||
/// response carries `siteAdapters: { domain, commands: ["github/issues", …] }` so
|
||||
/// the agent reaches for a structured-data adapter instead of scraping. No-op
|
||||
/// when nothing matches or the pack isn't synced yet.
|
||||
fn with_site_hint(mut result: Value, fallback_url: &str) -> Value {
|
||||
let url = result
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(fallback_url);
|
||||
let host = url::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(String::from));
|
||||
if let Some(host) = host {
|
||||
let adapters = crate::site::adapters_for_domain(&host);
|
||||
if !adapters.is_empty() {
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.insert(
|
||||
"siteAdapters".to_string(),
|
||||
json!({ "domain": host, "commands": adapters }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// After navigation, probe the page for known anti-bot vendor fingerprints
|
||||
@@ -2936,6 +2966,13 @@ async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
||||
let ref_count = refs.len();
|
||||
let mut out = json!({ "snapshot": tree, "origin": url, "refs": refs });
|
||||
|
||||
// Auto-trigger: if this domain has site adapters, surface them so the agent
|
||||
// pulls structured data instead of walking the tree. `with_site_hint` reads
|
||||
// the `url` field, so pass it under that key.
|
||||
if let Some(hint) = with_site_hint(json!({ "url": url }), &url).get("siteAdapters") {
|
||||
out["siteAdapters"] = hint.clone();
|
||||
}
|
||||
|
||||
// Canvas/WebGL apps (games, map/3D viewers, drawing tools) paint to a
|
||||
// <canvas> and expose almost no accessibility tree, so `snapshot` comes back
|
||||
// near-empty and agents get stuck looking for refs that will never exist
|
||||
@@ -3077,7 +3114,40 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Downscale the saved image so retina/full-page shots fit an agent's image
|
||||
// reader and screenshot pixels line up with `click x y` CSS px (issue #42).
|
||||
// Explicit --scale / --max-width / --max-height win; otherwise a default cap
|
||||
// (2000px longest edge, AGENT_BROWSER_SCREENSHOT_MAX_EDGE overrides, 0 = off)
|
||||
// applies. Annotated shots are left untouched so ref overlays stay aligned.
|
||||
let mut resized: Option<(u32, u32)> = None;
|
||||
if !annotate {
|
||||
let scale = cmd.get("scale").and_then(|v| v.as_f64());
|
||||
let max_w = cmd
|
||||
.get("maxWidth")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
let max_h = cmd
|
||||
.get("maxHeight")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
let default_edge = if scale.is_none() && max_w.is_none() && max_h.is_none() {
|
||||
std::env::var("AGENT_BROWSER_SCREENSHOT_MAX_EDGE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.or(Some(2000))
|
||||
.filter(|&e| e > 0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
resized = downscale_screenshot(&result.path, scale, max_w, max_h, default_edge);
|
||||
}
|
||||
|
||||
let mut response = json!({ "path": absolutize_saved_path(&result.path) });
|
||||
if let Some((w, h)) = resized {
|
||||
response["width"] = json!(w);
|
||||
response["height"] = json!(h);
|
||||
response["resized"] = json!(true);
|
||||
}
|
||||
if !result.annotations.is_empty() {
|
||||
response["annotations"] = serde_json::to_value(&result.annotations)
|
||||
.map_err(|e| format!("Failed to serialize annotations: {}", e))?;
|
||||
@@ -3093,6 +3163,56 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Downscale a saved screenshot in place (issue #42). Resolves the target longest
|
||||
/// edge from `scale` (fraction of current), explicit `max_w`/`max_h` caps, or a
|
||||
/// `default_edge` cap — whichever yields the smaller image. Only ever shrinks;
|
||||
/// no-op (returns None) if the image is already within bounds or can't be read.
|
||||
/// Returns the new (width, height) when it actually resized.
|
||||
fn downscale_screenshot(
|
||||
path: &str,
|
||||
scale: Option<f64>,
|
||||
max_w: Option<u32>,
|
||||
max_h: Option<u32>,
|
||||
default_edge: Option<u32>,
|
||||
) -> Option<(u32, u32)> {
|
||||
let img = image::open(path).ok()?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Collect candidate scale factors (≤ 1.0); the smallest wins.
|
||||
let mut factor = 1.0f64;
|
||||
if let Some(s) = scale {
|
||||
factor = factor.min(s);
|
||||
}
|
||||
if let Some(mw) = max_w {
|
||||
if w > mw {
|
||||
factor = factor.min(mw as f64 / w as f64);
|
||||
}
|
||||
}
|
||||
if let Some(mh) = max_h {
|
||||
if h > mh {
|
||||
factor = factor.min(mh as f64 / h as f64);
|
||||
}
|
||||
}
|
||||
if let Some(edge) = default_edge {
|
||||
let longest = w.max(h);
|
||||
if longest > edge {
|
||||
factor = factor.min(edge as f64 / longest as f64);
|
||||
}
|
||||
}
|
||||
|
||||
if factor >= 1.0 {
|
||||
return None; // already within bounds — never upscale
|
||||
}
|
||||
let nw = ((w as f64 * factor).round() as u32).max(1);
|
||||
let nh = ((h as f64 * factor).round() as u32).max(1);
|
||||
let resized = img.resize(nw, nh, image::imageops::FilterType::Lanczos3);
|
||||
resized.save(path).ok()?;
|
||||
Some((resized.width(), resized.height()))
|
||||
}
|
||||
|
||||
async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
// First-class coordinate click (issue #8.4): click a raw viewport point with
|
||||
// no element resolution. Parsed from `click <x> <y>` / `click --coords x,y`.
|
||||
@@ -3251,7 +3371,7 @@ async fn handle_fill(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let session_id = mgr.active_session_id()?.to_string();
|
||||
|
||||
interaction::fill(
|
||||
let engine = interaction::fill(
|
||||
&mgr.client,
|
||||
&session_id,
|
||||
&state.ref_map,
|
||||
@@ -3260,7 +3380,9 @@ async fn handle_fill(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
||||
&state.iframe_sessions,
|
||||
)
|
||||
.await?;
|
||||
Ok(json!({ "filled": selector }))
|
||||
// Echo the input path used (input/contenteditable/codemirror5/monaco/select)
|
||||
// so the agent can confirm a rich editor was handled, not silently no-op'd (#41).
|
||||
Ok(json!({ "filled": selector, "engine": engine }))
|
||||
}
|
||||
|
||||
async fn handle_type(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
|
||||
@@ -21,6 +21,16 @@ pub async fn run_daemon(session: &str) {
|
||||
// (via the ab-connect extension) land in a per-session Chrome tab group.
|
||||
let _ = super::browser::DAEMON_SESSION.set(session.to_string());
|
||||
|
||||
// Bootstrap / refresh the site-adapter pack in the background (first-run +
|
||||
// periodic TTL). This populates ~/.chrome-use/sites/.index.json so navigation
|
||||
// can auto-suggest `site` commands for the page you land on, with zero added
|
||||
// latency to any command. Best-effort; offline is a no-op.
|
||||
if crate::site::needs_refresh() {
|
||||
tokio::spawn(async {
|
||||
let _ = crate::site::update().await;
|
||||
});
|
||||
}
|
||||
|
||||
let socket_dir = get_daemon_socket_dir();
|
||||
if !socket_dir.exists() {
|
||||
let _ = fs::create_dir_all(&socket_dir);
|
||||
|
||||
@@ -1529,9 +1529,27 @@ pub async fn get_element_input_value(
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration:
|
||||
"function() { return typeof this.value === 'string' ? this.value : ''; }"
|
||||
.to_string(),
|
||||
// Read rich-editor content too (issue #41): CodeMirror 5 / Monaco
|
||||
// keep their text in a model, not `.value`; contenteditable keeps
|
||||
// it as innerText. Falls back to `.value` for plain inputs.
|
||||
function_declaration: r#"function() {
|
||||
const el = this;
|
||||
const cm5 = el.closest && el.closest('.CodeMirror');
|
||||
if (cm5 && cm5.CodeMirror) return cm5.CodeMirror.getValue();
|
||||
if (window.monaco && monaco.editor) {
|
||||
try {
|
||||
const eds = monaco.editor.getEditors ? monaco.editor.getEditors() : [];
|
||||
const ed = eds.find(e => e.getDomNode && e.getDomNode().contains(el)) || eds[0];
|
||||
if (ed) return ed.getValue();
|
||||
const m = monaco.editor.getModels ? monaco.editor.getModels() : [];
|
||||
if (m[0]) return m[0].getValue();
|
||||
} catch (e) {}
|
||||
}
|
||||
if (typeof el.value === 'string') return el.value;
|
||||
if (el.isContentEditable) return el.innerText;
|
||||
return '';
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
arguments: None,
|
||||
return_by_value: Some(true),
|
||||
@@ -1609,7 +1627,15 @@ pub async fn get_element_bounding_box(
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: r#"function() {
|
||||
const r = this.getBoundingClientRect();
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
||||
const inViewport = r.bottom > 0 && r.right > 0
|
||||
&& r.top < (innerHeight || document.documentElement.clientHeight)
|
||||
&& r.left < (innerWidth || document.documentElement.clientWidth);
|
||||
return {
|
||||
x: r.x, y: r.y, width: r.width, height: r.height,
|
||||
centerX: Math.round(r.x + r.width / 2),
|
||||
centerY: Math.round(r.y + r.height / 2),
|
||||
inViewport,
|
||||
};
|
||||
}"#
|
||||
.to_string(),
|
||||
object_id: Some(object_id),
|
||||
|
||||
@@ -579,7 +579,7 @@ pub async fn fill(
|
||||
selector_or_ref: &str,
|
||||
value: &str,
|
||||
iframe_sessions: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<String, String> {
|
||||
let (object_id, effective_session_id) = resolve_element_object_id(
|
||||
client,
|
||||
session_id,
|
||||
@@ -590,14 +590,15 @@ pub async fn fill(
|
||||
.await?;
|
||||
|
||||
// Emulate a real edit so framework-controlled inputs (React/Vue) and
|
||||
// site-side listeners actually see the change (issue #25): the old path set
|
||||
// `this.value` directly and used Input.insertText, which left React's
|
||||
// internal value-tracker out of sync and never fired change/blur — so
|
||||
// dependent logic (e.g. Mercari's postal-code → 都道府県 autocomplete) never
|
||||
// ran even though the value was visible. Set the value through the element's
|
||||
// PROTOTYPE setter (which React's _valueTracker hooks), then dispatch
|
||||
// input → change → blur/focusout. `type <sel> <text>` remains for sites that
|
||||
// need per-keystroke events.
|
||||
// site-side listeners actually see the change (issue #25): set the value
|
||||
// through the element's PROTOTYPE setter (which React's _valueTracker hooks),
|
||||
// then dispatch input → change → blur/focusout. Beyond plain inputs, detect
|
||||
// rich editors and use their own API/events (issue #41): CodeMirror 5 and
|
||||
// Monaco have a model that `.value`/`textContent` can't touch; ProseMirror /
|
||||
// contenteditable need `execCommand('insertText')` so beforeinput/input fire
|
||||
// (a raw `textContent =` corrupts PM's doc and skips React composers).
|
||||
// Returns the engine used so the caller can report it. `type <sel> <text>`
|
||||
// remains for sites that need per-keystroke events.
|
||||
let fill_js = format!(
|
||||
r#"function() {{
|
||||
const el = this;
|
||||
@@ -605,13 +606,43 @@ pub async fn fill(
|
||||
try {{ el.focus(); }} catch (e) {{}}
|
||||
const tag = el.tagName;
|
||||
const fire = (type, ctor) => el.dispatchEvent(new (ctor || Event)(type, {{ bubbles: true }}));
|
||||
if (tag === 'SELECT') {{
|
||||
el.value = v; fire('input'); fire('change'); return true;
|
||||
|
||||
// CodeMirror 5: a hidden <textarea> inside .CodeMirror with a live instance.
|
||||
const cm5 = el.closest && el.closest('.CodeMirror');
|
||||
if (cm5 && cm5.CodeMirror) {{ cm5.CodeMirror.setValue(v); return 'codemirror5'; }}
|
||||
|
||||
// Monaco: global `monaco`; prefer the editor whose DOM contains el.
|
||||
if (window.monaco && monaco.editor) {{
|
||||
try {{
|
||||
const eds = monaco.editor.getEditors ? monaco.editor.getEditors() : [];
|
||||
const ed = eds.find(e => e.getDomNode && e.getDomNode().contains(el)) || eds[0];
|
||||
if (ed) {{ ed.setValue(v); return 'monaco'; }}
|
||||
const models = monaco.editor.getModels ? monaco.editor.getModels() : [];
|
||||
if (models[0]) {{ models[0].setValue(v); return 'monaco'; }}
|
||||
}} catch (e) {{}}
|
||||
}}
|
||||
|
||||
if (tag === 'SELECT') {{ el.value = v; fire('input'); fire('change'); return 'select'; }}
|
||||
|
||||
if (el.isContentEditable) {{
|
||||
el.textContent = v; fire('input', window.InputEvent || Event); fire('change');
|
||||
try {{ el.blur(); }} catch (e) {{}} fire('focusout'); return true;
|
||||
// ProseMirror / contenteditable: select-all then insertText fires
|
||||
// beforeinput/input that PM and React composers listen for.
|
||||
let ok = false;
|
||||
try {{
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
ok = document.execCommand('insertText', false, v);
|
||||
}} catch (e) {{}}
|
||||
if (!ok) {{ el.textContent = v; fire('input', window.InputEvent || Event); }}
|
||||
fire('change');
|
||||
try {{ el.blur(); }} catch (e) {{}}
|
||||
fire('focusout');
|
||||
return ok ? 'contenteditable' : 'contenteditable-fallback';
|
||||
}}
|
||||
|
||||
const proto = tag === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype
|
||||
: window.HTMLInputElement.prototype;
|
||||
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
@@ -623,13 +654,13 @@ pub async fn fill(
|
||||
fire('change');
|
||||
try {{ el.blur(); }} catch (e) {{}}
|
||||
fire('focusout'); // blur-triggered lookups/validation
|
||||
return true;
|
||||
return 'input';
|
||||
}}"#,
|
||||
val = serde_json::to_string(value).unwrap_or_default()
|
||||
);
|
||||
|
||||
client
|
||||
.send_command_typed::<_, Value>(
|
||||
let result: EvaluateResult = client
|
||||
.send_command_typed(
|
||||
"Runtime.callFunctionOn",
|
||||
&CallFunctionOnParams {
|
||||
function_declaration: fill_js,
|
||||
@@ -642,7 +673,11 @@ pub async fn fill(
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
Ok(result
|
||||
.result
|
||||
.value
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_else(|| "input".to_string()))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
+61
-5
@@ -186,6 +186,26 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
}
|
||||
|
||||
if let Some(data) = &resp.data {
|
||||
// Auto-trigger: when you land on / read a page whose domain has site
|
||||
// adapters, surface them so the agent pulls structured data via
|
||||
// `chrome-use site <name>/<cmd>` instead of scraping the DOM. (In --json
|
||||
// mode this same info rides along in the `siteAdapters` field above.)
|
||||
if let Some(hint) = data.get("siteAdapters") {
|
||||
let domain = hint.get("domain").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let cmds: Vec<&str> = hint
|
||||
.get("commands")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
if !cmds.is_empty() {
|
||||
eprintln!("💡 site adapters for {domain} — prefer these for structured data:");
|
||||
eprintln!(" {}", color::dim(&cmds.join(", ")));
|
||||
eprintln!(
|
||||
" {}",
|
||||
color::dim(&format!("e.g. chrome-use site {} --json", cmds[0]))
|
||||
);
|
||||
}
|
||||
}
|
||||
// A click that opened a new tab: surface it so the agent doesn't read the
|
||||
// unchanged old page as a failed click (issue #24-A).
|
||||
if let Some(opened) = data.get("openedTab") {
|
||||
@@ -469,6 +489,17 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
||||
println!("y: {}", y);
|
||||
println!("width: {}", w);
|
||||
println!("height: {}", h);
|
||||
if let (Some(cx), Some(cy)) = (
|
||||
obj.get("centerX").and_then(|v| v.as_i64()),
|
||||
obj.get("centerY").and_then(|v| v.as_i64()),
|
||||
) {
|
||||
// Echoed in click-ready CSS px so the agent can paste straight
|
||||
// into `click <centerX> <centerY>` (issue #43).
|
||||
println!("center: {} {}", cx, cy);
|
||||
}
|
||||
if let Some(iv) = obj.get("inViewport").and_then(|v| v.as_bool()) {
|
||||
println!("inViewport: {}", iv);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1470,9 +1501,20 @@ Examples:
|
||||
chrome-use fill - Clear and fill an input field
|
||||
|
||||
Usage: chrome-use fill <selector> <text>
|
||||
chrome-use fill <selector> --file <path>
|
||||
chrome-use fill <selector> --stdin
|
||||
|
||||
Clears the input field and fills it with the specified text.
|
||||
This replaces any existing content in the field.
|
||||
Clears the field and fills it with the text, replacing existing content.
|
||||
Works on rich editors too (issue #41): CodeMirror 5, Monaco, ProseMirror and
|
||||
plain contenteditable are detected and set via their own API / input events,
|
||||
not a raw `.value` write — and the response echoes which `engine` was used.
|
||||
For framework inputs (React/Vue/Angular) the value goes through the native
|
||||
setter so the form registers it (no more "pristine" Save no-ops).
|
||||
|
||||
Options:
|
||||
--file <path> Read the value from a UTF-8 file (large/multiline text,
|
||||
backticks/quotes/newlines/non-ASCII — no shell escaping)
|
||||
--stdin Read the value from stdin
|
||||
|
||||
Global Options:
|
||||
--json Output as JSON
|
||||
@@ -1481,7 +1523,8 @@ Global Options:
|
||||
Examples:
|
||||
chrome-use fill "#email" "user@example.com"
|
||||
chrome-use fill @e3 "Hello World"
|
||||
chrome-use fill "input[name='search']" "query"
|
||||
chrome-use fill ".CodeMirror" --file ./article.md # set a CodeMirror editor
|
||||
cat post.md | chrome-use fill @e7 --stdin
|
||||
"##
|
||||
}
|
||||
"type" => {
|
||||
@@ -1883,6 +1926,13 @@ Options:
|
||||
--full, -f Capture full page (not just viewport)
|
||||
[selector] Capture just an element (CSS or @ref), e.g. `screenshot ".header" h.png`
|
||||
--clip <x,y,w,h> Capture a pixel region, e.g. `screenshot --clip 0,0,200,40 corner.png`
|
||||
--max-width <px> Downscale so the image's width ≤ px (preserves aspect)
|
||||
--max-height <px> Downscale so the image's height ≤ px
|
||||
--scale <0..1> Downscale by a factor, e.g. 0.5 (DPR-1, so screenshot px
|
||||
line up 1:1 with `click x y`)
|
||||
Default: capped at 2000px longest edge unless overridden
|
||||
(AGENT_BROWSER_SCREENSHOT_MAX_EDGE; 0 disables). Annotated
|
||||
shots are never downscaled, so ref overlays stay aligned.
|
||||
--annotate Overlay numbered labels on interactive elements.
|
||||
Each label [N] corresponds to ref @eN from snapshot.
|
||||
Prints a legend mapping labels to element roles/names.
|
||||
@@ -1905,6 +1955,8 @@ Examples:
|
||||
chrome-use screenshot --full ./full-page.png
|
||||
chrome-use screenshot ".header .indicator" corner.png # just one element
|
||||
chrome-use screenshot --clip 1600,0,200,40 corner.png # a pixel region
|
||||
chrome-use screenshot --scale 0.5 ./half.png # DPR-1: screenshot px == click px
|
||||
chrome-use screenshot --max-width 1400 ./shot.png # cap width for image readers
|
||||
chrome-use screenshot --annotate # Labeled screenshot + legend
|
||||
chrome-use screenshot --annotate ./page.png # Save annotated screenshot
|
||||
chrome-use screenshot --annotate --json # JSON output with annotations
|
||||
@@ -3243,7 +3295,8 @@ Core Commands:
|
||||
click <sel|x y> Click element/@ref, or a viewport coordinate
|
||||
dblclick <sel> Double-click element
|
||||
type <sel> <text> Type into element
|
||||
fill <sel> <text> Clear and fill
|
||||
fill <sel> <text> Clear and fill (handles CodeMirror/Monaco/ProseMirror/
|
||||
contenteditable; `--file <path>`/`--stdin` for large text)
|
||||
press <key> [--hold <ms>] Press key (Enter, Tab, Control+a). --hold keeps it
|
||||
down <ms> then releases — precise (in-daemon), for
|
||||
games/charge: `press d --hold 800`
|
||||
@@ -3263,7 +3316,8 @@ Core Commands:
|
||||
scroll <dir> [px] Scroll (up/down/left/right)
|
||||
scrollintoview <sel> Scroll element into view
|
||||
wait <sel|ms> Wait for element or time
|
||||
screenshot [path] Take screenshot
|
||||
screenshot [path] Take screenshot (auto-downscaled to ≤2000px long edge;
|
||||
--max-width/--max-height/--scale to override)
|
||||
pdf <path> Save as PDF
|
||||
snapshot Accessibility tree with refs (for AI)
|
||||
eval <js> Run JavaScript
|
||||
@@ -3277,6 +3331,8 @@ Navigation:
|
||||
|
||||
Get Info: chrome-use get <what> [selector]
|
||||
text, html, value, attr <name>, title, url, count, box, styles, cdp-url
|
||||
box <sel> → x,y,width,height,centerX,centerY,inViewport in CSS px (feed
|
||||
centerX/centerY into `click x y`); value reads CodeMirror/Monaco too
|
||||
text (no selector = whole page, all frames), text --main, frames (list)
|
||||
|
||||
Check State: chrome-use is <what> <selector>
|
||||
|
||||
+118
@@ -199,9 +199,127 @@ pub async fn update() -> Result<usize, String> {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
// Build the domain→adapters index and stamp the sync time so navigation can
|
||||
// suggest adapters (auto-trigger) and `needs_refresh` can pace re-syncs.
|
||||
write_domain_index(&dir);
|
||||
if let Some(p) = last_update_path() {
|
||||
let _ = std::fs::write(p, now_secs().to_string());
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// `~/.chrome-use/sites/.last_update` — unix-seconds marker of the last sync.
|
||||
fn last_update_path() -> Option<PathBuf> {
|
||||
sites_dir().map(|d| d.join(".last_update"))
|
||||
}
|
||||
|
||||
/// `~/.chrome-use/sites/.index.json` — `{ "github.com": ["github/issues", …], … }`,
|
||||
/// built on `update` so navigation can look up adapters by domain without parsing
|
||||
/// all ~145 adapter files on every command.
|
||||
fn index_path() -> Option<PathBuf> {
|
||||
sites_dir().map(|d| d.join(".index.json"))
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Parse every installed adapter and write the domain→adapters index. Within a
|
||||
/// domain, read-only adapters are listed first (then alphabetical) so the
|
||||
/// auto-suggested example leads with a safe read, not a write action.
|
||||
fn write_domain_index(dir: &std::path::Path) {
|
||||
let mut by_domain: std::collections::BTreeMap<String, Vec<(bool, String)>> = Default::default();
|
||||
for spec in list_adapters().unwrap_or_default() {
|
||||
if let Ok(a) = load_adapter(&spec) {
|
||||
if let Some(d) = a.domain() {
|
||||
let read_only = a
|
||||
.meta
|
||||
.get("readOnly")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
by_domain
|
||||
.entry(d.to_string())
|
||||
.or_default()
|
||||
.push((read_only, spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
let ordered: std::collections::BTreeMap<String, Vec<String>> = by_domain
|
||||
.into_iter()
|
||||
.map(|(domain, mut v)| {
|
||||
// read-only (true) first, then by spec name
|
||||
v.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
|
||||
(domain, v.into_iter().map(|(_, s)| s).collect())
|
||||
})
|
||||
.collect();
|
||||
if let Ok(json) = serde_json::to_string(&ordered) {
|
||||
let _ = std::fs::write(dir.join(".index.json"), json);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_DAYS: u64 = 7;
|
||||
|
||||
/// Whether the adapter pack should be (re)synced: true on first use (nothing
|
||||
/// installed) or when the last sync is older than the TTL. Disabled by
|
||||
/// `AGENT_BROWSER_SITES_NO_AUTO_UPDATE=1`; TTL overridable via
|
||||
/// `AGENT_BROWSER_SITES_TTL_DAYS` (0 = always).
|
||||
pub fn needs_refresh() -> bool {
|
||||
if std::env::var_os("AGENT_BROWSER_SITES_NO_AUTO_UPDATE").is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(dir) = sites_dir() else {
|
||||
return false;
|
||||
};
|
||||
// First use: no adapters installed yet.
|
||||
if list_adapters().map(|l| l.is_empty()).unwrap_or(true) {
|
||||
let _ = &dir;
|
||||
return true;
|
||||
}
|
||||
let ttl_days = std::env::var("AGENT_BROWSER_SITES_TTL_DAYS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(DEFAULT_TTL_DAYS);
|
||||
let ttl = ttl_days.saturating_mul(86_400);
|
||||
match last_update_path().and_then(|p| std::fs::read_to_string(p).ok()) {
|
||||
Some(s) => match s.trim().parse::<u64>() {
|
||||
Ok(ts) => now_secs().saturating_sub(ts) >= ttl,
|
||||
Err(_) => true,
|
||||
},
|
||||
None => true, // no marker → treat as stale
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapters whose `@meta.domain` matches `host` (exact, or `host` is a subdomain
|
||||
/// of it) — for auto-suggesting `site` commands when you land on a known site.
|
||||
/// Reads the prebuilt `.index.json`; empty if the pack isn't synced yet.
|
||||
pub fn adapters_for_domain(host: &str) -> Vec<String> {
|
||||
let host = host.trim_start_matches("www.");
|
||||
let Some(raw) = index_path().and_then(|p| std::fs::read_to_string(p).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(idx) = serde_json::from_str::<std::collections::BTreeMap<String, Vec<String>>>(&raw)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
// Preserve the index's per-domain ordering (read-only adapters first); just
|
||||
// dedup if a host somehow matches multiple domain keys.
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (domain, specs) in idx {
|
||||
let d = domain.trim_start_matches("www.");
|
||||
if host == d || host.ends_with(&format!(".{d}")) {
|
||||
for s in specs {
|
||||
if !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Map CLI args to the adapter's `args` object. Positional args fill the adapter's
|
||||
/// declared `args` keys in order; `--key value` overrides by name. The adapter
|
||||
/// validates required args itself.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "chrome-use",
|
||||
"version": "1.5.19",
|
||||
"version": "1.5.21",
|
||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.3",
|
||||
|
||||
@@ -59,6 +59,18 @@ next ref interaction.
|
||||
> anyway.) Driving off pixels on the relay also risks a coordinate event drifting
|
||||
> onto the user's foreground tab — refs never do. See issue #37.
|
||||
|
||||
> **Two different intents — only one is discouraged.** The rule above is about
|
||||
> *screenshot-to-locate* (using a picture to find/hit an element) — that's the bug.
|
||||
> *screenshot-to-capture* — saving a region or element to a file as a **reusable
|
||||
> image asset** (maps, charts, og-images, visual-diff baselines, report figures) —
|
||||
> is fully supported and encouraged: `screenshot [selector] [--clip x,y,w,h] <file>`.
|
||||
> Capturing a rendered map region to a PNG for a blog post is the right tool, not a
|
||||
> smell. Screenshots are auto-downscaled to ≤2000px (longest edge) so they fit an
|
||||
> image reader and their pixels line up with `click x y`; override with
|
||||
> `--max-width`/`--max-height`/`--scale`. To click something you couldn't hit by
|
||||
> ref, `box @ref` gives the element's CSS-px box + `centerX/centerY` to feed
|
||||
> straight into `click <centerX> <centerY>` — no screenshot needed.
|
||||
|
||||
## Before you automate: pick the cheapest tool
|
||||
|
||||
Driving a browser is the heavy option. chrome-use earns its keep when you
|
||||
@@ -218,6 +230,13 @@ chrome-use site github/issues owner/repo --json # run it → JSON (navigates t
|
||||
- If no adapter fits, fall back to the normal `snapshot`/`eval` loop. Adapters come from the
|
||||
[bb-sites](https://github.com/epiral/bb-sites) community pack; chrome-use fetches & runs them.
|
||||
|
||||
> **Auto-trigger — act on it.** chrome-use keeps the pack synced automatically (first use +
|
||||
> weekly), and when you `open`/`navigate`/`snapshot` a page whose domain has adapters it tells
|
||||
> you: a `💡 site adapters for <domain>` line on stderr, and a `siteAdapters: {domain, commands}`
|
||||
> field in `--json`. **When you see that, prefer the listed `site <name>/<cmd>` over snapshot+click
|
||||
> for reading data** — it's the cheaper, more reliable path and it's already installed. You don't
|
||||
> need to run `site update` yourself; just use the command it names.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
@@ -366,6 +385,12 @@ foreground, so prefer refs. For below-the-fold content in such a frame, scroll i
|
||||
with `scroll down N --at x,y` (a pixel over the frame) or `--frame n`. For a
|
||||
postal/autocomplete box inside the frame, `type @e "…" --key-events`.
|
||||
|
||||
> **Caveat: `find text "…"` can't reach into a cross-origin iframe** — it errors
|
||||
> "Element not found" even though `snapshot -i` lists those nodes and
|
||||
> `get text` reads them. Inside cross-origin iframes, target elements by their
|
||||
> **snapshot `@ref`**, not by `find`. (`box @ref` also works on iframe refs when
|
||||
> you need a coordinate fallback.)
|
||||
|
||||
### When refs don't work or you don't want to snapshot
|
||||
|
||||
Use semantic locators:
|
||||
|
||||
Reference in New Issue
Block a user