fix(browser): read content inside shadow DOM
Teach the browser page-content helper to traverse open shadow roots and assigned slot nodes when collecting text, rendering list/inline children, and resolving selectors. This lets Agent Zero inspect modern component-heavy pages more accurately without depending only on light-DOM textContent. Bump the injected helper version so existing browser contexts can refresh to the new DOM traversal behavior.
Alessandro committed
May 2, 2026 at 12:23 UTC
eb5220b0581443af4dd4804fbf3ce7e65115f698
1 file changed
+86
-7
plugins/_browser/assets/browser-page-content.js
+86
-7
@@ -1,7 +1,7 @@
1
(() => {
2
const GLOBAL_KEY = "__spaceBrowserPageContent__";
3
const DOM_HELPER_KEY = "__spaceBrowserDomHelper__";
4
- const VERSION = "7";
4
+ const VERSION = "8";
5
const BLOCK_TAGS = new Set([
6
"ADDRESS",
7
"ARTICLE",
@@ -518,6 +518,82 @@
518
return String(element?.tagName || "").toUpperCase();
519
}
520
521
+ function expandSlotNodes(node) {
522
+ if (!isElementNode(node) || getTagName(node) !== "SLOT" || typeof node.assignedNodes !== "function") {
523
+ return [node];
524
+ }
525
+
526
+ try {
527
+ const assignedNodes = [...(node.assignedNodes({ flatten: true }) || [])].filter(Boolean);
528
+ if (assignedNodes.length) {
529
+ return assignedNodes.flatMap((assignedNode) => expandSlotNodes(assignedNode));
530
+ }
531
+ } catch {
532
+ // Fall through to the slot's fallback children.
533
+ }
534
+
535
+ return [...(node.childNodes || [])].flatMap((childNode) => expandSlotNodes(childNode));
536
+ }
537
+
538
+ function getReadableChildNodes(element) {
539
+ const shadowRoot = element?.shadowRoot;
540
+ if (shadowRoot) {
541
+ const shadowNodes = [...(shadowRoot.childNodes || [])].flatMap((childNode) => expandSlotNodes(childNode));
542
+ if (shadowNodes.length) {
543
+ return shadowNodes;
544
+ }
545
+ }
546
+
547
+ return [...(element?.childNodes || [])].flatMap((childNode) => expandSlotNodes(childNode));
548
+ }
549
+
550
+ function getReadableElementChildren(element) {
551
+ return getReadableChildNodes(element).filter((childNode) => isElementNode(childNode));
552
+ }
553
+
554
+ function getReadableNodeText(node) {
555
+ if (isTextNode(node)) {
556
+ return node.textContent || "";
557
+ }
558
+
559
+ if (!isElementNode(node) || isHiddenElement(node)) {
560
+ return "";
561
+ }
562
+
563
+ return getReadableChildNodes(node)
564
+ .map((childNode) => getReadableNodeText(childNode))
565
+ .filter(Boolean)
566
+ .join(" ");
567
+ }
568
+
569
+ function querySelectorAllDeep(selector, root = globalThis.document) {
570
+ const results = [];
571
+ const seen = new Set();
572
+
573
+ const addResult = (element) => {
574
+ if (element && !seen.has(element)) {
575
+ seen.add(element);
576
+ results.push(element);
577
+ }
578
+ };
579
+
580
+ const visitRoot = (scope) => {
581
+ if (!scope || typeof scope.querySelectorAll !== "function") {
582
+ return;
583
+ }
584
+
585
+ [...(scope.querySelectorAll(selector) || [])].forEach(addResult);
586
+ [...(scope.querySelectorAll("*") || [])].forEach((element) => {
587
+ if (element.shadowRoot) {
588
+ visitRoot(element.shadowRoot);
589
+ }
590
+ });
591
+ };
592
+
593
+ visitRoot(root);
594
+ return results;
595
+ }
596
+
597
function getAttributeNamesSafe(element) {
598
try {
599
if (typeof element?.getAttributeNames === "function") {
@@ -968,7 +1044,8 @@
1044
}
1045
1046
function getElementText(element) {
971
- return normalizeText(element?.textContent || "");
1047
+ const readableText = normalizeText(getReadableNodeText(element));
1048
+ return readableText || normalizeText(element?.textContent || "");
1049
}
1050
1051
function collectLabelCandidates(element, options = {}) {
@@ -1473,7 +1550,7 @@
1550
function renderInlineChildren(element, context) {
1551
const parts = [];
1552
1476
- element.childNodes.forEach((childNode) => {
1553
+ getReadableChildNodes(element).forEach((childNode) => {
1554
const renderedChild = renderInlineNode(childNode, context);
1555
if (renderedChild) {
1556
parts.push(renderedChild);
@@ -1522,7 +1599,7 @@
1599
const inlineParts = [];
1600
const nestedBlocks = [];
1601
1525
- element.childNodes.forEach((childNode) => {
1602
+ getReadableChildNodes(element).forEach((childNode) => {
1603
if (isElementNode(childNode) && (getTagName(childNode) === "UL" || getTagName(childNode) === "OL")) {
1604
const nestedList = renderList(childNode, context, depth + 1);
1605
if (nestedList) {
@@ -1548,7 +1625,7 @@
1625
1626
function renderList(element, context, depth = 0) {
1627
const ordered = getTagName(element) === "OL";
1551
- return [...element.children]
1628
+ return getReadableElementChildren(element)
1629
.filter((child) => getTagName(child) === "LI" && !isHiddenElement(child))
1630
.map((item, index) => renderListItem(item, context, depth, index, ordered))
1631
.filter(Boolean)
@@ -1661,7 +1738,7 @@
1738
}
1739
};
1740
1664
- element.childNodes.forEach((childNode) => {
1741
+ getReadableChildNodes(element).forEach((childNode) => {
1742
if (isTextNode(childNode)) {
1743
const rawTextContent = normalizeText(childNode.textContent || "");
1744
if (shouldDropReadableText(rawTextContent)) {
@@ -1732,7 +1809,9 @@
1809
items: selectors.map((selector) => {
1810
let targets = [];
1811
try {
1735
- targets = [...(doc?.querySelectorAll?.(selector) || [])];
1812
+ targets = doc === globalThis.document
1813
+ ? querySelectorAllDeep(selector, doc)
1814
+ : [...(doc?.querySelectorAll?.(selector) || [])];
1815
} catch (error) {
1816
throw createNamedError(
1817
"BrowserPageContentSelectorError",