Files
ycode/scripts/wb-capture.mjs
T

241 lines
7.1 KiB
JavaScript

import './wb-legacy-disabled.mjs'
/**
* WorkBuddy 运行时样式采集(CDP)。
*
* 用途:拉取真实 WorkBuddy 界面的元素矩形与 computed style,作为 YCode 视觉对齐依据。
* 只读页面,不发送消息、不执行工具、不读取 cookies / localStorage。
*
* 用法:node scripts/wb-capture.mjs <port> <outDir>
*/
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)
})