import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { spawn } from 'node:child_process'; const BASE_URL = process.env.WENFAN_HELP_BASE_URL || 'http://localhost:3000'; const VIEWPORT = { width: 1440, height: 1000 }; const OUTPUT_DIR = resolve('apps/web/public/help/wenfan-xiaobao'); const AUTH_USER = { id: 'm-8', name: '超级管理员', roleId: 'role-admin', departmentId: 'dept-1', phone: '13200132008', email: 'admin@company.com', }; const SCREENSHOTS = [ { file: 'product-create.png', route: '/products' }, { file: 'project-create.png', route: '/projects' }, { file: 'version-create.png', route: '/versions' }, { file: 'requirement-pool.png', route: '/requirements' }, { file: 'version-requirement-include.png', route: '/versions' }, { file: 'research-plan.png', route: '/versions' }, { file: 'product-plan.png', route: '/versions' }, { file: 'ai-decompose.png', route: '/versions' }, { file: 'ui-design-plan.png', route: '/versions' }, { file: 'dev-task-workflow.png', route: '/versions' }, { file: 'test-case-workflow.png', route: '/versions' }, { file: 'bug-workflow.png', route: '/versions' }, { file: 'activity-logs.png', route: '/workspace' }, { file: 'xiaobao-warning.png', route: '/xiaobao-warning' }, ]; function findBrowser() { const candidates = [ process.env.CHROME_PATH, 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', ].filter(Boolean); return candidates.find((candidate) => existsSync(candidate)); } function delay(ms) { return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); } async function assertServerAvailable() { try { const response = await fetch(BASE_URL); if (!response.ok) throw new Error(`HTTP ${response.status}`); } catch (error) { throw new Error(`本地页面不可访问:${BASE_URL}。请先启动前端 dev server。`); } } function launchBrowser(browserPath, userDataDir) { const child = spawn( browserPath, [ '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, `--window-size=${VIEWPORT.width},${VIEWPORT.height}`, 'about:blank', ], { stdio: ['ignore', 'pipe', 'pipe'] }, ); return child; } function waitForDevtools(child) { return new Promise((resolveDevtools, rejectDevtools) => { const timer = setTimeout(() => rejectDevtools(new Error('Chrome DevTools 启动超时。')), 15000); function onData(data) { const text = data.toString(); const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/); if (!match) return; clearTimeout(timer); child.stdout.off('data', onData); child.stderr.off('data', onData); resolveDevtools(match[1]); } child.stdout.on('data', onData); child.stderr.on('data', onData); child.once('exit', (code) => { clearTimeout(timer); rejectDevtools(new Error(`浏览器提前退出,退出码:${code}`)); }); }); } async function getPageWebSocketUrl(wsEndpoint) { const url = new URL(wsEndpoint); const listUrl = `http://${url.hostname}:${url.port}/json/list`; for (let i = 0; i < 30; i += 1) { const response = await fetch(listUrl); const targets = await response.json(); const page = targets.find((target) => target.type === 'page' && target.webSocketDebuggerUrl); if (page) return page.webSocketDebuggerUrl; await delay(200); } throw new Error('没有找到可截图的 Chrome 页面 target。'); } class CdpClient { constructor(webSocketUrl) { this.webSocketUrl = webSocketUrl; this.nextId = 1; this.pending = new Map(); this.eventWaiters = new Map(); } async connect() { this.ws = new WebSocket(this.webSocketUrl); this.ws.addEventListener('message', (event) => this.handleMessage(event.data)); await new Promise((resolveOpen, rejectOpen) => { this.ws.addEventListener('open', resolveOpen, { once: true }); this.ws.addEventListener('error', rejectOpen, { once: true }); }); } handleMessage(raw) { const message = JSON.parse(raw); if (message.id && this.pending.has(message.id)) { const { resolve: resolvePending, reject } = this.pending.get(message.id); this.pending.delete(message.id); if (message.error) reject(new Error(message.error.message)); else resolvePending(message.result); return; } if (message.method && this.eventWaiters.has(message.method)) { const waiters = this.eventWaiters.get(message.method); this.eventWaiters.delete(message.method); for (const waiter of waiters) waiter(message.params); } } send(method, params = {}) { const id = this.nextId; this.nextId += 1; const payload = JSON.stringify({ id, method, params }); return new Promise((resolveSend, rejectSend) => { this.pending.set(id, { resolve: resolveSend, reject: rejectSend }); this.ws.send(payload); }); } waitForEvent(method, timeoutMs = 15000) { return new Promise((resolveEvent, rejectEvent) => { const timer = setTimeout(() => rejectEvent(new Error(`等待 ${method} 超时。`)), timeoutMs); const waiters = this.eventWaiters.get(method) || []; waiters.push((params) => { clearTimeout(timer); resolveEvent(params); }); this.eventWaiters.set(method, waiters); }); } close() { this.ws?.close(); } } async function navigateAndWait(client, url) { const loadPromise = client.waitForEvent('Page.loadEventFired').catch(() => undefined); await client.send('Page.navigate', { url }); await loadPromise; await delay(1200); } async function authenticate(client) { await navigateAndWait(client, `${BASE_URL}/login`); const serialized = JSON.stringify(AUTH_USER).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); await client.send('Runtime.evaluate', { expression: ` sessionStorage.setItem('ftb_auth_session', '${serialized}'); localStorage.setItem('ftb_auth_persist', '${serialized}'); `, }); } async function capture(client, route, outputPath) { await navigateAndWait(client, `${BASE_URL}${route}`); await client.send('Emulation.setDeviceMetricsOverride', { width: VIEWPORT.width, height: VIEWPORT.height, deviceScaleFactor: 1, mobile: false, }); const screenshot = await client.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: true, }); writeFileSync(outputPath, Buffer.from(screenshot.data, 'base64')); } async function terminateBrowser(browser) { if (browser.exitCode !== null) return; const exited = new Promise((resolveExit) => { browser.once('exit', resolveExit); }); browser.kill(); await Promise.race([exited, delay(2000)]); } async function main() { const browserPath = findBrowser(); if (!browserPath) { throw new Error('未找到 Chrome 或 Edge,请安装浏览器,或通过 CHROME_PATH 指定浏览器路径。'); } await assertServerAvailable(); mkdirSync(OUTPUT_DIR, { recursive: true }); const userDataDir = join(tmpdir(), `wenfan-help-screenshots-${Date.now()}`); const browser = launchBrowser(browserPath, userDataDir); let client; try { const browserWsEndpoint = await waitForDevtools(browser); const pageWsEndpoint = await getPageWebSocketUrl(browserWsEndpoint); client = new CdpClient(pageWsEndpoint); await client.connect(); await client.send('Page.enable'); await client.send('Runtime.enable'); await authenticate(client); for (const item of SCREENSHOTS) { const outputPath = join(OUTPUT_DIR, item.file); await capture(client, item.route, outputPath); console.log(`saved ${outputPath}`); } } finally { client?.close(); await terminateBrowser(browser); try { rmSync(userDataDir, { recursive: true, force: true }); } catch { console.warn(`临时浏览器目录未能立即清理,可稍后手动删除:${userDataDir}`); } } } main().catch((error) => { console.error(error.message); process.exit(1); });