feat(问翻小宝): 接入静态帮助中心
This commit is contained in:
270
apps/web/scripts/capture-wenfan-help-screenshots.mjs
Normal file
270
apps/web/scripts/capture-wenfan-help-screenshots.mjs
Normal file
@@ -0,0 +1,270 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user