#!/usr/bin/env node import { execFileSync } from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); const adminUrl = process.env.ADMIN_URL || 'https://wygj-admin.localhost'; const apiUrl = process.env.API_URL || adminUrl.replace('wygj-admin', 'wygj-api'); const outDir = process.argv.includes('--out') ? path.resolve(process.argv[process.argv.indexOf('--out') + 1]) : '/tmp'; const chromeBin = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const clientid = readFrontendEnv().VITE_APP_CLIENT_ID; const pages = [ ['wygj-ai-interview-flow.png', '/recruit/interview', 'AI面试'], ['wygj-practice-flow.png', '/train/practice', '三角色对练'], ['wygj-cases-flow.png', '/knowledge/cases', '案例沉淀'], ['wygj-sop-flow.png', '/knowledge/sop', 'SOP知识库'] ]; if (!fs.existsSync(chromeBin)) { throw new Error(`Chrome not found: ${chromeBin}`); } fs.mkdirSync(outDir, { recursive: true }); const token = await login(); const chrome = await launchChrome(); try { const page = await connectPage(chrome.port); await page.send('Page.enable'); await page.send('Runtime.enable'); await page.send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 1000, deviceScaleFactor: 1, mobile: false }); await page.send('Page.addScriptToEvaluateOnNewDocument', { source: `localStorage.setItem('Admin-Token', ${JSON.stringify(token)});` }); for (const [fileName, route, marker] of pages) { const target = `${adminUrl}${route}`; await page.navigate(target); await page.waitForText(marker, 15000); await page.sleep(800); const screenshot = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true }); fs.writeFileSync(path.join(outDir, fileName), Buffer.from(screenshot.data, 'base64')); console.log(`${path.join(outDir, fileName)} ${target}`); } } finally { chrome.proc.kill('SIGTERM'); try { fs.rmSync(chrome.userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); } catch { // ponytail: temp profile cleanup is best-effort; the OS temp sweeper can remove leftovers. } } function readFrontendEnv() { const env = {}; for (const line of fs.readFileSync(path.join(root, 'frontend/.env.development'), 'utf8').split(/\r?\n/)) { const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/); if (match) env[match[1]] = match[2].replace(/^[ '"]+|[ '"]+$/g, ''); } return env; } function pem(label, body) { return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`; } function aesEncrypt(text, keyText) { const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null); cipher.setAutoPadding(true); return Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]).toString('base64'); } function aesDecrypt(base64Text, key) { const decipher = crypto.createDecipheriv('aes-256-ecb', key, null); decipher.setAutoPadding(true); return Buffer.concat([decipher.update(Buffer.from(base64Text, 'base64')), decipher.final()]).toString('utf8'); } async function login() { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; const env = readFrontendEnv(); const requestPublicKey = pem('PUBLIC KEY', env.VITE_APP_RSA_PUBLIC_KEY); const responsePrivateKey = pem('PRIVATE KEY', env.VITE_APP_RSA_PRIVATE_KEY); const request = async (method, endpoint, body, encrypted = false) => { const headers = { clientid, 'Content-Language': 'zh_CN' }; let payload; if (body !== undefined) { headers['Content-Type'] = 'application/json;charset=utf-8'; if (encrypted) { const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32); payload = aesEncrypt(JSON.stringify(body), keyText); headers['encrypt-key'] = crypto.publicEncrypt( { key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(Buffer.from(keyText, 'utf8').toString('base64'), 'utf8') ).toString('base64'); } else { payload = JSON.stringify(body); } } const response = await fetch(`${apiUrl}${endpoint}`, { method, headers, body: payload }); const text = await response.text(); if (!response.ok) throw new Error(`request failed ${endpoint}: ${response.status}`); if (!response.headers.get('encrypt-key')) return JSON.parse(text); const keyBase64 = crypto.privateDecrypt( { key: responsePrivateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(response.headers.get('encrypt-key'), 'base64') ).toString('utf8'); return JSON.parse(aesDecrypt(text, Buffer.from(keyBase64, 'base64'))); }; const codeResponse = await request('GET', '/auth/code'); const uuid = codeResponse.data?.uuid; const code = execFileSync( 'docker', ['exec', 'wygj-redis', 'redis-cli', '-a', 'ruoyi123', '--raw', 'get', `global:captcha_codes:${uuid}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } ).trim().split('\n').pop().replace(/^"|"$/g, ''); const loginResponse = await request('POST', '/auth/login', { tenantId: '000000', username: process.env.AIHR_VERIFY_USER || 'admin', password: process.env.AIHR_VERIFY_PASSWORD || 'admin123', rememberMe: false, code, uuid, clientId: clientid, grantType: 'password' }, true); const token = loginResponse.data?.access_token; if (!token) throw new Error('admin login failed'); return token; } async function launchChrome() { const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wygj-demo-chrome-')); const proc = spawn(chromeBin, [ '--headless=new', '--disable-gpu', '--no-first-run', '--ignore-certificate-errors', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, 'about:blank' ], { stdio: ['ignore', 'ignore', 'pipe'] }); const port = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Chrome DevTools did not start')), 10000); proc.stderr.on('data', (chunk) => { const match = String(chunk).match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//); if (match) { clearTimeout(timer); resolve(Number(match[1])); } }); proc.on('exit', (code) => reject(new Error(`Chrome exited: ${code}`))); }); return { proc, port, userDataDir }; } async function connectPage(port) { const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); const wsUrl = targets.find((target) => target.type === 'page')?.webSocketDebuggerUrl; if (!wsUrl) throw new Error('Chrome page target missing'); const ws = new WebSocket(wsUrl); await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); let id = 0; const pending = new Map(); const waiters = []; ws.onmessage = (event) => { const message = JSON.parse(event.data); if (message.id && pending.has(message.id)) { const { resolve, reject } = pending.get(message.id); pending.delete(message.id); message.error ? reject(new Error(message.error.message)) : resolve(message.result || {}); return; } waiters.splice(0).forEach((waiter) => waiter(message)); }; const api = { send(method, params = {}) { const callId = ++id; ws.send(JSON.stringify({ id: callId, method, params })); return new Promise((resolve, reject) => pending.set(callId, { resolve, reject })); }, async navigate(url) { await this.send('Page.navigate', { url }); await this.sleep(1000); }, waitForEvent(method, timeout) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`timeout waiting for ${method}`)), timeout); waiters.push((message) => { if (message.method === method) { clearTimeout(timer); resolve(message); } }); }); }, async waitForText(text, timeout) { const started = Date.now(); while (Date.now() - started < timeout) { const result = await this.send('Runtime.evaluate', { expression: `document.body && document.body.innerText.includes(${JSON.stringify(text)})`, returnByValue: true }); if (result.result?.value) return; await this.sleep(300); } throw new Error(`page text not found: ${text}`); }, sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } }; return api; }