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",