/**
* Capture Inject JS — 注入到浏览器页面的捕获脚本
*
* 行为:
* - 鼠标悬浮:蓝色高亮框
* - Ctrl+C:复制当前高亮元素信息到剪贴板(BeeCapture 格式)
*
* 剪贴板写入三层(PRD §5):
* - text/plain: label(如 "DOM: button.submit")
* - text/html: label
* - web application/vnd.bee.capture+json: 完整 BeeCapture JSON(可选增强,try-catch)
*
* 注入方式:
* 1. Page.addScriptToEvaluateOnNewDocument — 新页面自动执行
* 2. Runtime.evaluate — 当前已加载页面立即执行
*
* installed 标志位在安装成功后设置,防止 addEventListener 重复注册。
*
* 注意:此脚本是独立字符串,运行在第三方网页上下文,不能 import @shared/bee-capture。
* BeeCapture 编码逻辑在此内联实现,与 src/shared/bee-capture.ts 的 encodeBeeCaptureHtml 对齐。
*/
export const captureInjectJS = `(function() {
if (window.__bee_capture_installed) return;
window.__bee_capture_enabled = true;
// ─── BeeCapture 编码(与 @shared/bee-capture.ts 对齐)──────────
var BEE_MIME = 'web application/vnd.bee.capture+json';
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(//g, '>');
}
/**
* 构造 label:DOM: {tag}{#id}{.class1.class2}
* 单行,无换行(chat 渲染要求)。
*/
function buildLabel(el) {
// 优先用元素文本内容(更有可读性),截断到 40 字符
var text = (el.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 40);
if (text) return text;
// 无文本时降级为 tag + id/class
var tag = el.tagName ? el.tagName.toLowerCase() : 'element';
var parts = [tag];
if (el.id) parts.push('#' + el.id);
if (el.className && typeof el.className === 'string') {
var classes = el.className.split(/\\s+/).filter(Boolean);
if (classes.length) parts.push('.' + classes.join('.'));
}
return 'DOM: ' + parts.join('');
}
/**
* 生成稳定的 CSS selector(nth-child 路径,不依赖随机 class/id)。
* 从元素向上遍历到 body,每级用 tag:nth-child(index)。
* 这是在页面结构不变时的确定性定位依据,AI 可直接 page.locator()。
*/
function buildStableSelector(el) {
var path = [];
var node = el;
var MAX_DEPTH = 15;
var depth = 0;
while (node && node.nodeType === 1 && node.tagName && depth < MAX_DEPTH) {
var tag = node.tagName.toLowerCase();
// html/body 作为起点,不用 nth-child
if (tag === 'html') break;
if (tag === 'body') { path.unshift('body'); break; }
// 计算 nth-child index(在父元素的子元素中的位置,元素节点)
var parent = node.parentElement;
if (!parent) { path.unshift(tag); break; }
var index = 1;
var sibling = parent.firstElementChild;
while (sibling && sibling !== node) {
sibling = sibling.nextElementSibling;
index++;
}
// 如果父元素有稳定的 id,直接用 #id 作为终点(更短且稳定)
if (parent.id && /^[A-Za-z][\w-]*$/.test(parent.id)) {
path.unshift(tag + ':nth-child(' + index + ')');
path.unshift('#' + parent.id);
break;
}
path.unshift(tag + ':nth-child(' + index + ')');
node = parent;
depth++;
}
return path.join(' > ');
}
/**
* 提取 snapshot 友好的无障碍信息(让 AI 能在 snapshot 树里快速匹配)。
*/
function buildA11yInfo(el) {
var role = el.getAttribute('role') || '';
var ariaLabel = el.getAttribute('aria-label') || '';
var ariaLabelledBy = el.getAttribute('aria-labelledby') || '';
var ariaDescribedBy = el.getAttribute('aria-describedby') || '';
// heading level
var headingLevel = 0;
var tagLower = (el.tagName || '').toLowerCase();
var hMatch = tagLower.match(/^(\d)$/);
if (hMatch) headingLevel = parseInt(hMatch[1], 10);
if (!headingLevel && role === 'heading') {
var ariaLevel = el.getAttribute('aria-level');
if (ariaLevel) headingLevel = parseInt(ariaLevel, 10);
}
// 隐式 role 推断:显式 role 优先,否则从 tag/type/href 推断
if (!role) {
role = inferImplicitRole(el, tagLower);
}
// 无障碍名称:优先 aria-label,否则 textContent 前 40
var name = ariaLabel || ((el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40));
return {
role: role,
name: name,
ariaLabel: ariaLabel,
ariaLabelledBy: ariaLabelledBy,
ariaDescribedBy: ariaDescribedBy,
headingLevel: headingLevel || undefined,
};
}
/**
* 从 tag/type/href 推断隐式 ARIA role。
* 与浏览器无障碍树映射一致,让 snapshot 能匹配。
*/
function inferImplicitRole(el, tagLower) {
// 有 href 的 a → link
if (tagLower === 'a' && el.getAttribute('href')) return 'link';
// 无 href 的 a → generic(现代浏览器)
if (tagLower === 'a') return 'generic';
// 直接映射
var directMap = {
button: 'button',
nav: 'navigation',
main: 'main',
header: 'banner',
footer: 'contentinfo',
aside: 'complementary',
form: 'form',
search: 'search',
ul: 'list',
ol: 'list',
li: 'listitem',
table: 'table',
tr: 'row',
td: 'cell',
th: 'columnheader',
img: 'image',
figure: 'figure',
dialog: 'dialog',
};
if (directMap[tagLower]) return directMap[tagLower];
// h1-h6 → heading
if (/^h[1-6]$/.test(tagLower)) return 'heading';
// input 根据类型
if (tagLower === 'input') {
var inputType = (el.getAttribute('type') || 'text').toLowerCase();
if (inputType === 'button' || inputType === 'submit' || inputType === 'reset') return 'button';
if (inputType === 'checkbox') return 'checkbox';
if (inputType === 'radio') return 'radio';
if (inputType === 'range') return 'slider';
if (inputType === 'search') return 'searchbox';
return 'textbox';
}
if (tagLower === 'textarea') return 'textbox';
if (tagLower === 'select') return 'listbox';
// 无隐式 role
return '';
}
/**
* 提取最近的有语义的祖先上下文(让 AI 能区分同级相似元素)。
* 向上查找最近的有 role / heading / id / nav / main / section / 语义 class 的祖先。
*/
function buildParentContext(el) {
var contexts = [];
var node = el.parentElement;
var depth = 0;
// 语义化 class 关键词(命中其一即视为有语义)
var semClassRe = /(?:^|[\s_-])(nav|menu|header|footer|sidebar|content|main|body|article|post|list|item|card|product|detail|search|login|user|cart|banner|hero|modal|dialog|popup|tab|panel|section|category|breadcrumb|pagination|comment|reply|form|register|checkout|profile|setting)(?:[\s_-]|$)/i;
while (node && depth < 8) {
var role = node.getAttribute && node.getAttribute('role');
var nodeTag = (node.tagName || '').toLowerCase();
var nodeClass = (typeof node.className === 'string' ? node.className : '');
var nodeText = (node.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 30);
var hasSemanticClass = semClassRe.test(nodeClass);
var isSemantic = role || /^h[1-6]$/.test(nodeTag) ||
['nav', 'main', 'header', 'footer', 'section', 'article', 'aside', 'form', 'dialog', 'ul', 'ol', 'table'].indexOf(nodeTag) >= 0 ||
node.id || hasSemanticClass;
if (isSemantic) {
contexts.push({
tag: nodeTag,
role: role || inferImplicitRole(node, nodeTag),
id: node.id || '',
className: nodeClass,
text: nodeText,
});
if (contexts.length >= 3) break; // 最多取 3 级上下文
}
node = node.parentElement;
depth++;
}
return contexts;
}
/**
* 构造 BeeCapture 对象(source='browser', kind='dom-element')。
*/
function buildCapture(el) {
var tag = el.tagName ? el.tagName.toLowerCase() : '';
var attrs = {};
if (el.attributes) {
for (var i = 0; i < el.attributes.length; i++) {
var a = el.attributes[i];
attrs[a.name] = a.value;
}
}
var selector = el.id ? '#' + el.id : tag;
if (!el.id && el.className && typeof el.className === 'string') {
var classes = el.className.split(/\\s+/).filter(Boolean);
if (classes.length) selector += '.' + classes.join('.');
}
var text = (el.textContent || '').trim().slice(0, 200);
return {
v: 1,
source: 'browser',
kind: 'dom-element',
label: buildLabel(el),
preview: text ? text.slice(0, 80) : undefined,
data: {
tag: tag,
id: el.id || '',
className: typeof el.className === 'string' ? el.className : '',
text: text,
selector: selector,
// 稳定的确定性定位依据(nth-child 路径),AI 可直接 page.locator() 使用
stableSelector: buildStableSelector(el),
attributes: attrs,
a11y: buildA11yInfo(el),
parentContext: buildParentContext(el),
url: location.href,
title: document.title,
},
};
}
/**
* 编码为 text/html 的 片段(与 encodeBeeCaptureHtml 对齐)。
*/
function encodeCaptureHtml(capture, refId) {
var dataStr = encodeURIComponent(JSON.stringify(capture.data));
var previewAttr = capture.preview
? ' data-bee-preview="' + escapeHtml(capture.preview) + '"'
: '';
return '' + escapeHtml(capture.label) + '';
}
/**
* 写三层剪贴板。
* 优先 navigator.clipboard.write(含 text/html + 自定义 MIME + text/plain),
* 失败降级 execCommand('copy')(同步选区拷贝,非安全上下文下最可靠),
* 再失败降级 navigator.clipboard.writeText(仅文本)。
*/
function writeCaptureToClipboard(capture) {
var refId = 'browser_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
var html = encodeCaptureHtml(capture, refId);
var plainText = JSON.stringify(capture, null, 2);
var jsonStr = JSON.stringify(capture);
// 优先:Clipboard API 写多格式(text/html + text/plain + 自定义 MIME)
if (navigator.clipboard && navigator.clipboard.write && window.ClipboardItem) {
try {
var items = {
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([plainText], { type: 'text/plain' }),
};
// 自定义 MIME 需要安全上下文,可能被拒,单独 try
try {
items[BEE_MIME] = new Blob([jsonStr], { type: BEE_MIME });
} catch (e) { /* custom MIME not allowed, skip */ }
return navigator.clipboard.write([new ClipboardItem(items)]).then(function() {
return 'clipboard.write';
}, function() {
// write 失败(权限/安全上下文)→ execCommand 兜底
return execCommandCopy(html);
}).catch(function() {
return execCommandCopy(html);
});
} catch (e) {
// ClipboardItem 构造失败 → execCommand 兜底
}
}
// execCommand 兜底:创建临时元素 + 选区 + execCommand('copy')
var result = execCommandCopy(html);
if (result) return Promise.resolve('execCommand');
// 最终降级:writeText 写 html 字符串(chat handlePaste 兜底解析 text/plain)
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(html);
}
return Promise.reject(new Error('Clipboard API not available'));
}
/**
* execCommand('copy') 同步兜底:创建临时可见元素承载 html,选区选中后执行 copy。
* 利用浏览器原生的 text/html 写入机制(同用户手动复制)。非安全上下文下最可靠。
* 返回 true 成功 / false 失败。
*/
function execCommandCopy(html) {
try {
var container = document.createElement('div');
container.setAttribute('contenteditable', 'true');
container.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;';
container.innerHTML = html;
document.body.appendChild(container);
var range = document.createRange();
range.selectNodeContents(container);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
var ok = document.execCommand('copy');
sel.removeAllRanges();
document.body.removeChild(container);
return ok;
} catch (e) {
return false;
}
}
// ─── 捕获 UI(高亮 + Ctrl+C)─────────────────────────────────
function install() {
if (window.__bee_capture_installed) return;
var highlight = document.createElement('div');
highlight.id = '__bee_highlight';
highlight.style.cssText = 'position:fixed;pointer-events:none;z-index:2147483647;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);display:none;transition:all 0.08s ease;';
document.documentElement.appendChild(highlight);
var currentTarget = null;
document.addEventListener('mouseover', function(e) {
if (!window.__bee_capture_enabled) return;
if (e.target === highlight) return;
currentTarget = e.target;
var r = e.target.getBoundingClientRect();
highlight.style.left = r.left + 'px';
highlight.style.top = r.top + 'px';
highlight.style.width = r.width + 'px';
highlight.style.height = r.height + 'px';
highlight.style.display = 'block';
}, true);
document.addEventListener('mouseout', function(e) {
if (e.target === currentTarget) {
highlight.style.display = 'none';
currentTarget = null;
}
}, true);
document.addEventListener('keydown', function(e) {
if (!window.__bee_capture_enabled) return;
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
console.log('[BEE-CAPTURE] Ctrl+C detected, currentTarget=', currentTarget, 'activeElement=', document.activeElement);
var el = currentTarget || document.activeElement;
if (!el || el === document.body || el === document.documentElement) {
console.log('[BEE-CAPTURE] no valid element, abort');
return;
}
var capture = buildCapture(el);
if (!capture) {
console.log('[BEE-CAPTURE] buildCapture returned null, abort');
return;
}
console.log('[BEE-CAPTURE] capture built, label=', capture.label);
e.preventDefault();
e.stopPropagation();
writeCaptureToClipboard(capture).then(function(method) {
console.log('[BEE-CAPTURE] clipboard write SUCCESS via', method);
highlight.style.border = '2px solid #22c55e';
highlight.style.background = 'rgba(34,197,94,0.2)';
setTimeout(function() {
highlight.style.border = '2px solid #3b82f6';
highlight.style.background = 'rgba(59,130,246,0.1)';
}, 300);
}).catch(function(err) {
console.log('[BEE-CAPTURE] clipboard write FAILED:', err && err.message ? err.message : err);
});
}
}, true);
window.addEventListener('scroll', function() {
highlight.style.display = 'none';
currentTarget = null;
}, true);
window.__bee_capture_installed = true;
}
// DOM 已就绪:立即安装
if (document.documentElement && document.body) {
install();
return;
}
// DOM 未就绪(addScriptToEvaluateOnNewDocument 场景):等 DOMContentLoaded
document.addEventListener('DOMContentLoaded', install, { once: true });
})();`