241 lines
7.1 KiB
JavaScript
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)
|
|
})
|