import './wb-legacy-disabled.mjs' /** * WorkBuddy 运行时样式采集(CDP)。 * * 用途:拉取真实 WorkBuddy 界面的元素矩形与 computed style,作为 YCode 视觉对齐依据。 * 只读页面,不发送消息、不执行工具、不读取 cookies / localStorage。 * * 用法:node scripts/wb-capture.mjs */ import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' const port = Number(process.argv[2] ?? 9223) const outDir = process.argv[3] ?? 'docs/workbuddy-capture' /** 要采集的元素:侧栏、对话区、输入区 */ const SELECTORS = [ // 布局骨架 'body', '#root', 'body > div', '[class*="app-shell"]', '[class*="workbench"]', '[class*="layout"]', // 左侧栏 '.cb-sidebar-nav', '.cb-sidebar-nav__header', '.cb-sidebar-nav__header-title', '.cb-sidebar-nav__header-subtitle', '.cb-sidebar-nav__content', '.cb-sidebar-nav__section', '.cb-sidebar-nav__section-title', '.cb-sidebar-nav__item', '.cb-sidebar-nav__item--selected', '.cb-sidebar-nav__item-icon', '.cb-sidebar-nav__item-title', '.cb-sidebar-nav__item-subtitle', '.cb-sidebar-nav__item-badge', '.cb-sidebar-nav__footer', '[class*="sidebar"]', // 对话区 '[class*="conversation"]', '[class*="message-list"]', '.cr-message-list', '.cr-message-list__content', '.cr-agent', '.cr-agent__header', '.cr-self-message', '.cr-self-bubble', '.cr-markdown', '.cr-markdown p', '.cr-collapse', '.cr-collapse__header', // 输入区 '.cr-input-box', '.cr-input-box__main', '.cr-input-container', '.cr-input-editor-host', '.cr-input-toolbar', '.cr-input-toolbar__right', '.cr-input-footer', '.cr-send-button', '[class*="input-container"]', '[class*="input-area-container"]', // 右栏 / 面板 '.detail-sidebar', '[class*="detail-panel"]', '[class*="artifact"]' ] /** 精选属性,避免一次性导出 300+ 属性难以阅读 */ const PROPS = [ 'display', 'position', 'flex-direction', 'flex-grow', 'flex-shrink', 'align-items', 'justify-content', 'width', 'min-width', 'max-width', 'height', 'min-height', 'max-height', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right', 'gap', 'row-gap', 'column-gap', 'background-color', 'background-image', 'color', 'border-top-width', 'border-top-color', 'border-radius', 'box-shadow', 'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'text-transform', 'opacity', 'overflow-y', 'z-index' ] async function main() { const listRes = await fetch(`http://127.0.0.1:${port}/json/list`) const targets = await listRes.json() const pages = targets.filter((t) => t.type === 'page') if (pages.length === 0) { console.error('未找到 page 目标:', targets.map((t) => `${t.type}`).join(', ')) process.exit(1) } console.log('页面目标:') for (const p of pages) console.log(` - ${p.title} | ${p.url.split('?')[0].slice(0, 90)}`) // 优先主对话窗口(排除 search 等辅助窗口) const main = pages.find((p) => !/windowKind=/.test(p.url)) ?? pages[0] console.log('选定目标:', main.title) const ws = new WebSocket(main.webSocketDebuggerUrl) let id = 0 const pending = new Map() const send = (method, params = {}) => new Promise((resolve, reject) => { const msgId = ++id const timer = setTimeout(() => { pending.delete(msgId) reject(new Error(`CDP 超时: ${method}`)) }, 20000) pending.set(msgId, (v) => { clearTimeout(timer) resolve(v) }) ws.send(JSON.stringify({ id: msgId, method, params })) }) await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('WebSocket 连接超时')), 10000) ws.addEventListener('open', () => { clearTimeout(timer) resolve() }) ws.addEventListener('error', (e) => { clearTimeout(timer) reject(new Error(`WebSocket 错误: ${e?.message ?? 'unknown'}`)) }) }) ws.addEventListener('message', (ev) => { const msg = JSON.parse(ev.data) if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg.result) pending.delete(msg.id) } }) console.log('CDP 已连接') const expression = `(() => { const SELECTORS = ${JSON.stringify(SELECTORS)}; const PROPS = ${JSON.stringify(PROPS)}; const clean = (v) => (v == null ? null : String(v).replace(/\\s+/g, ' ').trim().slice(0, 220)); const pick = (el) => { const cs = getComputedStyle(el); const out = {}; for (const p of PROPS) { const v = clean(cs.getPropertyValue(p)); if (v && v !== 'none' && v !== 'normal' && v !== 'auto' && v !== '0px' && v !== 'rgba(0, 0, 0, 0)') out[p] = v; } return out; }; const rect = (el) => { const r = el.getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) }; }; const result = {}; for (const sel of SELECTORS) { let nodes = []; try { nodes = [...document.querySelectorAll(sel)]; } catch { nodes = []; } if (nodes.length === 0) continue; result[sel] = { count: nodes.length, samples: nodes.slice(0, 3).map((el) => ({ tag: el.tagName.toLowerCase(), cls: clean(el.className).slice(0, 160), text: clean(el.textContent).slice(0, 120), rect: rect(el), styles: pick(el) })) }; } // 顶层结构:从 body 往下三层,输出带尺寸的容器 const tree = []; const walk = (el, depth) => { if (depth > 4 || tree.length > 120) return; const r = el.getBoundingClientRect(); if (r.width < 8 || r.height < 8) return; tree.push({ depth, tag: el.tagName.toLowerCase(), cls: clean(el.className).slice(0, 90), rect: rect(el) }); for (const c of el.children) walk(c, depth + 1); }; for (const c of document.body.children) walk(c, 0); return JSON.stringify({ title: document.title, viewport: { w: innerWidth, h: innerHeight, dpr: devicePixelRatio, zoom: (window.outerWidth / innerWidth).toFixed(3) }, htmlAttrs: { theme: document.documentElement.dataset.theme || null, class: clean(document.documentElement.className) }, bodyBg: getComputedStyle(document.body).backgroundColor, selectors: result, layoutTree: tree }); })()` const evaluated = await send('Runtime.evaluate', { expression, returnByValue: true }) const data = JSON.parse(evaluated.result.value) await mkdir(outDir, { recursive: true }) await writeFile(join(outDir, 'styles.json'), JSON.stringify(data, null, 2), 'utf8') console.log('已写出 styles.json,命中选择器', Object.keys(data.selectors).length, '个') const shot = await send('Page.captureScreenshot', { format: 'png' }) if (shot?.data) { await writeFile(join(outDir, 'page.png'), Buffer.from(shot.data, 'base64')) console.log('已写出 page.png') } ws.close() } main().catch((err) => { console.error('采集失败:', err.message) process.exit(1) })