Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fcf52db60 | ||
|
|
af8823b27b | ||
|
|
c85e3faa82 | ||
|
|
b25958946c | ||
|
|
d4ff49caa8 | ||
|
|
4e949fffbf | ||
|
|
af46490812 | ||
|
|
57c52d6517 | ||
|
|
2707ceb1c4 | ||
|
|
f7a657ac46 | ||
|
|
4e295ce139 |
@@ -147,16 +147,31 @@ jobs:
|
|||||||
git fetch --tags --force --quiet origin 2>/dev/null || true
|
git fetch --tags --force --quiet origin 2>/dev/null || true
|
||||||
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
||||||
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
PREV="$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || true)"
|
||||||
|
RANGE="${TAG}"
|
||||||
|
[ -n "$PREV" ] && RANGE="${PREV}..${TAG}"
|
||||||
|
# Group commit subjects by conventional-commit type so the notes are
|
||||||
|
# scannable ("what's new / what's fixed") instead of a flat dev log.
|
||||||
|
LOG="$(git log "$RANGE" --no-merges --pretty='%s' | grep -v '^chore(release)' || true)"
|
||||||
|
# NOTE: the job runs under `bash -e`. grep returning 1 (no match) and
|
||||||
|
# the `[ -n "$body" ]` test returning 1 (empty section) must NOT abort
|
||||||
|
# the script — otherwise a release whose commit range lacks a whole
|
||||||
|
# category (e.g. only `feat`, no `fix`) dies before writing the closing
|
||||||
|
# heredoc delimiter and the whole release step fails. `|| true` +
|
||||||
|
# `return 0` keep section() always-succeeding.
|
||||||
|
section() { # $1=header $2=grep-pattern
|
||||||
|
local body; body="$(printf '%s\n' "$LOG" | grep -E "$2" | sed 's/^/- /' || true)"
|
||||||
|
[ -n "$body" ] && printf '\n### %s\n%s\n' "$1" "$body"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
{
|
{
|
||||||
echo "notes<<__NOTES_EOF__"
|
echo "notes<<__NOTES_EOF__"
|
||||||
echo "## What changed"
|
echo "## What changed"
|
||||||
echo ""
|
section "✨ Features" '^feat'
|
||||||
|
section "🐛 Fixes" '^fix'
|
||||||
|
section "🔧 Other" '^(perf|refactor|docs|build|ci|test|style|revert)'
|
||||||
if [ -n "$PREV" ]; then
|
if [ -n "$PREV" ]; then
|
||||||
git log "${PREV}..${TAG}" --no-merges --pretty='- %s' | grep -v '^- chore(release)' || true
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "**Full changelog**: https://github.com/${{ github.repository }}/compare/${PREV}...${TAG}"
|
echo "**Full changelog**: https://github.com/${{ github.repository }}/compare/${PREV}...${TAG}"
|
||||||
else
|
|
||||||
git log "${TAG}" --no-merges --pretty='- %s' | grep -v '^- chore(release)' || true
|
|
||||||
fi
|
fi
|
||||||
echo "__NOTES_EOF__"
|
echo "__NOTES_EOF__"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.5.0"
|
version = "1.5.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.5.0"
|
version = "1.5.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+168
-13
@@ -30,12 +30,65 @@ pub enum ParseError {
|
|||||||
InvalidSessionName { name: String },
|
InvalidSessionName { name: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Top-level commands an agent is likely to mistype, used for "did you mean"
|
||||||
|
/// suggestions on an unknown command (issue #29). Not exhaustive — just the
|
||||||
|
/// common verbs plus a few known wrong-guesses mapped to the real command.
|
||||||
|
const KNOWN_COMMANDS: &[&str] = &[
|
||||||
|
"open", "navigate", "click", "fill", "type", "press", "snapshot", "screenshot", "eval", "get",
|
||||||
|
"text", "html", "frames", "find", "wait", "scroll", "hover", "select", "check", "uncheck",
|
||||||
|
"tab", "tabs", "close", "back", "forward", "reload", "sessions", "status", "daemon", "doctor",
|
||||||
|
"upgrade", "connect", "cookies", "mouse", "keyboard", "stream", "frame", "profiles", "title",
|
||||||
|
"url", "is", "drag", "dialog", "upload",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Levenshtein distance, capped — small inputs only (command names).
|
||||||
|
fn edit_distance(a: &str, b: &str) -> usize {
|
||||||
|
let a: Vec<char> = a.chars().collect();
|
||||||
|
let b: Vec<char> = b.chars().collect();
|
||||||
|
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||||||
|
let mut curr = vec![0usize; b.len() + 1];
|
||||||
|
for (i, &ca) in a.iter().enumerate() {
|
||||||
|
curr[0] = i + 1;
|
||||||
|
for (j, &cb) in b.iter().enumerate() {
|
||||||
|
let cost = if ca == cb { 0 } else { 1 };
|
||||||
|
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
|
||||||
|
}
|
||||||
|
std::mem::swap(&mut prev, &mut curr);
|
||||||
|
}
|
||||||
|
prev[b.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closest known command within a small edit distance, or a prefix/substring
|
||||||
|
/// match — `None` if nothing is close enough to suggest confidently.
|
||||||
|
fn nearest_command(input: &str) -> Option<String> {
|
||||||
|
let lower = input.to_lowercase();
|
||||||
|
// Exact prefix/substring hits first (e.g. "session" -> "sessions").
|
||||||
|
if let Some(c) = KNOWN_COMMANDS
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.starts_with(&lower) || lower.starts_with(**c))
|
||||||
|
{
|
||||||
|
return Some(c.to_string());
|
||||||
|
}
|
||||||
|
// Tolerance scales with length: short words get distance 1, longer get 2.
|
||||||
|
let max_dist = if lower.len() <= 4 { 1 } else { 2 };
|
||||||
|
KNOWN_COMMANDS
|
||||||
|
.iter()
|
||||||
|
.map(|c| (*c, edit_distance(&lower, c)))
|
||||||
|
.filter(|(_, d)| *d <= max_dist)
|
||||||
|
.min_by_key(|(_, d)| *d)
|
||||||
|
.map(|(c, _)| c.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
impl ParseError {
|
impl ParseError {
|
||||||
pub fn format(&self) -> String {
|
pub fn format(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
ParseError::UnknownCommand { command } => {
|
ParseError::UnknownCommand { command } => match nearest_command(command) {
|
||||||
format!("Unknown command: {}", command)
|
Some(suggestion) => format!(
|
||||||
}
|
"Unknown command: {}\nDid you mean: chrome-use {}?",
|
||||||
|
command, suggestion
|
||||||
|
),
|
||||||
|
None => format!("Unknown command: {}", command),
|
||||||
|
},
|
||||||
ParseError::UnknownSubcommand {
|
ParseError::UnknownSubcommand {
|
||||||
subcommand,
|
subcommand,
|
||||||
valid_options,
|
valid_options,
|
||||||
@@ -1296,6 +1349,11 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
// === Get ===
|
// === Get ===
|
||||||
"get" => parse_get(&rest, &id),
|
"get" => parse_get(&rest, &id),
|
||||||
|
|
||||||
|
// List every frame the session can reach (top + same-process child
|
||||||
|
// frames + out-of-process iframes), with a text-length per frame so you
|
||||||
|
// can see where a listing's description actually lives (issue #27).
|
||||||
|
"frames" => Ok(json!({ "id": id, "action": "frames" })),
|
||||||
|
|
||||||
// Top-level shortcuts for `get <x>` status reads — users naturally type
|
// Top-level shortcuts for `get <x>` status reads — users naturally type
|
||||||
// `chrome-use url` / `cdp-url` / `title` without the `get` prefix
|
// `chrome-use url` / `cdp-url` / `title` without the `get` prefix
|
||||||
// (and expect `cdp-url`/`cdp_url` to work interchangeably).
|
// (and expect `cdp-url`/`cdp_url` to work interchangeably).
|
||||||
@@ -2388,11 +2446,42 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
|||||||
|
|
||||||
match rest.first().copied() {
|
match rest.first().copied() {
|
||||||
Some("text") => {
|
Some("text") => {
|
||||||
// `get text` with no selector returns the whole page's text (body) —
|
// `get text --all-frames` aggregates visible text across every
|
||||||
// a common convenience; previously it errored without a selector
|
// frame, including out-of-process iframes invisible to the top
|
||||||
// (issue #24-D).
|
// document (issue #27). The selector is ignored in this mode.
|
||||||
let sel = rest.get(1).copied().unwrap_or("body");
|
let all_frames = rest[1..]
|
||||||
Ok(json!({ "id": id, "action": "gettext", "selector": sel }))
|
.iter()
|
||||||
|
.any(|a| matches!(*a, "--all-frames" | "--frames" | "-a"));
|
||||||
|
if all_frames {
|
||||||
|
return Ok(json!({ "id": id, "action": "gettext", "allFrames": true }));
|
||||||
|
}
|
||||||
|
// `get text --pierce` reads through CLOSED shadow DOM / child docs
|
||||||
|
// via the CDP DOM tree — content eval/innerText can't reach, e.g. an
|
||||||
|
// extension's injected panel in a closed shadow root (issue #30).
|
||||||
|
let pierce = rest[1..]
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(*a, "--pierce" | "--shadow" | "--deep"));
|
||||||
|
if pierce {
|
||||||
|
return Ok(json!({ "id": id, "action": "gettext", "pierce": true }));
|
||||||
|
}
|
||||||
|
// `get text --main` returns the main-content region (readability),
|
||||||
|
// skipping header/nav/footer/sidebar boilerplate (issue #27).
|
||||||
|
let main = rest[1..]
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(*a, "--main" | "--readable" | "-m"));
|
||||||
|
if main {
|
||||||
|
return Ok(json!({ "id": id, "action": "gettext", "main": true }));
|
||||||
|
}
|
||||||
|
// `get text` with no selector reads the WHOLE PAGE and now defaults
|
||||||
|
// to cross-frame aggregation, so an agent gets a page's iframed
|
||||||
|
// content (listing descriptions etc.) without having to know about
|
||||||
|
// `--all-frames` (#27). On a single-frame page this is identical to
|
||||||
|
// the old body read; multi-frame pages get the child frames too —
|
||||||
|
// a strict superset. An explicit selector stays element-scoped.
|
||||||
|
match rest.iter().skip(1).find(|a| !a.starts_with("--")).copied() {
|
||||||
|
Some(sel) => Ok(json!({ "id": id, "action": "gettext", "selector": sel })),
|
||||||
|
None => Ok(json!({ "id": id, "action": "gettext", "allFrames": true })),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some("html") => {
|
Some("html") => {
|
||||||
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
let sel = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
|
||||||
@@ -4750,15 +4839,81 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_text_defaults_to_body() {
|
fn test_get_text_defaults_to_all_frames() {
|
||||||
// `get text` with no selector now returns the whole page (body) instead
|
// `get text` with no selector now reads the whole page across ALL frames
|
||||||
// of erroring (issue #24-D).
|
// by default (#27), so iframed content isn't silently missed. (Was: a
|
||||||
|
// top-frame `body` read, #24-D.)
|
||||||
let cmd = parse_command(&args("get text"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("get text"), &default_flags()).unwrap();
|
||||||
assert_eq!(cmd["action"], "gettext");
|
assert_eq!(cmd["action"], "gettext");
|
||||||
assert_eq!(cmd["selector"], "body");
|
assert_eq!(cmd["allFrames"], true);
|
||||||
// An explicit selector still wins.
|
assert!(cmd.get("selector").is_none());
|
||||||
|
// An explicit selector still wins and stays element-scoped.
|
||||||
let cmd2 = parse_command(&args("get text h1"), &default_flags()).unwrap();
|
let cmd2 = parse_command(&args("get text h1"), &default_flags()).unwrap();
|
||||||
assert_eq!(cmd2["selector"], "h1");
|
assert_eq!(cmd2["selector"], "h1");
|
||||||
|
assert!(cmd2.get("allFrames").is_none());
|
||||||
|
// `text` top-level shortcut behaves the same.
|
||||||
|
let cmd3 = parse_command(&args("text"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd3["allFrames"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_text_all_frames() {
|
||||||
|
// `--all-frames` switches to whole-page, cross-frame aggregation and
|
||||||
|
// drops the selector (issue #27).
|
||||||
|
for variant in ["get text --all-frames", "get text --frames", "text -a"] {
|
||||||
|
let cmd = parse_command(&args(variant), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "gettext", "{variant}");
|
||||||
|
assert_eq!(cmd["allFrames"], true, "{variant}");
|
||||||
|
assert!(cmd.get("selector").is_none(), "{variant}");
|
||||||
|
}
|
||||||
|
// A flag mixed with a selector still triggers all-frames.
|
||||||
|
let cmd = parse_command(&args("get text body --all-frames"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["allFrames"], true);
|
||||||
|
// Without the flag, a leading flag-like token is skipped for the selector.
|
||||||
|
let cmd = parse_command(&args("get text main"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["selector"], "main");
|
||||||
|
assert!(cmd.get("allFrames").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_frames_command() {
|
||||||
|
let cmd = parse_command(&args("frames"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "frames");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nearest_command_suggestions() {
|
||||||
|
assert_eq!(nearest_command("sesions").as_deref(), Some("sessions"));
|
||||||
|
assert_eq!(nearest_command("session").as_deref(), Some("sessions"));
|
||||||
|
assert_eq!(nearest_command("clik").as_deref(), Some("click"));
|
||||||
|
assert_eq!(nearest_command("screenshits").as_deref(), Some("screenshot"));
|
||||||
|
// Nonsense with no close match stays silent.
|
||||||
|
assert_eq!(nearest_command("xyzzy"), None);
|
||||||
|
// The unknown-command error embeds the suggestion.
|
||||||
|
let err = ParseError::UnknownCommand {
|
||||||
|
command: "sesions".to_string(),
|
||||||
|
};
|
||||||
|
assert!(err.format().contains("Did you mean: chrome-use sessions?"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_text_main() {
|
||||||
|
for variant in ["get text --main", "get text --readable", "text -m"] {
|
||||||
|
let cmd = parse_command(&args(variant), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "gettext", "{variant}");
|
||||||
|
assert_eq!(cmd["main"], true, "{variant}");
|
||||||
|
assert!(cmd.get("selector").is_none(), "{variant}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_text_pierce() {
|
||||||
|
for variant in ["get text --pierce", "get text --shadow", "text --deep"] {
|
||||||
|
let cmd = parse_command(&args(variant), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "gettext", "{variant}");
|
||||||
|
assert_eq!(cmd["pierce"], true, "{variant}");
|
||||||
|
assert!(cmd.get("selector").is_none(), "{variant}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -893,6 +893,14 @@ fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `sessions` is a natural top-level guess for "list my sessions" (the skill
|
||||||
|
// advertises sessions as a feature) — route it to the daemon inventory the
|
||||||
|
// same way `daemon status` does (issue #29).
|
||||||
|
if clean.first().map(|s| s.as_str()) == Some("sessions") {
|
||||||
|
run_daemon(&["sessions".to_string(), "status".to_string()], flags.json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle close --all: close all active sessions
|
// Handle close --all: close all active sessions
|
||||||
if matches!(
|
if matches!(
|
||||||
clean.first().map(|s| s.as_str()),
|
clean.first().map(|s| s.as_str()),
|
||||||
|
|||||||
@@ -1332,6 +1332,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
"uncheck" => handle_uncheck(cmd, state).await,
|
"uncheck" => handle_uncheck(cmd, state).await,
|
||||||
"wait" => handle_wait(cmd, state).await,
|
"wait" => handle_wait(cmd, state).await,
|
||||||
"gettext" => handle_gettext(cmd, state).await,
|
"gettext" => handle_gettext(cmd, state).await,
|
||||||
|
"frames" => handle_frames(cmd, state).await,
|
||||||
"getattribute" => handle_getattribute(cmd, state).await,
|
"getattribute" => handle_getattribute(cmd, state).await,
|
||||||
"isvisible" => handle_isvisible(cmd, state).await,
|
"isvisible" => handle_isvisible(cmd, state).await,
|
||||||
"isenabled" => handle_isenabled(cmd, state).await,
|
"isenabled" => handle_isenabled(cmd, state).await,
|
||||||
@@ -3594,6 +3595,56 @@ async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri
|
|||||||
async fn handle_gettext(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_gettext(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
|
|
||||||
|
// `get text --all-frames` aggregates visible text across every frame the
|
||||||
|
// session can reach — including out-of-process iframes that never show up
|
||||||
|
// in the top document (#27: Yahoo/Rakuten/Mercari listing descriptions).
|
||||||
|
if cmd.get("allFrames").and_then(|v| v.as_bool()) == Some(true) {
|
||||||
|
let frames = super::element::collect_all_frames_text(
|
||||||
|
&mgr.client,
|
||||||
|
&session_id,
|
||||||
|
&state.iframe_sessions,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut combined = String::new();
|
||||||
|
let mut frame_count = 0usize;
|
||||||
|
for f in &frames {
|
||||||
|
let t = f.text.trim();
|
||||||
|
if t.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
frame_count += 1;
|
||||||
|
if f.kind != "top" {
|
||||||
|
combined.push_str(&format!("\n\n----- frame [{}] {} -----\n", f.kind, f.url));
|
||||||
|
}
|
||||||
|
combined.push_str(t);
|
||||||
|
}
|
||||||
|
let url = mgr.get_url().await.unwrap_or_default();
|
||||||
|
return Ok(json!({
|
||||||
|
"text": combined,
|
||||||
|
"origin": url,
|
||||||
|
"frames": frame_count,
|
||||||
|
"allFrames": true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// `get text --pierce` reads text through CLOSED shadow roots and child
|
||||||
|
// documents via the CDP DOM tree — content `innerText`/`eval` can't see,
|
||||||
|
// e.g. an extension's injected panel in a closed shadow DOM (#30).
|
||||||
|
if cmd.get("pierce").and_then(|v| v.as_bool()) == Some(true) {
|
||||||
|
let text = super::element::get_pierced_text(&mgr.client, &session_id).await?;
|
||||||
|
let url = mgr.get_url().await.unwrap_or_default();
|
||||||
|
return Ok(json!({ "text": text, "origin": url, "pierce": true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// `get text --main` returns the page's main-content region (readability-lite),
|
||||||
|
// skipping global header/nav/footer/sidebar boilerplate (#27).
|
||||||
|
if cmd.get("main").and_then(|v| v.as_bool()) == Some(true) {
|
||||||
|
let text = super::element::get_main_content_text(&mgr.client, &session_id).await?;
|
||||||
|
let url = mgr.get_url().await.unwrap_or_default();
|
||||||
|
return Ok(json!({ "text": text, "origin": url, "main": true }));
|
||||||
|
}
|
||||||
|
|
||||||
let selector = cmd
|
let selector = cmd
|
||||||
.get("selector")
|
.get("selector")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -3611,6 +3662,32 @@ async fn handle_gettext(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
|
|||||||
Ok(json!({ "text": text, "origin": url }))
|
Ok(json!({ "text": text, "origin": url }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_frames(_cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
|
let frames = super::element::collect_all_frames_text(
|
||||||
|
&mgr.client,
|
||||||
|
&session_id,
|
||||||
|
&state.iframe_sessions,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let list: Vec<Value> = frames
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, f)| {
|
||||||
|
json!({
|
||||||
|
"index": i,
|
||||||
|
"kind": f.kind,
|
||||||
|
"url": f.url,
|
||||||
|
"frameId": f.frame_id,
|
||||||
|
"textLen": f.text.trim().chars().count(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let url = mgr.get_url().await.unwrap_or_default();
|
||||||
|
Ok(json!({ "frames": list, "count": list.len(), "origin": url }))
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_getattribute(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_getattribute(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
@@ -4493,7 +4570,22 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
state.ref_map.clear();
|
state.ref_map.clear();
|
||||||
state.iframe_sessions.clear();
|
state.iframe_sessions.clear();
|
||||||
state.active_frame_id = None;
|
state.active_frame_id = None;
|
||||||
let result = mgr.tab_switch_by_id(tab_id).await?;
|
let mut result = mgr.tab_switch_by_id(tab_id).await?;
|
||||||
|
|
||||||
|
// Liveness probe: confirm the new session actually answers before we report
|
||||||
|
// success, so `tab <id>` doesn't print a misleading ✓ for a session that's
|
||||||
|
// stale and will fail on the very next command (issue #29.3). On the churned
|
||||||
|
// -tabId case the ext-0.4.9 targetId recovery (#24) self-heals within ~6s, so
|
||||||
|
// we surface a warning rather than a hard error to avoid a false failure
|
||||||
|
// during that window.
|
||||||
|
if mgr.evaluate("1", None).await.is_err() {
|
||||||
|
if let Some(obj) = result.as_object_mut() {
|
||||||
|
obj.insert(
|
||||||
|
"warning".to_string(),
|
||||||
|
json!("switched tab is not responding yet (session re-attaching); retry the next command"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// `--activate`: raise this tab to the foreground (the switch made it active;
|
// `--activate`: raise this tab to the foreground (the switch made it active;
|
||||||
// bring_to_front acts on the active tab) — for handing a specific tab to the
|
// bring_to_front acts on the active tab) — for handing a specific tab to the
|
||||||
|
|||||||
@@ -975,6 +975,263 @@ pub async fn get_element_text(
|
|||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Text content collected from a single frame of the page.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FrameText {
|
||||||
|
pub frame_id: String,
|
||||||
|
pub url: String,
|
||||||
|
/// "top" | "inline" (same-process child frame) | "oopif" (out-of-process).
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The expression we run in every frame to read its visible text. innerText
|
||||||
|
// honors CSS visibility (skips display:none), textContent is the fallback.
|
||||||
|
const FRAME_INNERTEXT_JS: &str = "(function(){try{var b=document.body||document.documentElement;return b?(b.innerText||b.textContent||''):'';}catch(e){return '';}})()";
|
||||||
|
|
||||||
|
async fn eval_text_default(client: &CdpClient, session_id: &str) -> String {
|
||||||
|
let res = client
|
||||||
|
.send_command(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"expression": FRAME_INNERTEXT_JS,
|
||||||
|
"returnByValue": true,
|
||||||
|
})),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
res.ok()
|
||||||
|
.and_then(|v| v.get("result").and_then(|r| r.get("value")).cloned())
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-process child frames share the top renderer but live in their own
|
||||||
|
// execution context. Page.createIsolatedWorld hands us a context id bound to
|
||||||
|
// that frame so Runtime.evaluate reads the child document, not the parent.
|
||||||
|
async fn eval_text_in_frame(client: &CdpClient, session_id: &str, frame_id: &str) -> String {
|
||||||
|
let ctx = client
|
||||||
|
.send_command(
|
||||||
|
"Page.createIsolatedWorld",
|
||||||
|
Some(serde_json::json!({ "frameId": frame_id, "worldName": "chrome_use_text" })),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get("executionContextId").and_then(|c| c.as_i64()));
|
||||||
|
let Some(ctx_id) = ctx else { return String::new() };
|
||||||
|
let res = client
|
||||||
|
.send_command(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"expression": FRAME_INNERTEXT_JS,
|
||||||
|
"returnByValue": true,
|
||||||
|
"contextId": ctx_id,
|
||||||
|
})),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
res.ok()
|
||||||
|
.and_then(|v| v.get("result").and_then(|r| r.get("value")).cloned())
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_frame_tree(node: &Value, is_top: bool, out: &mut Vec<(String, String, bool)>) {
|
||||||
|
if let Some(frame) = node.get("frame") {
|
||||||
|
if let Some(id) = frame.get("id").and_then(|v| v.as_str()) {
|
||||||
|
let url = frame
|
||||||
|
.get("url")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
out.push((id.to_string(), url, is_top));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(children) = node.get("childFrames").and_then(|v| v.as_array()) {
|
||||||
|
for child in children {
|
||||||
|
flatten_frame_tree(child, false, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect visible text from every frame reachable in the active session,
|
||||||
|
/// including out-of-process iframes (which never appear in the top frame's
|
||||||
|
/// `Page.getFrameTree` and so are invisible to `document.body.innerText`).
|
||||||
|
///
|
||||||
|
/// Same-process child frames are read through `Page.createIsolatedWorld`;
|
||||||
|
/// OOPIFs are read through their own auto-attached debugger session
|
||||||
|
/// (`iframe_sessions`, keyed by frameId == targetId). This is the engine
|
||||||
|
/// behind `get text --all-frames` and `chrome-use frames` — the fix for
|
||||||
|
/// listing/marketplace pages whose description lives in a child frame (#27).
|
||||||
|
pub async fn collect_all_frames_text(
|
||||||
|
client: &CdpClient,
|
||||||
|
top_session: &str,
|
||||||
|
iframe_sessions: &HashMap<String, String>,
|
||||||
|
) -> Result<Vec<FrameText>, String> {
|
||||||
|
let mut out: Vec<FrameText> = Vec::new();
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
// 1. Top session: the top frame plus its same-process descendants. OOPIF
|
||||||
|
// frames that happen to surface here are skipped — they're read via
|
||||||
|
// their dedicated session in step 2 (cross-process isolated worlds fail).
|
||||||
|
let tree = client
|
||||||
|
.send_command_no_params("Page.getFrameTree", Some(top_session))
|
||||||
|
.await?;
|
||||||
|
let mut frames: Vec<(String, String, bool)> = Vec::new();
|
||||||
|
flatten_frame_tree(&tree["frameTree"], true, &mut frames);
|
||||||
|
for (fid, url, is_top) in frames {
|
||||||
|
if iframe_sessions.contains_key(&fid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !seen.insert(fid.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (kind, text) = if is_top {
|
||||||
|
("top", eval_text_default(client, top_session).await)
|
||||||
|
} else {
|
||||||
|
("inline", eval_text_in_frame(client, top_session, &fid).await)
|
||||||
|
};
|
||||||
|
out.push(FrameText {
|
||||||
|
frame_id: fid,
|
||||||
|
url,
|
||||||
|
kind,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Each out-of-process iframe, read through its own session.
|
||||||
|
for (fid, sid) in iframe_sessions {
|
||||||
|
if !seen.insert(fid.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let url = client
|
||||||
|
.send_command_no_params("Page.getFrameTree", Some(sid))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| {
|
||||||
|
t.get("frameTree")
|
||||||
|
.and_then(|ft| ft.get("frame"))
|
||||||
|
.and_then(|f| f.get("url"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let text = eval_text_default(client, sid).await;
|
||||||
|
out.push(FrameText {
|
||||||
|
frame_id: fid.clone(),
|
||||||
|
url,
|
||||||
|
kind: "oopif",
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readability-lite: prefer the page's semantic main-content region over the
|
||||||
|
// whole body so global header/nav/footer chrome (and, on many listing pages,
|
||||||
|
// the "related items" sidebar) doesn't drown out the actual content. Runs on
|
||||||
|
// the live, rendered tree (innerText needs layout — a detached clone returns
|
||||||
|
// empty), so we pick the densest <main>/<article> region rather than cloning
|
||||||
|
// and stripping. Falls back to <body> when no substantial main region exists.
|
||||||
|
const MAIN_CONTENT_JS: &str = r#"(function(){
|
||||||
|
function txt(el){try{return (el.innerText||'').trim();}catch(e){return '';}}
|
||||||
|
var sels=['main','[role=main]','article','#main','#contents','#l-content'];
|
||||||
|
var best=null,bestLen=0;
|
||||||
|
for(var i=0;i<sels.length;i++){
|
||||||
|
var els=document.querySelectorAll(sels[i]);
|
||||||
|
for(var j=0;j<els.length;j++){var l=txt(els[j]).length;if(l>bestLen){bestLen=l;best=els[j];}}
|
||||||
|
}
|
||||||
|
if(best&&bestLen>200)return txt(best);
|
||||||
|
return txt(document.body);
|
||||||
|
})()"#;
|
||||||
|
|
||||||
|
/// Extract the page's main-content text (readability-lite), preferring a
|
||||||
|
/// semantic `<main>`/`<article>` region over the full body. Used by
|
||||||
|
/// `get text --main` to avoid header/nav/sidebar boilerplate (#27).
|
||||||
|
pub async fn get_main_content_text(client: &CdpClient, session_id: &str) -> Result<String, String> {
|
||||||
|
let res = client
|
||||||
|
.send_command(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"expression": MAIN_CONTENT_JS,
|
||||||
|
"returnByValue": true,
|
||||||
|
})),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(res
|
||||||
|
.get("result")
|
||||||
|
.and_then(|r| r.get("value"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text nodes whose parent is one of these carry no visible content.
|
||||||
|
fn is_noise_tag(name: &str) -> bool {
|
||||||
|
matches!(name, "SCRIPT" | "STYLE" | "NOSCRIPT" | "TEMPLATE" | "HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk a CDP DOM.Node tree, collecting text-node values. Unlike `innerText`
|
||||||
|
// (JS, blocked by CLOSED shadow roots), the CDP DOM tree from
|
||||||
|
// `DOM.getDocument(pierce:true)` includes closed shadow roots and child
|
||||||
|
// documents — so this reaches text JS can't. `parent_noise` carries whether an
|
||||||
|
// ancestor was <script>/<style>/etc so their text is skipped.
|
||||||
|
fn collect_dom_text(node: &Value, parent_noise: bool, out: &mut String) {
|
||||||
|
let node_type = node.get("nodeType").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let node_name = node.get("nodeName").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if node_type == 3 {
|
||||||
|
if !parent_noise {
|
||||||
|
if let Some(t) = node.get("nodeValue").and_then(|v| v.as_str()) {
|
||||||
|
let t = t.trim();
|
||||||
|
if !t.is_empty() {
|
||||||
|
if !out.is_empty() {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out.push_str(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let noise = parent_noise || is_noise_tag(node_name);
|
||||||
|
if let Some(children) = node.get("children").and_then(|v| v.as_array()) {
|
||||||
|
for child in children {
|
||||||
|
collect_dom_text(child, noise, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(shadow) = node.get("shadowRoots").and_then(|v| v.as_array()) {
|
||||||
|
for sr in shadow {
|
||||||
|
collect_dom_text(sr, noise, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(doc) = node.get("contentDocument") {
|
||||||
|
collect_dom_text(doc, noise, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text from the page via the CDP DOM tree with `pierce:true`, which
|
||||||
|
/// reaches into CLOSED shadow roots and child documents that `innerText`/`eval`
|
||||||
|
/// cannot. Lets an agent read content rendered into a closed shadow DOM (e.g. an
|
||||||
|
/// extension's injected debug panel) without any extra Chrome permission — it
|
||||||
|
/// rides the per-tab debugger session that's already attached (#30).
|
||||||
|
pub async fn get_pierced_text(client: &CdpClient, session_id: &str) -> Result<String, String> {
|
||||||
|
let doc = client
|
||||||
|
.send_command(
|
||||||
|
"DOM.getDocument",
|
||||||
|
Some(serde_json::json!({ "depth": -1, "pierce": true })),
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut out = String::new();
|
||||||
|
if let Some(root) = doc.get("root") {
|
||||||
|
collect_dom_text(root, false, &mut out);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_element_attribute(
|
pub async fn get_element_attribute(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@@ -1448,6 +1705,31 @@ mod tests {
|
|||||||
assert_eq!(parse_ref("@e123"), Some("e123".to_string()));
|
assert_eq!(parse_ref("@e123"), Some("e123".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_collect_dom_text_pierces_closed_shadow_and_skips_noise() {
|
||||||
|
// A CDP DOM.Node tree: a host element whose CLOSED shadow root holds the
|
||||||
|
// text, plus a <script> whose text must be skipped.
|
||||||
|
let tree = serde_json::json!({
|
||||||
|
"nodeType": 1, "nodeName": "BODY",
|
||||||
|
"children": [
|
||||||
|
{ "nodeType": 1, "nodeName": "SCRIPT",
|
||||||
|
"children": [ { "nodeType": 3, "nodeName": "#text", "nodeValue": "var secret=1;" } ] },
|
||||||
|
{ "nodeType": 1, "nodeName": "DIV",
|
||||||
|
"shadowRoots": [
|
||||||
|
{ "nodeType": 11, "nodeName": "#document-fragment",
|
||||||
|
"children": [
|
||||||
|
{ "nodeType": 1, "nodeName": "SPAN",
|
||||||
|
"children": [ { "nodeType": 3, "nodeName": "#text", "nodeValue": "DECRYPTED 42" } ] }
|
||||||
|
] }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let mut out = String::new();
|
||||||
|
collect_dom_text(&tree, false, &mut out);
|
||||||
|
assert_eq!(out, "DECRYPTED 42");
|
||||||
|
assert!(!out.contains("secret"), "script text must be skipped");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_ref_equals_prefix() {
|
fn test_parse_ref_equals_prefix() {
|
||||||
assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
|
assert_eq!(parse_ref("ref=e1"), Some("e1".to_string()));
|
||||||
|
|||||||
+51
-14
@@ -342,6 +342,34 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Frame list (`chrome-use frames`)
|
||||||
|
if action == Some("frames") {
|
||||||
|
if let Some(list) = data.get("frames").and_then(|v| v.as_array()) {
|
||||||
|
let count = list.len();
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
color::bold(&format!("{} frame{}", count, if count == 1 { "" } else { "s" }))
|
||||||
|
);
|
||||||
|
for f in list {
|
||||||
|
let idx = f.get("index").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let kind = f.get("kind").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let url = f.get("url").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let len = f.get("textLen").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
println!(
|
||||||
|
" [{}] {:<6} {} chars {}",
|
||||||
|
idx,
|
||||||
|
kind,
|
||||||
|
len,
|
||||||
|
color::dim(if url.is_empty() { "(about:blank)" } else { url })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
color::dim("read everything with: chrome-use get text --all-frames")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Title
|
// Title
|
||||||
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
|
||||||
println!("{}", title);
|
println!("{}", title);
|
||||||
@@ -569,19 +597,21 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
// Tab switch
|
// Tab switch
|
||||||
if action == Some("tab_switch") {
|
if action == Some("tab_switch") {
|
||||||
if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) {
|
if let Some(tab_id) = data.get("tabId").and_then(|v| v.as_str()) {
|
||||||
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
let warning = data.get("warning").and_then(|v| v.as_str());
|
||||||
println!(
|
// A non-responding session isn't a real success — show a warning
|
||||||
"{} Switched to tab [{}] ({})",
|
// indicator instead of the green ✓ (issue #29.3).
|
||||||
color::success_indicator(),
|
let indicator = if warning.is_some() {
|
||||||
tab_id,
|
color::warning_indicator()
|
||||||
url
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
println!(
|
color::success_indicator()
|
||||||
"{} Switched to tab [{}]",
|
};
|
||||||
color::success_indicator(),
|
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
|
||||||
tab_id
|
println!("{} Switched to tab [{}] ({})", indicator, tab_id, url);
|
||||||
);
|
} else {
|
||||||
|
println!("{} Switched to tab [{}]", indicator, tab_id);
|
||||||
|
}
|
||||||
|
if let Some(w) = warning {
|
||||||
|
eprintln!("{}", color::dim(w));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1929,7 +1959,9 @@ Usage: chrome-use get <subcommand> [args]
|
|||||||
Retrieves various types of information from elements or the page.
|
Retrieves various types of information from elements or the page.
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
text <selector> Get text content of element
|
text [selector] Element text; no selector = WHOLE PAGE, all frames
|
||||||
|
text --main Main-content text only (skip nav/header/sidebar)
|
||||||
|
text --pierce Read through CLOSED shadow DOM (injected panels)
|
||||||
html <selector> Get inner HTML of element
|
html <selector> Get inner HTML of element
|
||||||
value <selector> Get value of input element
|
value <selector> Get value of input element
|
||||||
attr <selector> <name> Get attribute value
|
attr <selector> <name> Get attribute value
|
||||||
@@ -1945,7 +1977,10 @@ Global Options:
|
|||||||
--session <name> Use specific session
|
--session <name> Use specific session
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
chrome-use get text @e1
|
chrome-use get text # whole page across ALL frames (default)
|
||||||
|
chrome-use get text @e1 # one element
|
||||||
|
chrome-use get text --main # main content, no nav/sidebar boilerplate
|
||||||
|
chrome-use frames # list frames + where the text lives
|
||||||
chrome-use get html "#content"
|
chrome-use get html "#content"
|
||||||
chrome-use get value "#email-input"
|
chrome-use get value "#email-input"
|
||||||
chrome-use get attr "#link" href
|
chrome-use get attr "#link" href
|
||||||
@@ -3155,6 +3190,7 @@ Navigation:
|
|||||||
|
|
||||||
Get Info: chrome-use get <what> [selector]
|
Get Info: chrome-use get <what> [selector]
|
||||||
text, html, value, attr <name>, title, url, count, box, styles, cdp-url
|
text, html, value, attr <name>, title, url, count, box, styles, cdp-url
|
||||||
|
text (no selector = whole page, all frames), text --main, frames (list)
|
||||||
|
|
||||||
Check State: chrome-use is <what> <selector>
|
Check State: chrome-use is <what> <selector>
|
||||||
visible, enabled, checked
|
visible, enabled, checked
|
||||||
@@ -3253,6 +3289,7 @@ Confirmation:
|
|||||||
Sessions:
|
Sessions:
|
||||||
session Show current session name
|
session Show current session name
|
||||||
session list List active sessions
|
session list List active sessions
|
||||||
|
sessions List running session daemons (alias of daemon status)
|
||||||
daemon status List running session daemons (+ relay state)
|
daemon status List running session daemons (+ relay state)
|
||||||
daemon restart Kill all session daemons; keeps the extension relay
|
daemon restart Kill all session daemons; keeps the extension relay
|
||||||
up. Clears stale/cross-leaked state after an upgrade.
|
up. Clears stale/cross-leaked state after an upgrade.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -30,6 +30,16 @@ const tabs = new Map()
|
|||||||
const sessionToTab = new Map()
|
const sessionToTab = new Map()
|
||||||
/** child (OOPIF/worker) sessionId -> tabId */
|
/** child (OOPIF/worker) sessionId -> tabId */
|
||||||
const childSessionToTab = new Map()
|
const childSessionToTab = new Map()
|
||||||
|
/** sessionId -> CDP targetId, kept ACROSS detach so a dead `cb-tab-<oldTabId>`
|
||||||
|
* session can be recovered by its stable targetId when the cross-process nav
|
||||||
|
* gave the tab a new Chrome tabId (issue #24). Capped to bound memory. */
|
||||||
|
const sessionTargets = new Map()
|
||||||
|
function rememberSessionTarget(sessionId, targetId) {
|
||||||
|
if (!sessionId || !targetId) return
|
||||||
|
sessionTargets.delete(sessionId)
|
||||||
|
sessionTargets.set(sessionId, targetId)
|
||||||
|
if (sessionTargets.size > 256) sessionTargets.delete(sessionTargets.keys().next().value)
|
||||||
|
}
|
||||||
/** tab-group name -> chrome tabGroups id (best-effort cache) */
|
/** tab-group name -> chrome tabGroups id (best-effort cache) */
|
||||||
const groupIdByName = new Map()
|
const groupIdByName = new Map()
|
||||||
|
|
||||||
@@ -172,17 +182,49 @@ function tabIdFromSession(sessionId) {
|
|||||||
// (closed / restricted). (issues #20.1, #23)
|
// (closed / restricted). (issues #20.1, #23)
|
||||||
async function recoverSessionTab(sessionId) {
|
async function recoverSessionTab(sessionId) {
|
||||||
const tabId = tabIdFromSession(sessionId)
|
const tabId = tabIdFromSession(sessionId)
|
||||||
if (tabId == null) return null
|
// 1) Fast path: the encoded Chrome tabId still exists — re-attach it (covers
|
||||||
for (let i = 0; i < 3; i++) {
|
// the common renderer-process swap where the tabId is preserved, #23).
|
||||||
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
if (tabId != null) {
|
||||||
if (!eligible(tab)) return null
|
for (let i = 0; i < 3; i++) {
|
||||||
try {
|
const tab = await chrome.tabs.get(tabId).catch(() => null)
|
||||||
await attachTab(tabId)
|
if (!eligible(tab)) break // tabId is gone — fall through to targetId recovery
|
||||||
if (tabs.has(tabId)) return tabId
|
try {
|
||||||
} catch {
|
await attachTab(tabId)
|
||||||
// mid-swap: the tab exists but isn't attachable yet — back off and retry.
|
if (tabs.has(tabId)) return tabId
|
||||||
|
} catch {
|
||||||
|
// mid-swap: tab exists but isn't attachable yet — back off and retry.
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 120 + i * 150))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) The Chrome tabId is gone, but the CDP targetId is STABLE across the nav.
|
||||||
|
// Some cross-process hops (Mercari's signin token exchange) give the tab a
|
||||||
|
// NEW tabId while keeping the same target, so `cb-tab-<oldTabId>` can't be
|
||||||
|
// recovered by tabId. Find the tab now hosting our remembered targetId via
|
||||||
|
// chrome.debugger.getTargets(), attach it, and ALIAS the dead session to it
|
||||||
|
// so the daemon's session id keeps resolving. Longer window: this hop can
|
||||||
|
// take several seconds to settle (issue #24).
|
||||||
|
const targetId = sessionTargets.get(sessionId)
|
||||||
|
if (targetId) {
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const targets = await chrome.debugger.getTargets().catch(() => null)
|
||||||
|
const t = targets && targets.find((x) => x.id === targetId && x.tabId != null)
|
||||||
|
if (t && t.tabId != null) {
|
||||||
|
const tab = await chrome.tabs.get(t.tabId).catch(() => null)
|
||||||
|
if (eligible(tab)) {
|
||||||
|
try {
|
||||||
|
await attachTab(t.tabId)
|
||||||
|
if (tabs.has(t.tabId)) {
|
||||||
|
sessionToTab.set(sessionId, t.tabId) // alias dead session -> live tab
|
||||||
|
return t.tabId
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not attachable yet — keep waiting for the swap to settle.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 300 + i * 300))
|
||||||
}
|
}
|
||||||
await new Promise((r) => setTimeout(r, 120 + i * 150))
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -343,6 +385,7 @@ async function attachTab(tabId) {
|
|||||||
const entry = { sessionId, targetId }
|
const entry = { sessionId, targetId }
|
||||||
tabs.set(tabId, entry)
|
tabs.set(tabId, entry)
|
||||||
sessionToTab.set(sessionId, tabId)
|
sessionToTab.set(sessionId, tabId)
|
||||||
|
rememberSessionTarget(sessionId, targetId)
|
||||||
setBadge(tabId, port ? 'on' : 'connecting')
|
setBadge(tabId, port ? 'on' : 'connecting')
|
||||||
postToHost({
|
postToHost({
|
||||||
method: 'forwardCDPEvent',
|
method: 'forwardCDPEvent',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "chrome-use",
|
"name": "chrome-use",
|
||||||
"version": "0.4.8",
|
"version": "0.4.9",
|
||||||
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||||
"icons": {
|
"icons": {
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chrome-use",
|
"name": "chrome-use",
|
||||||
"version": "1.5.0",
|
"version": "1.5.4",
|
||||||
"description": "chrome-use \u2014 drive your real, logged-in Chrome from any AI agent, stealth by default",
|
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -207,7 +207,11 @@ assigned fresh on every snapshot.
|
|||||||
For unstructured reading (no refs needed):
|
For unstructured reading (no refs needed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
chrome-use get text @e1 # visible text of an element
|
chrome-use get text # WHOLE PAGE — all frames by default (see below)
|
||||||
|
chrome-use get text @e1 # visible text of one element (or a CSS selector)
|
||||||
|
chrome-use get text --main # main content only — skip nav/header/sidebar
|
||||||
|
chrome-use get text --pierce # read through CLOSED shadow DOM (injected panels)
|
||||||
|
chrome-use frames # list every frame + where the text lives
|
||||||
chrome-use get html @e1 # innerHTML
|
chrome-use get html @e1 # innerHTML
|
||||||
chrome-use get attr @e1 href # any attribute
|
chrome-use get attr @e1 href # any attribute
|
||||||
chrome-use get value @e1 # input value
|
chrome-use get value @e1 # input value
|
||||||
@@ -216,6 +220,27 @@ chrome-use get url # current URL
|
|||||||
chrome-use get count ".item" # count matching elements
|
chrome-use get count ".item" # count matching elements
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Whole-page text is cross-frame by default.** `chrome-use get text` with no
|
||||||
|
selector aggregates visible text across **every** frame — top document plus
|
||||||
|
same-process child frames plus cross-origin iframes — so you never silently miss
|
||||||
|
content that lives in an iframe (Yahoo Auctions / Rakuten / Mercari shop
|
||||||
|
descriptions, embedded checkout/spec frames). Each child frame is delimited with
|
||||||
|
a `----- frame [kind] url -----` marker. You do **not** need to remember a flag —
|
||||||
|
the default already reads all frames. (`--all-frames` is still accepted as an
|
||||||
|
explicit alias.)
|
||||||
|
|
||||||
|
So: when text looks missing or wrong, you don't have to guess — just
|
||||||
|
`chrome-use get text` reads everything. To **see** the structure (which frame
|
||||||
|
holds what), run `chrome-use frames`. To **cut boilerplate** (global nav/header/
|
||||||
|
footer, "related items" sidebars), use `chrome-use get text --main`. If content
|
||||||
|
is lazy-loaded, `scroll` it into view first, then read.
|
||||||
|
|
||||||
|
**Closed shadow DOM.** Some injected UI (browser-extension debug panels, web
|
||||||
|
components) renders into a *closed* shadow root that `eval`/`innerText` cannot
|
||||||
|
read. `chrome-use get text --pierce` reads through closed shadow roots and child
|
||||||
|
documents via the CDP DOM tree — use it when content is clearly on screen (you
|
||||||
|
see it in a screenshot) but `get text`/`eval` come back empty.
|
||||||
|
|
||||||
## Interacting
|
## Interacting
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user