/** * 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 }); })();`