231 lines
8.6 KiB
JavaScript
231 lines
8.6 KiB
JavaScript
#!/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;
|
|
}
|