feat(text): 'get text --pierce' reads through CLOSED shadow DOM (#30)
Some injected UI (browser-extension debug panels, web components) renders into a CLOSED shadow root that eval/innerText cannot read. --pierce walks the CDP DOM tree (DOM.getDocument depth:-1 pierce:true), which includes closed shadow roots and child documents, and collects text nodes (skipping script/style/etc). Review-safe: rides the per-tab debugger session already attached, no new Chrome permission and no ab-connect/extension change — so it works in extension-relay mode without touching the published extension. Verified live: a closed-shadow panel that main-world eval reports HIDDEN is read in full via --pierce. First slice of #30 (read extension/injected-panel content). Deeper extension introspection (background SW / chrome.storage) stays a launch-mode / raw-CDP concern, deliberately NOT done by expanding ab-connect's debugger powers.
This commit is contained in:
@@ -2455,6 +2455,15 @@ fn parse_get(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
|||||||
if all_frames {
|
if all_frames {
|
||||||
return Ok(json!({ "id": id, "action": "gettext", "allFrames": true }));
|
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),
|
// `get text --main` returns the main-content region (readability),
|
||||||
// skipping header/nav/footer/sidebar boilerplate (issue #27).
|
// skipping header/nav/footer/sidebar boilerplate (issue #27).
|
||||||
let main = rest[1..]
|
let main = rest[1..]
|
||||||
@@ -4897,6 +4906,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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]
|
||||||
fn test_tab_activate_flag() {
|
fn test_tab_activate_flag() {
|
||||||
let plain = parse_command(&args("tab t3"), &default_flags()).unwrap();
|
let plain = parse_command(&args("tab t3"), &default_flags()).unwrap();
|
||||||
|
|||||||
@@ -3628,6 +3628,15 @@ async fn handle_gettext(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `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),
|
// `get text --main` returns the page's main-content region (readability-lite),
|
||||||
// skipping global header/nav/footer/sidebar boilerplate (#27).
|
// skipping global header/nav/footer/sidebar boilerplate (#27).
|
||||||
if cmd.get("main").and_then(|v| v.as_bool()) == Some(true) {
|
if cmd.get("main").and_then(|v| v.as_bool()) == Some(true) {
|
||||||
|
|||||||
@@ -1169,6 +1169,69 @@ pub async fn get_main_content_text(client: &CdpClient, session_id: &str) -> Resu
|
|||||||
.to_string())
|
.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,
|
||||||
@@ -1642,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()));
|
||||||
|
|||||||
@@ -1961,6 +1961,7 @@ Retrieves various types of information from elements or the page.
|
|||||||
Subcommands:
|
Subcommands:
|
||||||
text [selector] Element text; no selector = WHOLE PAGE, all frames
|
text [selector] Element text; no selector = WHOLE PAGE, all frames
|
||||||
text --main Main-content text only (skip nav/header/sidebar)
|
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
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ For unstructured reading (no refs needed):
|
|||||||
chrome-use get text # WHOLE PAGE — all frames by default (see below)
|
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 @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 --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 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
|
||||||
@@ -234,6 +235,12 @@ holds what), run `chrome-use frames`. To **cut boilerplate** (global nav/header/
|
|||||||
footer, "related items" sidebars), use `chrome-use get text --main`. If content
|
footer, "related items" sidebars), use `chrome-use get text --main`. If content
|
||||||
is lazy-loaded, `scroll` it into view first, then read.
|
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