hangwin--mcp-chrome
1856 行
68 KiB
JavaScript
1856 行
68 KiB
JavaScript
/* eslint-disable */
|
|
// accessibility-tree-helper.js
|
|
// Injected script to generate an accessibility-like tree of the visible page
|
|
// Elements receive stable refs (ref_*) via WeakRef mapping for later reference.
|
|
|
|
(function () {
|
|
if (window.__ACCESSIBILITY_TREE_HELPER_INITIALIZED__) return;
|
|
window.__ACCESSIBILITY_TREE_HELPER_INITIALIZED__ = true;
|
|
|
|
// Traversal and output limits to ensure stability on very large/complex pages
|
|
const MAX_DEPTH = 30; // maximum DOM depth to traverse
|
|
const MAX_NODES = 4000; // hard limit to avoid long blocking on huge DOMs
|
|
const MAX_LINE_LABEL = 100; // max characters for a single label in output
|
|
const REF_MAP_LIMIT = 1000; // limit size of the ref map to keep payload small
|
|
|
|
// Keep a weak map from ref id to elements
|
|
if (!window.__claudeElementMap) window.__claudeElementMap = {};
|
|
if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;
|
|
|
|
/**
|
|
* Infer ARIA-like role from element
|
|
* @param {Element} el
|
|
* @returns {string}
|
|
*/
|
|
function inferRole(el) {
|
|
const role = el.getAttribute('role');
|
|
if (role) return role;
|
|
const tag = el.tagName.toLowerCase();
|
|
const type = el.getAttribute('type') || '';
|
|
const map = {
|
|
a: 'link',
|
|
button: 'button',
|
|
input:
|
|
type === 'submit' || type === 'button'
|
|
? 'button'
|
|
: type === 'checkbox'
|
|
? 'checkbox'
|
|
: type === 'radio'
|
|
? 'radio'
|
|
: type === 'file'
|
|
? 'button'
|
|
: 'textbox',
|
|
select: 'combobox',
|
|
textarea: 'textbox',
|
|
h1: 'heading',
|
|
h2: 'heading',
|
|
h3: 'heading',
|
|
h4: 'heading',
|
|
h5: 'heading',
|
|
h6: 'heading',
|
|
img: 'image',
|
|
nav: 'navigation',
|
|
main: 'main',
|
|
header: 'banner',
|
|
footer: 'contentinfo',
|
|
section: 'region',
|
|
article: 'article',
|
|
aside: 'complementary',
|
|
form: 'form',
|
|
table: 'table',
|
|
ul: 'list',
|
|
ol: 'list',
|
|
li: 'listitem',
|
|
label: 'label',
|
|
};
|
|
return map[tag] || 'generic';
|
|
}
|
|
|
|
/**
|
|
* Derive readable label for element
|
|
* @param {Element} el
|
|
* @returns {string}
|
|
*/
|
|
function inferLabel(el) {
|
|
const tag = el.tagName.toLowerCase();
|
|
if (tag === 'select') {
|
|
const sel = /** @type {HTMLSelectElement} */ (el);
|
|
const opt = sel.querySelector('option[selected]') || sel.options[sel.selectedIndex];
|
|
if (opt && opt.textContent) return opt.textContent.trim();
|
|
}
|
|
const aria = el.getAttribute('aria-label');
|
|
if (aria && aria.trim()) return aria.trim();
|
|
const placeholder = el.getAttribute('placeholder');
|
|
if (placeholder && placeholder.trim()) return placeholder.trim();
|
|
const title = el.getAttribute('title');
|
|
if (title && title.trim()) return title.trim();
|
|
const alt = el.getAttribute('alt');
|
|
if (alt && alt.trim()) return alt.trim();
|
|
if (/** @type {HTMLElement} */ (el).id) {
|
|
const lab = document.querySelector(`label[for="${/** @type {HTMLElement} */ (el).id}"]`);
|
|
if (lab && lab.textContent && lab.textContent.trim()) return lab.textContent.trim();
|
|
}
|
|
if (tag === 'input') {
|
|
const input = /** @type {HTMLInputElement} */ (el);
|
|
const type = input.getAttribute('type') || '';
|
|
const val = input.getAttribute('value');
|
|
if (type === 'submit' && val && val.trim()) return val.trim();
|
|
if (input.value && input.value.length < 50 && input.value.trim()) return input.value.trim();
|
|
}
|
|
if (['button', 'a', 'summary'].includes(tag)) {
|
|
let text = '';
|
|
for (let i = 0; i < el.childNodes.length; i++) {
|
|
const n = el.childNodes[i];
|
|
if (n.nodeType === Node.TEXT_NODE) text += n.textContent || '';
|
|
}
|
|
if (text.trim()) return text.trim();
|
|
}
|
|
if (/^h[1-6]$/.test(tag)) {
|
|
const t = el.textContent;
|
|
if (t && t.trim()) return t.trim().substring(0, MAX_LINE_LABEL);
|
|
}
|
|
if (tag === 'img') {
|
|
const src = el.getAttribute('src');
|
|
if (src) {
|
|
const file = src.split('/').pop()?.split('?')[0];
|
|
return `Image: ${file}`;
|
|
}
|
|
}
|
|
let agg = '';
|
|
for (let i = 0; i < el.childNodes.length; i++) {
|
|
const n = el.childNodes[i];
|
|
if (n.nodeType === Node.TEXT_NODE) agg += n.textContent || '';
|
|
}
|
|
if (agg && agg.trim() && agg.trim().length >= 3) {
|
|
const v = agg.trim();
|
|
return v.length > 50 ? v.substring(0, 50) + '...' : v;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Check if element is visible in DOM
|
|
* @param {Element} el
|
|
*/
|
|
function isVisible(el) {
|
|
const cs = window.getComputedStyle(/** @type {HTMLElement} */ (el));
|
|
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
const he = /** @type {HTMLElement} */ (el);
|
|
return he.offsetWidth > 0 && he.offsetHeight > 0;
|
|
}
|
|
|
|
/**
|
|
* Whether the element is interactive
|
|
* @param {Element} el
|
|
*/
|
|
function isInteractive(el) {
|
|
// Native interactive tags
|
|
const tag = el.tagName.toLowerCase();
|
|
if (['a', 'button', 'input', 'select', 'textarea', 'details', 'summary'].includes(tag))
|
|
return true;
|
|
|
|
// Generic interactive hints
|
|
if (el.getAttribute('onclick') != null) return true;
|
|
if (
|
|
el.getAttribute('tabindex') != null &&
|
|
String(el.getAttribute('tabindex')).trim() !== '' &&
|
|
!String(el.getAttribute('tabindex')).trim().startsWith('-')
|
|
)
|
|
return true;
|
|
if (el.getAttribute('contenteditable') === 'true') return true;
|
|
|
|
// ARIA roles commonly used by custom elements
|
|
const role = (el.getAttribute && el.getAttribute('role')) || '';
|
|
const interactiveRoles = new Set([
|
|
'button',
|
|
'link',
|
|
'checkbox',
|
|
'radio',
|
|
'switch',
|
|
'slider',
|
|
'option',
|
|
'menuitem',
|
|
'textbox',
|
|
'searchbox',
|
|
'combobox',
|
|
'spinbutton',
|
|
'tab',
|
|
'treeitem',
|
|
]);
|
|
if (role && interactiveRoles.has(role.toLowerCase())) return true;
|
|
|
|
// Shadow host case: treat host as interactive if its open shadow root contains
|
|
// an interactive control (textarea/input/select/button/a or contenteditable).
|
|
try {
|
|
const anyEl = /** @type {any} */ (el);
|
|
const sr = anyEl && anyEl.shadowRoot ? anyEl.shadowRoot : null;
|
|
if (sr) {
|
|
const inner = sr.querySelector(
|
|
'input, textarea, select, button, a[href], [contenteditable="true"], [role="button"], [role="link"], [role="textbox"], [role="combobox"], [role="searchbox"], [role="menuitem"], [role="option"], [role="switch"], [role="radio"], [role="checkbox"], [role="tab"], [role="slider"]',
|
|
);
|
|
if (inner) return true;
|
|
}
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Structural containers useful to include
|
|
* @param {Element} el
|
|
*/
|
|
function isStructural(el) {
|
|
const tag = el.tagName.toLowerCase();
|
|
if (
|
|
[
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
'nav',
|
|
'main',
|
|
'header',
|
|
'footer',
|
|
'section',
|
|
'article',
|
|
'aside',
|
|
].includes(tag)
|
|
)
|
|
return true;
|
|
return el.getAttribute('role') != null;
|
|
}
|
|
|
|
/**
|
|
* Form-ish containers to keep
|
|
* @param {Element} el
|
|
*/
|
|
function isFormishContainer(el) {
|
|
const tag = el.tagName.toLowerCase();
|
|
const role = (el.getAttribute && el.getAttribute('role')) || '';
|
|
const id = /** @type {HTMLElement} */ (el).id || '';
|
|
// Normalize className for HTML/SVG elements
|
|
let cls = '';
|
|
try {
|
|
const attr = el.getAttribute && el.getAttribute('class');
|
|
if (typeof attr === 'string') cls = attr;
|
|
else {
|
|
const cn = /** @type {any} */ (el).className;
|
|
if (typeof cn === 'string') cls = cn;
|
|
else if (cn && typeof cn.baseVal === 'string') cls = cn.baseVal;
|
|
}
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
return (
|
|
role === 'search' ||
|
|
role === 'form' ||
|
|
role === 'group' ||
|
|
role === 'toolbar' ||
|
|
role === 'navigation' ||
|
|
tag === 'form' ||
|
|
tag === 'fieldset' ||
|
|
tag === 'nav' ||
|
|
tag === 'legend' ||
|
|
id.includes('search') ||
|
|
cls.includes('search') ||
|
|
id.includes('form') ||
|
|
cls.includes('form') ||
|
|
id.includes('menu') ||
|
|
cls.includes('menu') ||
|
|
id.includes('nav') ||
|
|
cls.includes('nav')
|
|
);
|
|
}
|
|
|
|
// Utility: query CSS across open shadow roots (best-effort)
|
|
function querySelectorDeepFirst(selector) {
|
|
try {
|
|
// Fast path
|
|
const direct = document.querySelector(selector);
|
|
if (direct) return direct;
|
|
} catch (_) {}
|
|
const visited = new Set();
|
|
const stack = [document.documentElement];
|
|
while (stack.length) {
|
|
const node = stack.pop();
|
|
if (!node || visited.has(node)) continue;
|
|
visited.add(node);
|
|
try {
|
|
const root = /** @type {any} */ (node).shadowRoot || (node.nodeType === 9 ? node : null);
|
|
if (root) {
|
|
try {
|
|
const hit = root.querySelector(selector);
|
|
if (hit) return hit;
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
// Traverse DOM and shadow roots
|
|
try {
|
|
const children = /** @type {Element} */ (node).children || [];
|
|
for (let i = 0; i < children.length; i++) stack.push(children[i]);
|
|
const sr = /** @type {any} */ (node).shadowRoot;
|
|
if (sr && sr.children) {
|
|
for (let i = 0; i < sr.children.length; i++) stack.push(sr.children[i]);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Query CSS selector and return match info including uniqueness check.
|
|
* @param {string} selector - CSS selector to query
|
|
* @param {boolean} allowMultiple - If true, skip uniqueness check and return first match
|
|
* @returns {{element: Element | null, matchCount: number, error?: string}}
|
|
* Note: matchCount is capped at 2 (where 2 means "2 or more") for performance
|
|
*/
|
|
function querySelectorWithUniquenessCheck(selector, allowMultiple = false) {
|
|
const seen = new Set();
|
|
let firstMatch = null;
|
|
let matchCount = 0;
|
|
|
|
const recordMatch = (el) => {
|
|
if (!(el instanceof Element) || seen.has(el)) return false;
|
|
seen.add(el);
|
|
matchCount++;
|
|
if (!firstMatch) firstMatch = el;
|
|
// Short-circuit if:
|
|
// - allowMultiple is true and we found first match (no need to continue)
|
|
// - allowMultiple is false and we found multiple matches
|
|
if (allowMultiple && firstMatch) return true;
|
|
if (!allowMultiple && matchCount >= 2) return true;
|
|
return false;
|
|
};
|
|
|
|
// Query in main document
|
|
let selectorError = null;
|
|
try {
|
|
const directMatches = document.querySelectorAll(selector);
|
|
for (let i = 0; i < directMatches.length; i++) {
|
|
if (recordMatch(directMatches[i])) {
|
|
// Early exit: either found first match (allowMultiple) or found multiple (not allowed)
|
|
return { element: firstMatch, matchCount: allowMultiple ? 1 : 2 };
|
|
}
|
|
}
|
|
} catch (e) {
|
|
selectorError = e;
|
|
}
|
|
|
|
if (selectorError) {
|
|
return {
|
|
element: null,
|
|
matchCount: 0,
|
|
error: `Invalid CSS selector "${selector}": ${selectorError.message || selectorError}`,
|
|
};
|
|
}
|
|
|
|
// If allowMultiple and we already have a match, return immediately
|
|
if (allowMultiple && firstMatch) {
|
|
return { element: firstMatch, matchCount: 1 };
|
|
}
|
|
|
|
// Query in shadow DOMs
|
|
const visited = new Set();
|
|
const stack = [document.documentElement];
|
|
while (stack.length) {
|
|
const node = stack.pop();
|
|
if (!node || visited.has(node)) continue;
|
|
visited.add(node);
|
|
|
|
try {
|
|
const shadowRoot = /** @type {any} */ (node).shadowRoot;
|
|
if (shadowRoot) {
|
|
try {
|
|
const shadowMatches = shadowRoot.querySelectorAll(selector);
|
|
for (let i = 0; i < shadowMatches.length; i++) {
|
|
if (recordMatch(shadowMatches[i])) {
|
|
// Early exit: either found first match (allowMultiple) or found multiple (not allowed)
|
|
return { element: firstMatch, matchCount: allowMultiple ? 1 : 2 };
|
|
}
|
|
}
|
|
} catch (e) {
|
|
return {
|
|
element: null,
|
|
matchCount: 0,
|
|
error: `Invalid CSS selector "${selector}": ${e.message || e}`,
|
|
};
|
|
}
|
|
|
|
// Add shadow root children to stack
|
|
try {
|
|
const shadowChildren = shadowRoot.children || [];
|
|
for (let i = 0; i < shadowChildren.length; i++) {
|
|
stack.push(shadowChildren[i]);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
} catch (_) {}
|
|
|
|
// Add regular children to stack
|
|
try {
|
|
const children = /** @type {Element} */ (node).children || [];
|
|
for (let i = 0; i < children.length; i++) {
|
|
stack.push(children[i]);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
return { element: firstMatch, matchCount: Math.min(matchCount, 2) };
|
|
}
|
|
|
|
/**
|
|
* Query XPath selector and return match info including uniqueness check.
|
|
* @param {string} selector - XPath selector to query
|
|
* @param {boolean} allowMultiple - If true, skip uniqueness check and return first match
|
|
* @returns {{element: Element | null, matchCount: number, error?: string}}
|
|
* Note: matchCount is capped at 2 (where 2 means "2 or more") for performance
|
|
*/
|
|
function queryXPathWithUniquenessCheck(selector, allowMultiple = false) {
|
|
if (!selector) {
|
|
return { element: null, matchCount: 0 };
|
|
}
|
|
|
|
try {
|
|
if (allowMultiple) {
|
|
// When multiple matches are allowed, use ANY_UNORDERED_NODE_TYPE for performance
|
|
// This returns just the first match without evaluating the entire result set
|
|
const result = document.evaluate(
|
|
selector,
|
|
document,
|
|
null,
|
|
XPathResult.ANY_UNORDERED_NODE_TYPE,
|
|
null,
|
|
);
|
|
const firstMatch =
|
|
result.singleNodeValue instanceof Element
|
|
? /** @type {Element} */ (result.singleNodeValue)
|
|
: null;
|
|
return { element: firstMatch, matchCount: firstMatch ? 1 : 0 };
|
|
} else {
|
|
// When uniqueness is required, use ORDERED_NODE_SNAPSHOT_TYPE to count matches
|
|
const snapshot = document.evaluate(
|
|
selector,
|
|
document,
|
|
null,
|
|
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
|
|
null,
|
|
);
|
|
const totalMatches = snapshot.snapshotLength;
|
|
// Cap at 2 for performance (2 means "2 or more")
|
|
const matchCount = Math.min(totalMatches, 2);
|
|
const firstMatch =
|
|
totalMatches > 0 && snapshot.snapshotItem(0) instanceof Element
|
|
? /** @type {Element} */ (snapshot.snapshotItem(0))
|
|
: null;
|
|
return { element: firstMatch, matchCount };
|
|
}
|
|
} catch (e) {
|
|
return {
|
|
element: null,
|
|
matchCount: 0,
|
|
error: `Invalid XPath "${selector}": ${e.message || e}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether to include element in tree under config
|
|
* @param {Element} el
|
|
* @param {{filter?: 'all'|'interactive'}} cfg
|
|
*/
|
|
function shouldInclude(el, cfg) {
|
|
const tag = el.tagName.toLowerCase();
|
|
if (['script', 'style', 'meta', 'link', 'title', 'noscript'].includes(tag)) return false;
|
|
if (el.getAttribute('aria-hidden') === 'true') return false;
|
|
if (!isVisible(el)) return false;
|
|
if (cfg.filter !== 'all') {
|
|
const r = /** @type {HTMLElement} */ (el).getBoundingClientRect();
|
|
if (
|
|
!(r.top < window.innerHeight && r.bottom > 0 && r.left < window.innerWidth && r.right > 0)
|
|
)
|
|
return false;
|
|
}
|
|
if (cfg.filter === 'interactive') return isInteractive(el);
|
|
if (isInteractive(el)) return true;
|
|
if (isStructural(el)) return true;
|
|
if (inferLabel(el).length > 0) return true;
|
|
return isFormishContainer(el);
|
|
}
|
|
|
|
/**
|
|
* Generate a fairly stable CSS selector
|
|
* @param {Element} el
|
|
* @returns {string}
|
|
*/
|
|
function generateSelector(el) {
|
|
if (!(el instanceof Element)) return '';
|
|
if (/** @type {HTMLElement} */ (el).id) {
|
|
const idSel = `#${CSS.escape(/** @type {HTMLElement} */ (el).id)}`;
|
|
if (document.querySelectorAll(idSel).length === 1) return idSel;
|
|
}
|
|
for (const attr of ['data-testid', 'data-cy', 'name']) {
|
|
const attrValue = el.getAttribute(attr);
|
|
if (attrValue) {
|
|
const s = `[${attr}="${CSS.escape(attrValue)}"]`;
|
|
if (document.querySelectorAll(s).length === 1) return s;
|
|
}
|
|
}
|
|
let path = '';
|
|
let current = el;
|
|
while (current && current.nodeType === Node.ELEMENT_NODE && current.tagName !== 'BODY') {
|
|
let selector = current.tagName.toLowerCase();
|
|
const parent = current.parentElement;
|
|
if (parent) {
|
|
const siblings = Array.from(parent.children).filter(
|
|
(child) => child.tagName === current.tagName,
|
|
);
|
|
if (siblings.length > 1) {
|
|
const index = siblings.indexOf(current) + 1;
|
|
selector += `:nth-of-type(${index})`;
|
|
}
|
|
}
|
|
path = path ? `${selector} > ${path}` : selector;
|
|
current = parent;
|
|
}
|
|
return path ? `body > ${path}` : 'body';
|
|
}
|
|
|
|
/**
|
|
* Traverse DOM and build pageContent lines; collect ref map for interactive nodes.
|
|
* @param {Element} el
|
|
* @param {number} depth
|
|
* @param {{filter?: 'all'|'interactive', maxDepth?: number}} cfg
|
|
* @param {string[]} out
|
|
* @param {Array<{ref:string, selector:string, rect:{x:number,y:number,width:number,height:number}}>} refMap
|
|
*/
|
|
function traverse(el, depth, cfg, out, refMap, state) {
|
|
const maxDepth = cfg && typeof cfg.maxDepth === 'number' ? cfg.maxDepth : MAX_DEPTH;
|
|
if (depth > maxDepth || !el || !el.tagName) return;
|
|
if (state.processed >= MAX_NODES) return;
|
|
if (state.visited.has(el)) return;
|
|
state.visited.add(el);
|
|
const include = shouldInclude(el, cfg) || depth === 0;
|
|
if (include) {
|
|
const role = inferRole(el);
|
|
let label = inferLabel(el);
|
|
let refId = null;
|
|
for (const k in window.__claudeElementMap) {
|
|
if (window.__claudeElementMap[k].deref && window.__claudeElementMap[k].deref() === el) {
|
|
refId = k;
|
|
break;
|
|
}
|
|
}
|
|
if (!refId) {
|
|
refId = `ref_${++window.__claudeRefCounter}`;
|
|
window.__claudeElementMap[refId] = new WeakRef(el);
|
|
}
|
|
const rect = /** @type {HTMLElement} */ (el).getBoundingClientRect();
|
|
const cx = Math.round(rect.left + rect.width / 2);
|
|
const cy = Math.round(rect.top + rect.height / 2);
|
|
let line = `${' '.repeat(depth)}- ${role}`;
|
|
if (label) {
|
|
label = label.replace(/\s+/g, ' ').substring(0, MAX_LINE_LABEL);
|
|
line += ` "${label.replace(/"/g, '\\"')}"`;
|
|
}
|
|
line += ` [ref=${refId}] (x=${cx},y=${cy})`;
|
|
if (/** @type {HTMLElement} */ (el).id) line += ` id="${/** @type {HTMLElement} */ (el).id}"`;
|
|
const href = el.getAttribute('href');
|
|
if (href) line += ` href="${href}"`;
|
|
const type = el.getAttribute('type');
|
|
if (type) line += ` type="${type}"`;
|
|
const placeholder = el.getAttribute('placeholder');
|
|
if (placeholder) line += ` placeholder="${placeholder}"`;
|
|
// Surface disabled/pointer-events for better agent judgement
|
|
try {
|
|
const disabled = el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true';
|
|
if (disabled) line += ` disabled`;
|
|
const cs = window.getComputedStyle(/** @type {HTMLElement} */ (el));
|
|
if (cs && cs.pointerEvents === 'none') line += ` pe=none`;
|
|
} catch (_) {
|
|
/* ignore style issues */
|
|
}
|
|
out.push(line);
|
|
state.included++;
|
|
state.processed++;
|
|
|
|
// Only collect ref mapping for interactive elements to limit cost
|
|
if (isInteractive(el) && refMap.length < REF_MAP_LIMIT) {
|
|
refMap.push({
|
|
ref: /** @type {string} */ (refId),
|
|
selector: generateSelector(el),
|
|
rect: {
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
if (state.processed >= MAX_NODES) return;
|
|
// Traverse light DOM children
|
|
if (/** @type {HTMLElement} */ (el).children && depth < maxDepth) {
|
|
const children = /** @type {HTMLElement} */ (el).children;
|
|
for (let i = 0; i < children.length; i++) {
|
|
if (state.processed >= MAX_NODES) break;
|
|
traverse(children[i], include ? depth + 1 : depth, cfg, out, refMap, state);
|
|
}
|
|
}
|
|
// Traverse shadow DOM roots (limited by maxDepth and MAX_NODES)
|
|
try {
|
|
const anyEl = /** @type {any} */ (el);
|
|
if (anyEl && anyEl.shadowRoot && depth < maxDepth) {
|
|
const srChildren = anyEl.shadowRoot.children || [];
|
|
for (let i = 0; i < srChildren.length; i++) {
|
|
if (state.processed >= MAX_NODES) break;
|
|
traverse(srChildren[i], include ? depth + 1 : depth, cfg, out, refMap, state);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
/* ignore shadow errors */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate tree and return
|
|
* @param {'all'|'interactive'|null} filter
|
|
* @param {{maxDepth?: number, refId?: string}|undefined} options
|
|
*/
|
|
function __generateAccessibilityTree(filter, options) {
|
|
try {
|
|
const start = performance && performance.now ? performance.now() : Date.now();
|
|
const out = [];
|
|
const cfg = { filter: filter || undefined };
|
|
|
|
// Clamp maxDepth to MAX_DEPTH to keep costs bounded
|
|
if (options && Number.isFinite(options.maxDepth)) {
|
|
const d = Math.max(0, Math.floor(Number(options.maxDepth)));
|
|
cfg.maxDepth = Math.min(d, MAX_DEPTH);
|
|
}
|
|
|
|
const refMap = [];
|
|
const state = { processed: 0, included: 0, visited: new WeakSet() };
|
|
|
|
// Determine root element (body or refId-specified element)
|
|
let focus = null;
|
|
let root = document.body;
|
|
if (options && options.refId) {
|
|
const refIdStr = String(options.refId || '').trim();
|
|
if (refIdStr) {
|
|
const el = resolveRef(refIdStr);
|
|
if (!el || !(el instanceof Element)) {
|
|
return { error: `ref "${refIdStr}" not found or expired` };
|
|
}
|
|
root = el;
|
|
focus = { refId: refIdStr };
|
|
}
|
|
}
|
|
|
|
if (root) traverse(root, 0, cfg, out, refMap, state);
|
|
for (const k in window.__claudeElementMap) {
|
|
if (!window.__claudeElementMap[k].deref || !window.__claudeElementMap[k].deref())
|
|
delete window.__claudeElementMap[k];
|
|
}
|
|
const pageContent = out
|
|
.filter((line) => !/^\s*- generic \[ref=ref_\d+\]$/.test(line))
|
|
.join('\n');
|
|
const end = performance && performance.now ? performance.now() : Date.now();
|
|
return {
|
|
pageContent,
|
|
focus,
|
|
viewport: {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
dpr: window.devicePixelRatio || 1,
|
|
},
|
|
stats: {
|
|
processed: state.processed,
|
|
included: state.included,
|
|
durationMs: Math.round(end - start),
|
|
},
|
|
refMap,
|
|
};
|
|
} catch (err) {
|
|
throw new Error(
|
|
'Error generating accessibility tree: ' +
|
|
(err && err.message ? err.message : 'Unknown error'),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Expose API on window
|
|
window.__generateAccessibilityTree = __generateAccessibilityTree;
|
|
|
|
// ============================================================================
|
|
// Hover for Ref (DOM Fallback Support)
|
|
// ============================================================================
|
|
|
|
async function handleHoverForRef(ref) {
|
|
if (!ref) return { success: false, error: 'ref is required' };
|
|
const el = resolveRef(ref);
|
|
if (el) {
|
|
dispatchHoverEvents(el);
|
|
return { success: true, target: summarizeElement(el) };
|
|
}
|
|
return await forwardHoverRefToChildren(ref);
|
|
}
|
|
|
|
function resolveRef(ref) {
|
|
const map = window.__claudeElementMap || {};
|
|
const weak = map[ref];
|
|
return weak && typeof weak.deref === 'function' ? weak.deref() : null;
|
|
}
|
|
|
|
function dispatchHoverEvents(el) {
|
|
const rect = el.getBoundingClientRect();
|
|
const center = {
|
|
x: Math.round(rect.left + rect.width / 2),
|
|
y: Math.round(rect.top + rect.height / 2),
|
|
};
|
|
['mousemove', 'mouseover', 'mouseenter'].forEach((type) => {
|
|
el.dispatchEvent(
|
|
new MouseEvent(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
clientX: center.x,
|
|
clientY: center.y,
|
|
view: window,
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
function summarizeElement(el) {
|
|
return {
|
|
tagName: el.tagName,
|
|
id: el.id || '',
|
|
className: el.className || '',
|
|
text: (el.textContent || '').trim().slice(0, 100),
|
|
};
|
|
}
|
|
|
|
function forwardHoverRefToChildren(ref) {
|
|
return new Promise((resolve) => {
|
|
const frames = Array.from(document.querySelectorAll('iframe, frame'));
|
|
if (!frames.length) {
|
|
resolve({ success: false, error: `ref "${ref}" not found` });
|
|
return;
|
|
}
|
|
const reqId = `hover_ref_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const listener = (ev) => {
|
|
const data = ev?.data;
|
|
if (!data || data.type !== 'rr-bridge-hover-ref-result' || data.reqId !== reqId) return;
|
|
window.removeEventListener('message', listener, true);
|
|
resolve(data.result);
|
|
};
|
|
window.addEventListener('message', listener, true);
|
|
setTimeout(() => {
|
|
window.removeEventListener('message', listener, true);
|
|
resolve({ success: false, error: `ref "${ref}" not found in child frames` });
|
|
}, 1500);
|
|
for (const frame of frames) {
|
|
try {
|
|
frame.contentWindow?.postMessage({ type: 'rr-bridge-hover-ref', reqId, ref }, '*');
|
|
} catch {}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Chrome message bridge for ping and tree generation
|
|
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
|
try {
|
|
if (request && request.action === 'chrome_read_page_ping') {
|
|
sendResponse({ status: 'pong' });
|
|
return false;
|
|
}
|
|
if (request && request.action === 'rr_overlay') {
|
|
try {
|
|
const cmd = request.cmd || 'init';
|
|
let root = document.getElementById('__rr_overlay_root');
|
|
if (!root) {
|
|
root = document.createElement('div');
|
|
root.id = '__rr_overlay_root';
|
|
Object.assign(root.style, {
|
|
position: 'fixed',
|
|
right: '8px',
|
|
bottom: '8px',
|
|
zIndex: 2_147_483_647,
|
|
maxWidth: '40vw',
|
|
maxHeight: '40vh',
|
|
overflow: 'auto',
|
|
background: 'rgba(0,0,0,0.6)',
|
|
color: '#fff',
|
|
fontFamily:
|
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
|
fontSize: '12px',
|
|
padding: '8px',
|
|
borderRadius: '6px',
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
|
});
|
|
const title = document.createElement('div');
|
|
title.textContent = 'Record-Replay 运行日志';
|
|
Object.assign(title.style, { fontWeight: 'bold', marginBottom: '6px' });
|
|
const body = document.createElement('div');
|
|
body.id = '__rr_overlay_body';
|
|
root.appendChild(title);
|
|
root.appendChild(body);
|
|
document.documentElement.appendChild(root);
|
|
}
|
|
const body = document.getElementById('__rr_overlay_body');
|
|
if (cmd === 'append' && body) {
|
|
const line = document.createElement('div');
|
|
line.textContent = String(request.text || '');
|
|
body.appendChild(line);
|
|
body.scrollTop = body.scrollHeight;
|
|
}
|
|
if (cmd === 'done' && root) {
|
|
root.style.opacity = '0.5';
|
|
}
|
|
sendResponse({ success: true });
|
|
return true;
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
|
return true;
|
|
}
|
|
}
|
|
// Element picker: start a temporary overlay to let user pick an element
|
|
if (request && request.action === 'rr_picker_start') {
|
|
try {
|
|
// state
|
|
const state = { active: true };
|
|
const hostId = '__rr_picker_host__';
|
|
let host = document.getElementById(hostId);
|
|
if (host) host.remove();
|
|
host = document.createElement('div');
|
|
host.id = hostId;
|
|
Object.assign(host.style, {
|
|
position: 'fixed',
|
|
inset: '0',
|
|
zIndex: 2147483646,
|
|
cursor: 'crosshair',
|
|
background: 'rgba(0,0,0,0.0)',
|
|
});
|
|
const box = document.createElement('div');
|
|
Object.assign(box.style, {
|
|
position: 'fixed',
|
|
border: '2px solid #3b82f6',
|
|
background: 'rgba(59,130,246,0.15)',
|
|
pointerEvents: 'none',
|
|
});
|
|
const tip = document.createElement('div');
|
|
tip.textContent = '点击选取元素(Esc 取消)';
|
|
Object.assign(tip.style, {
|
|
position: 'fixed',
|
|
top: '10px',
|
|
left: '10px',
|
|
background: 'rgba(0,0,0,0.7)',
|
|
color: '#fff',
|
|
padding: '6px 10px',
|
|
borderRadius: '6px',
|
|
fontSize: '12px',
|
|
fontFamily: 'system-ui,-apple-system,Segoe UI,Roboto,Arial',
|
|
});
|
|
host.appendChild(box);
|
|
host.appendChild(tip);
|
|
document.documentElement.appendChild(host);
|
|
|
|
const cleanup = () => {
|
|
try {
|
|
host.remove();
|
|
} catch {}
|
|
try {
|
|
document.removeEventListener('mousemove', onMove, true);
|
|
} catch {}
|
|
try {
|
|
document.removeEventListener('click', onClick, true);
|
|
} catch {}
|
|
try {
|
|
document.removeEventListener('keydown', onKey, true);
|
|
} catch {}
|
|
state.active = false;
|
|
};
|
|
|
|
const onMove = (e) => {
|
|
if (!state.active) return;
|
|
const el = e.target instanceof Element ? e.target : null;
|
|
if (!el) return;
|
|
try {
|
|
const r = el.getBoundingClientRect();
|
|
Object.assign(box.style, {
|
|
left: `${Math.round(r.left)}px`,
|
|
top: `${Math.round(r.top)}px`,
|
|
width: `${Math.round(Math.max(0, r.width))}px`,
|
|
height: `${Math.round(Math.max(0, r.height))}px`,
|
|
display: r.width > 0 && r.height > 0 ? 'block' : 'none',
|
|
});
|
|
} catch {}
|
|
};
|
|
const uniqueClassSelector = (node) => {
|
|
try {
|
|
const classes = Array.from(node.classList || []).filter(
|
|
(c) => c && /^[a-zA-Z0-9_-]+$/.test(c),
|
|
);
|
|
for (const cls of classes) {
|
|
const sel = `.${CSS.escape(cls)}`;
|
|
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
}
|
|
const tag = node.tagName ? node.tagName.toLowerCase() : '';
|
|
for (const cls of classes) {
|
|
const sel = `${tag}.${CSS.escape(cls)}`;
|
|
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
}
|
|
for (let i = 0; i < Math.min(classes.length, 3); i++) {
|
|
for (let j = i + 1; j < Math.min(classes.length, 3); j++) {
|
|
const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`;
|
|
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
}
|
|
}
|
|
} catch {}
|
|
return '';
|
|
};
|
|
const computeCandidates = (el) => {
|
|
const cands = [];
|
|
// css by id / class / short path
|
|
if (el.id) {
|
|
const idSel = `#${CSS.escape(el.id)}`;
|
|
if (document.querySelectorAll(idSel).length === 1)
|
|
cands.push({ type: 'css', value: idSel });
|
|
}
|
|
const classSel = uniqueClassSelector(el);
|
|
if (classSel) cands.push({ type: 'css', value: classSel });
|
|
// data-* and name
|
|
for (const attr of ['data-testid', 'data-cy', 'name']) {
|
|
const val = el.getAttribute(attr);
|
|
if (val) {
|
|
const s = `[${attr}="${CSS.escape(val)}"]`;
|
|
if (document.querySelectorAll(s).length === 1)
|
|
cands.push({ type: 'attr', value: s });
|
|
}
|
|
}
|
|
// aria
|
|
const aria = el.getAttribute && el.getAttribute('aria-label');
|
|
if (aria) cands.push({ type: 'aria', value: `textbox[name=${aria}]` });
|
|
// text for clickable
|
|
const tag = (el.tagName || '').toLowerCase();
|
|
if (['button', 'a', 'summary'].includes(tag)) {
|
|
const text = (el.textContent || '').trim();
|
|
if (text) cands.push({ type: 'text', value: text.substring(0, 64) });
|
|
}
|
|
// fallback path selector
|
|
const gen = (node) => {
|
|
if (!(node instanceof Element)) return '';
|
|
let path = '';
|
|
let current = node;
|
|
while (
|
|
current &&
|
|
current.nodeType === Node.ELEMENT_NODE &&
|
|
current.tagName !== 'BODY'
|
|
) {
|
|
let sel = current.tagName.toLowerCase();
|
|
const parent = current.parentElement;
|
|
if (parent) {
|
|
const siblings = Array.from(parent.children).filter(
|
|
(child) => child.tagName === current.tagName,
|
|
);
|
|
if (siblings.length > 1) {
|
|
const index = siblings.indexOf(current) + 1;
|
|
sel += `:nth-of-type(${index})`;
|
|
}
|
|
}
|
|
path = path ? `${sel} > ${path}` : sel;
|
|
current = parent;
|
|
}
|
|
return path ? `body > ${path}` : 'body';
|
|
};
|
|
const pathSel = gen(el);
|
|
if (pathSel) cands.push({ type: 'css', value: pathSel });
|
|
return cands;
|
|
};
|
|
const onClick = (e) => {
|
|
if (!state.active) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const el = e.target instanceof Element ? e.target : null;
|
|
if (!el) {
|
|
cleanup();
|
|
sendResponse({ success: false, error: 'no element' });
|
|
return true;
|
|
}
|
|
// create ref
|
|
try {
|
|
if (!window.__claudeElementMap) window.__claudeElementMap = {};
|
|
if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;
|
|
} catch {}
|
|
let refId = null;
|
|
try {
|
|
for (const k in window.__claudeElementMap) {
|
|
if (
|
|
window.__claudeElementMap[k].deref &&
|
|
window.__claudeElementMap[k].deref() === el
|
|
) {
|
|
refId = k;
|
|
break;
|
|
}
|
|
}
|
|
if (!refId) {
|
|
refId = `ref_${++window.__claudeRefCounter}`;
|
|
window.__claudeElementMap[refId] = new WeakRef(el);
|
|
}
|
|
} catch {}
|
|
const cands = computeCandidates(el);
|
|
cleanup();
|
|
sendResponse({ success: true, ref: refId, candidates: cands });
|
|
return true;
|
|
};
|
|
const onKey = (e) => {
|
|
if (e.key === 'Escape') {
|
|
cleanup();
|
|
sendResponse({ success: false, cancelled: true });
|
|
}
|
|
};
|
|
document.addEventListener('mousemove', onMove, true);
|
|
document.addEventListener('click', onClick, true);
|
|
document.addEventListener('keydown', onKey, true);
|
|
return true; // async
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
|
return true;
|
|
}
|
|
}
|
|
if (request && request.action === 'rr_picker_stop') {
|
|
try {
|
|
const host = document.getElementById('__rr_picker_host__');
|
|
if (host) host.remove();
|
|
sendResponse({ success: true });
|
|
return true;
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
|
return true;
|
|
}
|
|
}
|
|
if (request && request.action === 'generateAccessibilityTree') {
|
|
const result = __generateAccessibilityTree(request.filter || null, {
|
|
maxDepth: request.depth,
|
|
refId: request.refId,
|
|
});
|
|
if (result && result.error) {
|
|
sendResponse({ success: false, error: result.error });
|
|
return true;
|
|
}
|
|
sendResponse({ success: true, ...result });
|
|
return true;
|
|
}
|
|
if (request && request.action === 'ensureRefForSelector') {
|
|
try {
|
|
// Composite selector support: "frameSelector |> innerSelector"
|
|
const maybeSel = String(request.selector || '').trim();
|
|
const allowMultiple = !!request.allowMultiple;
|
|
if (maybeSel.includes('|>')) {
|
|
try {
|
|
const parts = maybeSel
|
|
.split('|>')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
if (parts.length >= 2) {
|
|
const frameSel = parts[0];
|
|
const innerSel = parts.slice(1).join(' |> ');
|
|
// Find target frame element in current document
|
|
let frameEl = null;
|
|
try {
|
|
frameEl = querySelectorDeepFirst(frameSel) || document.querySelector(frameSel);
|
|
} catch {}
|
|
if (
|
|
!frameEl ||
|
|
!(frameEl instanceof HTMLIFrameElement || frameEl instanceof HTMLFrameElement)
|
|
) {
|
|
sendResponse({
|
|
success: false,
|
|
error: `Composite frame selector not found: ${frameSel}`,
|
|
});
|
|
return true;
|
|
}
|
|
const cw = frameEl.contentWindow;
|
|
if (!cw) {
|
|
sendResponse({
|
|
success: false,
|
|
error: 'Unable to obtain contentWindow of target frame',
|
|
});
|
|
return true;
|
|
}
|
|
// Bridge to child frame via postMessage with timeout
|
|
const reqId = `rrc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const BRIDGE_TIMEOUT_MS = 5000; // 5 second timeout for iframe bridge
|
|
let responded = false;
|
|
let timeoutHandle = null;
|
|
|
|
const cleanup = () => {
|
|
window.removeEventListener('message', listener, true);
|
|
if (timeoutHandle) {
|
|
clearTimeout(timeoutHandle);
|
|
timeoutHandle = null;
|
|
}
|
|
};
|
|
|
|
const listener = (ev) => {
|
|
try {
|
|
const data = ev && ev.data;
|
|
if (
|
|
!data ||
|
|
data.type !== 'rr-bridge-ensure-ref-result' ||
|
|
data.reqId !== reqId
|
|
)
|
|
return;
|
|
// Validate source is the expected frame (security check)
|
|
if (ev.source !== cw) return;
|
|
|
|
if (responded) return; // Already timed out
|
|
responded = true;
|
|
cleanup();
|
|
|
|
if (data.success) {
|
|
sendResponse({
|
|
success: true,
|
|
ref: data.ref,
|
|
center: data.center,
|
|
href: data.href,
|
|
});
|
|
} else {
|
|
sendResponse({ success: false, error: data.error || 'child failed' });
|
|
}
|
|
} catch (e) {
|
|
if (!responded) {
|
|
responded = true;
|
|
cleanup();
|
|
sendResponse({
|
|
success: false,
|
|
error: String(e && e.message ? e.message : e),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Set up timeout to prevent infinite wait
|
|
timeoutHandle = setTimeout(() => {
|
|
if (!responded) {
|
|
responded = true;
|
|
cleanup();
|
|
sendResponse({
|
|
success: false,
|
|
error: `iframe bridge timeout after ${BRIDGE_TIMEOUT_MS}ms`,
|
|
});
|
|
}
|
|
}, BRIDGE_TIMEOUT_MS);
|
|
|
|
window.addEventListener('message', listener, true);
|
|
cw.postMessage(
|
|
{
|
|
type: 'rr-bridge-ensure-ref',
|
|
reqId,
|
|
selector: innerSel,
|
|
useText: !!request.useText,
|
|
isXPath: !!request.isXPath,
|
|
tagName: String(request.tagName || ''),
|
|
allowMultiple: !!request.allowMultiple,
|
|
},
|
|
'*',
|
|
);
|
|
return true; // async response via message bridge
|
|
}
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
|
return true;
|
|
}
|
|
}
|
|
// Support CSS selector, XPath, or visible text search
|
|
const useText = !!request.useText;
|
|
const textQuery = String(request.text || '').trim();
|
|
const sel = String(request.selector || '').trim();
|
|
const isXPath = !!request.isXPath;
|
|
const limitTag = String(request.tagName || '')
|
|
.trim()
|
|
.toUpperCase();
|
|
let el = null;
|
|
if (useText && textQuery) {
|
|
const normalize = (s) =>
|
|
String(s || '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.toLowerCase();
|
|
const query = normalize(textQuery);
|
|
const bigrams = (s) => {
|
|
const arr = [];
|
|
for (let i = 0; i < s.length - 1; i++) arr.push(s.slice(i, i + 2));
|
|
return arr;
|
|
};
|
|
const dice = (a, b) => {
|
|
if (!a || !b) return 0;
|
|
const A = bigrams(a);
|
|
const B = bigrams(b);
|
|
if (A.length === 0 || B.length === 0) return 0;
|
|
let inter = 0;
|
|
const map = new Map();
|
|
for (const t of A) map.set(t, (map.get(t) || 0) + 1);
|
|
for (const t of B) {
|
|
const c = map.get(t) || 0;
|
|
if (c > 0) {
|
|
inter++;
|
|
map.set(t, c - 1);
|
|
}
|
|
}
|
|
return (2 * inter) / (A.length + B.length);
|
|
};
|
|
let best = { el: null, score: 0 };
|
|
// Deep traversal including shadow roots
|
|
const stack = [document.documentElement];
|
|
let visited = 0;
|
|
while (stack.length) {
|
|
const node = /** @type {any} */ (stack.pop());
|
|
if (!node || !(node instanceof Element)) continue;
|
|
try {
|
|
if (limitTag && String(node.tagName || '').toUpperCase() !== limitTag) {
|
|
// still traverse into children/shadow for performance? yes
|
|
} else {
|
|
const cs = window.getComputedStyle(node);
|
|
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') {
|
|
/* skip hidden */
|
|
} else {
|
|
const rect = /** @type {HTMLElement} */ (node).getBoundingClientRect();
|
|
if (rect.width > 0 && rect.height > 0) {
|
|
const txt = normalize(node.textContent || '');
|
|
if (txt) {
|
|
if (txt.includes(query)) {
|
|
el = /** @type {Element} */ (node);
|
|
break;
|
|
}
|
|
const sc = dice(txt, query);
|
|
if (sc > best.score)
|
|
best = { el: /** @type {Element} */ (node), score: sc };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
// push children and shadow children
|
|
try {
|
|
const children = node.children || [];
|
|
for (let i = 0; i < children.length; i++) stack.push(children[i]);
|
|
} catch {}
|
|
try {
|
|
const sr = node.shadowRoot;
|
|
if (sr && sr.children) {
|
|
for (let i = 0; i < sr.children.length; i++) stack.push(sr.children[i]);
|
|
}
|
|
} catch {}
|
|
if (++visited > 8000) break;
|
|
}
|
|
if (!el && best.el && best.score >= 0.6) el = best.el;
|
|
} else if (isXPath) {
|
|
if (!sel) {
|
|
sendResponse({ success: false, error: 'selector is required' });
|
|
return true;
|
|
}
|
|
const result = queryXPathWithUniquenessCheck(sel, allowMultiple);
|
|
if (result.error) {
|
|
sendResponse({ success: false, error: result.error });
|
|
return true;
|
|
}
|
|
if (result.matchCount === 0) {
|
|
sendResponse({ success: false, error: `selector not found: ${sel}` });
|
|
return true;
|
|
}
|
|
if (!allowMultiple && result.matchCount > 1) {
|
|
sendResponse({
|
|
success: false,
|
|
error: `Selector "${sel}" matched multiple elements. Please refine the selector to match only one element.`,
|
|
});
|
|
return true;
|
|
}
|
|
el = result.element;
|
|
} else {
|
|
if (!sel) {
|
|
sendResponse({ success: false, error: 'selector is required' });
|
|
return true;
|
|
}
|
|
const result = querySelectorWithUniquenessCheck(sel, allowMultiple);
|
|
if (result.error) {
|
|
sendResponse({ success: false, error: result.error });
|
|
return true;
|
|
}
|
|
if (result.matchCount === 0) {
|
|
sendResponse({ success: false, error: `selector not found: ${sel}` });
|
|
return true;
|
|
}
|
|
if (!allowMultiple && result.matchCount > 1) {
|
|
sendResponse({
|
|
success: false,
|
|
error: `Selector "${sel}" matched multiple elements. Please refine the selector to match only one element.`,
|
|
});
|
|
return true;
|
|
}
|
|
el = result.element;
|
|
}
|
|
if (!el) {
|
|
sendResponse({ success: false, error: `selector not found: ${sel}` });
|
|
return true;
|
|
}
|
|
let refId = null;
|
|
for (const k in window.__claudeElementMap) {
|
|
if (window.__claudeElementMap[k].deref && window.__claudeElementMap[k].deref() === el) {
|
|
refId = k;
|
|
break;
|
|
}
|
|
}
|
|
if (!refId) {
|
|
refId = `ref_${++window.__claudeRefCounter}`;
|
|
window.__claudeElementMap[refId] = new WeakRef(el);
|
|
}
|
|
const rect = /** @type {HTMLElement} */ (el).getBoundingClientRect();
|
|
sendResponse({
|
|
success: true,
|
|
ref: refId,
|
|
center: {
|
|
x: Math.round(rect.left + rect.width / 2),
|
|
y: Math.round(rect.top + rect.height / 2),
|
|
},
|
|
});
|
|
return true;
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
|
return true;
|
|
}
|
|
}
|
|
if (request && request.action === 'dispatchHoverForRef') {
|
|
handleHoverForRef(String(request.ref || '').trim())
|
|
.then((result) => sendResponse(result))
|
|
.catch((error) =>
|
|
sendResponse({ success: false, error: error?.message || String(error) }),
|
|
);
|
|
return true;
|
|
}
|
|
if (request && request.action === 'getAttributeForSelector') {
|
|
try {
|
|
const sel = String(request.selector || '').trim();
|
|
const name = String(request.name || '').trim();
|
|
if (!sel || !name) {
|
|
sendResponse({ success: false, error: 'selector and name are required' });
|
|
return true;
|
|
}
|
|
const el = document.querySelector(sel) || querySelectorDeepFirst(sel);
|
|
if (!el) {
|
|
sendResponse({ success: false, error: `selector not found: ${sel}` });
|
|
return true;
|
|
}
|
|
let value = null;
|
|
if (name === 'text' || name === 'textContent') {
|
|
value = (el.textContent || '').trim();
|
|
} else if (name === 'value') {
|
|
try {
|
|
value = /** @type {HTMLInputElement} */ (el).value ?? null;
|
|
} catch (_) {
|
|
value = el.getAttribute('value');
|
|
}
|
|
} else {
|
|
value = el.getAttribute(name);
|
|
}
|
|
sendResponse({ success: true, value });
|
|
return true;
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
|
return true;
|
|
}
|
|
}
|
|
if (request && request.action === 'collectVariables') {
|
|
try {
|
|
let vars = Array.isArray(request.variables) ? request.variables : [];
|
|
if ((!vars || vars.length === 0) && request.payload) {
|
|
try {
|
|
const p = JSON.parse(String(request.payload || '{}'));
|
|
if (Array.isArray(p.variables)) vars = p.variables;
|
|
} catch {}
|
|
}
|
|
const useOverlay = request.useOverlay !== false; // default true
|
|
const values = {};
|
|
if (!useOverlay) {
|
|
for (const v of vars) {
|
|
const key = String(v && v.key ? v.key : '');
|
|
if (!key) continue;
|
|
const label = v.label || key;
|
|
const def = v.default || '';
|
|
const promptText = `请输入参数 ${label} (${key})`;
|
|
let val = window.prompt(promptText, def);
|
|
if (typeof val !== 'string') val = def;
|
|
values[key] = val;
|
|
}
|
|
sendResponse({ success: true, values });
|
|
return true;
|
|
}
|
|
// Build overlay form
|
|
const hostId = '__rr_var_overlay__';
|
|
let host = document.getElementById(hostId);
|
|
if (host) host.remove();
|
|
host = document.createElement('div');
|
|
host.id = hostId;
|
|
Object.assign(host.style, {
|
|
position: 'fixed',
|
|
inset: '0',
|
|
background: 'rgba(0,0,0,0.35)',
|
|
zIndex: 2147483646,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
});
|
|
const panel = document.createElement('div');
|
|
Object.assign(panel.style, {
|
|
background: '#fff',
|
|
borderRadius: '8px',
|
|
width: 'min(520px, 96vw)',
|
|
maxHeight: '80vh',
|
|
overflow: 'auto',
|
|
boxShadow: '0 8px 24px rgba(0,0,0,0.2)',
|
|
padding: '16px',
|
|
fontFamily: 'system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
|
});
|
|
const title = document.createElement('div');
|
|
title.textContent = '请输入回放参数';
|
|
Object.assign(title.style, { fontSize: '16px', fontWeight: '600', marginBottom: '12px' });
|
|
const form = document.createElement('form');
|
|
for (const v of vars) {
|
|
const row = document.createElement('div');
|
|
Object.assign(row.style, { marginBottom: '10px' });
|
|
const label = document.createElement('label');
|
|
label.textContent = `${v.label || v.key}${v.sensitive ? ' (敏感)' : ''}`;
|
|
Object.assign(label.style, {
|
|
display: 'block',
|
|
marginBottom: '6px',
|
|
fontWeight: '500',
|
|
});
|
|
const input = document.createElement('input');
|
|
input.type = v.sensitive ? 'password' : 'text';
|
|
input.name = String(v.key);
|
|
input.value = String(v.default || '');
|
|
Object.assign(input.style, {
|
|
width: '100%',
|
|
boxSizing: 'border-box',
|
|
padding: '8px 10px',
|
|
border: '1px solid #d0d7de',
|
|
borderRadius: '6px',
|
|
outline: 'none',
|
|
});
|
|
row.appendChild(label);
|
|
row.appendChild(input);
|
|
form.appendChild(row);
|
|
}
|
|
const actions = document.createElement('div');
|
|
Object.assign(actions.style, { display: 'flex', gap: '8px', marginTop: '12px' });
|
|
const ok = document.createElement('button');
|
|
ok.type = 'submit';
|
|
ok.textContent = '确定';
|
|
Object.assign(ok.style, {
|
|
background: '#0969da',
|
|
color: '#fff',
|
|
border: 'none',
|
|
padding: '8px 16px',
|
|
borderRadius: '6px',
|
|
cursor: 'pointer',
|
|
});
|
|
const cancel = document.createElement('button');
|
|
cancel.type = 'button';
|
|
cancel.textContent = '取消';
|
|
Object.assign(cancel.style, {
|
|
background: '#f3f4f6',
|
|
color: '#111',
|
|
border: '1px solid #d0d7de',
|
|
padding: '8px 16px',
|
|
borderRadius: '6px',
|
|
cursor: 'pointer',
|
|
});
|
|
actions.appendChild(ok);
|
|
actions.appendChild(cancel);
|
|
panel.appendChild(title);
|
|
panel.appendChild(form);
|
|
panel.appendChild(actions);
|
|
host.appendChild(panel);
|
|
document.documentElement.appendChild(host);
|
|
|
|
const cleanup = () => {
|
|
try {
|
|
host.remove();
|
|
} catch {}
|
|
};
|
|
cancel.onclick = () => {
|
|
cleanup();
|
|
sendResponse({ success: false, cancelled: true });
|
|
};
|
|
form.onsubmit = (e) => {
|
|
e.preventDefault();
|
|
for (const v of vars) {
|
|
const el = form.querySelector(`input[name="${CSS.escape(String(v.key))}"]`);
|
|
if (el) values[v.key] = /** @type {HTMLInputElement} */ (el).value;
|
|
}
|
|
cleanup();
|
|
sendResponse({ success: true, values });
|
|
};
|
|
return true; // async
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
|
return true;
|
|
}
|
|
}
|
|
if (request && request.action === 'resolveRef') {
|
|
const ref = request.ref;
|
|
try {
|
|
const map = window.__claudeElementMap;
|
|
const weak = map && map[ref];
|
|
const el = weak && typeof weak.deref === 'function' ? weak.deref() : null;
|
|
if (!el || !(el instanceof Element)) {
|
|
sendResponse({ success: false, error: `ref "${ref}" not found or expired` });
|
|
return true;
|
|
}
|
|
const rect = /** @type {HTMLElement} */ (el).getBoundingClientRect();
|
|
sendResponse({
|
|
success: true,
|
|
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
center: {
|
|
x: Math.round(rect.left + rect.width / 2),
|
|
y: Math.round(rect.top + rect.height / 2),
|
|
},
|
|
selector: (function () {
|
|
// Simple selector generation inline to avoid duplication
|
|
const generateSelector = function (node) {
|
|
if (!(node instanceof Element)) return '';
|
|
if (node.id) {
|
|
const idSel = `#${CSS.escape(node.id)}`;
|
|
if (document.querySelectorAll(idSel).length === 1) return idSel;
|
|
}
|
|
// prefer unique class selectors if available
|
|
try {
|
|
const classes = Array.from(node.classList || []).filter(
|
|
(c) => c && /^[a-zA-Z0-9_-]+$/.test(c),
|
|
);
|
|
for (const cls of classes) {
|
|
const sel = `.${CSS.escape(cls)}`;
|
|
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
}
|
|
const tag = node.tagName ? node.tagName.toLowerCase() : '';
|
|
for (const cls of classes) {
|
|
const sel = `${tag}.${CSS.escape(cls)}`;
|
|
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
}
|
|
for (let i = 0; i < Math.min(classes.length, 3); i++) {
|
|
for (let j = i + 1; j < Math.min(classes.length, 3); j++) {
|
|
const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`;
|
|
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
}
|
|
}
|
|
} catch {}
|
|
for (const attr of ['data-testid', 'data-cy', 'name']) {
|
|
const val = node.getAttribute(attr);
|
|
if (val) {
|
|
const s = `[${attr}="${CSS.escape(val)}"]`;
|
|
if (document.querySelectorAll(s).length === 1) return s;
|
|
}
|
|
}
|
|
let path = '';
|
|
let current = node;
|
|
while (
|
|
current &&
|
|
current.nodeType === Node.ELEMENT_NODE &&
|
|
current.tagName !== 'BODY'
|
|
) {
|
|
let sel = current.tagName.toLowerCase();
|
|
const parent = current.parentElement;
|
|
if (parent) {
|
|
const siblings = Array.from(parent.children).filter(
|
|
(c) => c.tagName === current.tagName,
|
|
);
|
|
if (siblings.length > 1) {
|
|
const idx = siblings.indexOf(current) + 1;
|
|
sel += `:nth-of-type(${idx})`;
|
|
}
|
|
}
|
|
path = path ? `${sel} > ${path}` : sel;
|
|
current = parent;
|
|
}
|
|
return path ? `body > ${path}` : 'body';
|
|
};
|
|
return generateSelector(el);
|
|
})(),
|
|
});
|
|
return true;
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
|
return true;
|
|
}
|
|
}
|
|
if (request && request.action === 'verifyFingerprint') {
|
|
try {
|
|
const ref = String(request.ref || '').trim();
|
|
const fingerprint = String(request.fingerprint || '').trim();
|
|
if (!ref || !fingerprint) {
|
|
sendResponse({ success: false, error: 'ref and fingerprint are required' });
|
|
return true;
|
|
}
|
|
const map = window.__claudeElementMap;
|
|
const weak = map && map[ref];
|
|
const el = weak && typeof weak.deref === 'function' ? weak.deref() : null;
|
|
if (!el || !(el instanceof Element)) {
|
|
sendResponse({ success: false, error: `ref "${ref}" not found or expired` });
|
|
return true;
|
|
}
|
|
// 验证指纹:解析存储的指纹并与当前元素对比
|
|
const parts = fingerprint.split('|');
|
|
const storedTag = parts[0] || 'unknown';
|
|
const currentTag = el.tagName ? String(el.tagName).toLowerCase() : 'unknown';
|
|
// Tag 必须匹配
|
|
if (storedTag !== currentTag) {
|
|
sendResponse({ success: true, match: false });
|
|
return true;
|
|
}
|
|
// 如果存储的指纹有 id,当前元素必须有相同的 id
|
|
const storedIdPart = parts.find((p) => p.startsWith('id='));
|
|
if (storedIdPart) {
|
|
const storedId = storedIdPart.slice(3);
|
|
const currentId = el.id ? String(el.id).trim() : '';
|
|
if (storedId !== currentId) {
|
|
sendResponse({ success: true, match: false });
|
|
return true;
|
|
}
|
|
}
|
|
sendResponse({ success: true, match: true });
|
|
return true;
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
|
return true;
|
|
}
|
|
}
|
|
if (request && request.action === 'focusByRef') {
|
|
try {
|
|
const ref = String(request.ref || '');
|
|
const map = window.__claudeElementMap || {};
|
|
const weak = map[ref];
|
|
const el = weak && typeof weak.deref === 'function' ? weak.deref() : null;
|
|
if (!el || !(el instanceof Element)) {
|
|
sendResponse({ success: false, error: `ref "${ref}" not found or expired` });
|
|
return true;
|
|
}
|
|
try {
|
|
/** @type {HTMLElement} */ (el).scrollIntoView({
|
|
behavior: 'instant',
|
|
block: 'center',
|
|
inline: 'nearest',
|
|
});
|
|
} catch {}
|
|
try {
|
|
/** @type {HTMLElement} */ (el).focus && /** @type {HTMLElement} */ (el).focus();
|
|
} catch {}
|
|
sendResponse({ success: true });
|
|
return true;
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: String(e && e.message ? e.message : e) });
|
|
return true;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
sendResponse({ success: false, error: e && e.message ? e.message : String(e) });
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
console.log('Accessibility tree helper script loaded');
|
|
// Cross-frame bridge: child listens for ensure-ref requests from parent (composite selector)
|
|
try {
|
|
window.addEventListener(
|
|
'message',
|
|
(ev) => {
|
|
try {
|
|
const data = ev && ev.data;
|
|
// Handle hover-ref bridge requests from parent frame
|
|
if (data && data.type === 'rr-bridge-hover-ref') {
|
|
handleHoverForRef(data.ref)
|
|
.then((result) => {
|
|
ev.source?.postMessage(
|
|
{ type: 'rr-bridge-hover-ref-result', reqId: data.reqId, result },
|
|
'*',
|
|
);
|
|
})
|
|
.catch((error) => {
|
|
ev.source?.postMessage(
|
|
{
|
|
type: 'rr-bridge-hover-ref-result',
|
|
reqId: data.reqId,
|
|
result: { success: false, error: error?.message || String(error) },
|
|
},
|
|
'*',
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
if (!data || data.type !== 'rr-bridge-ensure-ref') return;
|
|
const { reqId, selector, useText, isXPath, tagName } = data || {};
|
|
const respond = (payload) => {
|
|
try {
|
|
ev.source &&
|
|
ev.source.postMessage(
|
|
{ type: 'rr-bridge-ensure-ref-result', reqId, ...payload },
|
|
'*',
|
|
);
|
|
} catch {}
|
|
};
|
|
try {
|
|
const sel = String(selector || '').trim();
|
|
const limitTag = String(tagName || '')
|
|
.trim()
|
|
.toUpperCase();
|
|
let el = null;
|
|
if (useText && sel) {
|
|
const normalize = (s) =>
|
|
String(s || '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.toLowerCase();
|
|
const query = normalize(sel);
|
|
const bigrams = (s) => {
|
|
const arr = [];
|
|
for (let i = 0; i < s.length - 1; i++) arr.push(s.slice(i, i + 2));
|
|
return arr;
|
|
};
|
|
const dice = (a, b) => {
|
|
if (!a || !b) return 0;
|
|
const A = bigrams(a),
|
|
B = bigrams(b);
|
|
if (!A.length || !B.length) return 0;
|
|
let inter = 0;
|
|
const m = new Map();
|
|
for (const t of A) m.set(t, (m.get(t) || 0) + 1);
|
|
for (const t of B) {
|
|
const c = m.get(t) || 0;
|
|
if (c > 0) {
|
|
inter++;
|
|
m.set(t, c - 1);
|
|
}
|
|
}
|
|
return (2 * inter) / (A.length + B.length);
|
|
};
|
|
let best = { el: null, score: 0 };
|
|
const stack = [document.documentElement];
|
|
while (stack.length) {
|
|
const node = stack.pop();
|
|
if (!node || !(node instanceof Element)) continue;
|
|
try {
|
|
if (limitTag && String(node.tagName || '').toUpperCase() !== limitTag) {
|
|
} else {
|
|
const cs = window.getComputedStyle(node);
|
|
if (cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0') {
|
|
const rect = node.getBoundingClientRect();
|
|
if (rect.width > 0 && rect.height > 0) {
|
|
const txt = normalize(node.textContent || '');
|
|
if (txt) {
|
|
if (txt.includes(query)) {
|
|
el = node;
|
|
break;
|
|
}
|
|
const sc = dice(txt, query);
|
|
if (sc > best.score) best = { el: node, score: sc };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
try {
|
|
const children = node.children || [];
|
|
for (let i = 0; i < children.length; i++) stack.push(children[i]);
|
|
const sr = node.shadowRoot;
|
|
if (sr && sr.children)
|
|
for (let i = 0; i < sr.children.length; i++) stack.push(sr.children[i]);
|
|
} catch {}
|
|
}
|
|
if (!el && best.el) el = best.el;
|
|
} else if (isXPath) {
|
|
if (!sel) {
|
|
respond({ success: false, error: 'selector is required' });
|
|
return;
|
|
}
|
|
const allowMultiple = !!data.allowMultiple;
|
|
const result = queryXPathWithUniquenessCheck(sel, allowMultiple);
|
|
if (result.error) {
|
|
respond({ success: false, error: result.error });
|
|
return;
|
|
}
|
|
if (result.matchCount === 0) {
|
|
respond({ success: false, error: `Selector "${sel}" not found in child frame` });
|
|
return;
|
|
}
|
|
if (!allowMultiple && result.matchCount > 1) {
|
|
respond({
|
|
success: false,
|
|
error: `Selector "${sel}" matched multiple elements inside frame. Please refine the selector to match only one element.`,
|
|
});
|
|
return;
|
|
}
|
|
el = result.element;
|
|
} else {
|
|
if (!sel) {
|
|
respond({ success: false, error: 'selector is required' });
|
|
return;
|
|
}
|
|
const allowMultiple = !!data.allowMultiple;
|
|
const result = querySelectorWithUniquenessCheck(sel, allowMultiple);
|
|
if (result.error) {
|
|
respond({ success: false, error: result.error });
|
|
return;
|
|
}
|
|
if (result.matchCount === 0) {
|
|
respond({ success: false, error: `Selector "${sel}" not found in child frame` });
|
|
return;
|
|
}
|
|
if (!allowMultiple && result.matchCount > 1) {
|
|
respond({
|
|
success: false,
|
|
error: `Selector "${sel}" matched multiple elements inside frame. Please refine the selector to match only one element.`,
|
|
});
|
|
return;
|
|
}
|
|
el = result.element;
|
|
}
|
|
if (!el || !(el instanceof Element)) {
|
|
respond({ success: false, error: 'Element not found in child frame' });
|
|
return;
|
|
}
|
|
if (!window.__claudeElementMap) window.__claudeElementMap = {};
|
|
if (!window.__claudeRefCounter) window.__claudeRefCounter = 0;
|
|
let refId = null;
|
|
for (const k in window.__claudeElementMap) {
|
|
const w = window.__claudeElementMap[k];
|
|
if (w && typeof w.deref === 'function' && w.deref && w.deref() === el) {
|
|
refId = k;
|
|
break;
|
|
}
|
|
}
|
|
if (!refId) {
|
|
refId = `ref_${++window.__claudeRefCounter}`;
|
|
window.__claudeElementMap[refId] = new WeakRef(el);
|
|
}
|
|
const rect = el.getBoundingClientRect();
|
|
respond({
|
|
success: true,
|
|
ref: refId,
|
|
center: {
|
|
x: Math.round(rect.left + rect.width / 2),
|
|
y: Math.round(rect.top + rect.height / 2),
|
|
},
|
|
href: String(location && location.href ? location.href : ''),
|
|
});
|
|
} catch (e) {
|
|
respond({ success: false, error: String(e && e.message ? e.message : e) });
|
|
}
|
|
} catch {}
|
|
},
|
|
true,
|
|
);
|
|
} catch {}
|
|
})();
|