Files
ftb-project-management/apps/web/scripts/capture-wenfan-help-screenshots.mjs
2026-06-30 19:27:23 +08:00

271 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});