#!/usr/bin/env node /** * ui-shot.mjs — 给群聊台拍一张截图,用来肉眼审图。 * * 用法:node tools/ui-shot.mjs [url] [--out 文件.png] [--w 1440] [--h 900] [--wait 2500] [--dark|--light] */ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; const argv = process.argv.slice(2); const URL_ = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'http://127.0.0.1:3099/'; const flag = (n, d) => { const i = argv.indexOf('--' + n); return i > -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; }; const OUT = path.resolve(String(flag('out', path.join(process.cwd(), 'ui-shot.png')))); const W = Number(flag('w', 1480)); const H = Number(flag('h', 940)); const WAIT = Number(flag('wait', 2600)); const THEME = argv.includes('--light') ? 'light' : 'dark'; const PORT = 9333; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const EDGE = ['C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'].find((p) => fs.existsSync(p)); if (!EDGE) { console.error('找不到 msedge.exe'); process.exit(2); } const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'aig-shot-')); const edge = spawn(EDGE, [ '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--hide-scrollbars', `--window-size=${W},${H}`, `--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`, 'about:blank', ], { stdio: 'ignore', windowsHide: true }); async function wsUrl() { for (let i = 0; i < 60; i++) { try { const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json(); const p = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl); if (p) return p.webSocketDebuggerUrl; } catch { /* 等 */ } await sleep(250); } throw new Error('调试端口没起来'); } let ws; try { ws = new WebSocket(await wsUrl()); await new Promise((res, rej) => { ws.addEventListener('open', res, { once: true }); ws.addEventListener('error', () => rej(new Error('CDP 失败')), { once: true }); }); let id = 0; const pend = new Map(); ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pend.has(m.id)) { const { resolve, reject } = pend.get(m.id); pend.delete(m.id); m.error ? reject(new Error(m.error.message)) : resolve(m.result); } }); const send = (method, params = {}) => new Promise((resolve, reject) => { const i = ++id; pend.set(i, { resolve, reject }); ws.send(JSON.stringify({ id: i, method, params })); setTimeout(() => { if (pend.has(i)) { pend.delete(i); reject(new Error(method + ' 超时')); } }, 30000); }); const evaluate = (expr) => send('Runtime.evaluate', { expression: expr, returnByValue: true }); await send('Runtime.enable'); await send('Page.enable'); await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 2, mobile: false }); await send('Page.navigate', { url: URL_ }); for (let i = 0; i < 60; i++) { await sleep(250); const n = (await evaluate('document.querySelectorAll(".mcard").length')).result?.value || 0; if (n > 0) break; } await evaluate(`try { localStorage.setItem('aig.theme', ${JSON.stringify(THEME)}); } catch(e) {}; document.documentElement.dataset.theme = ${JSON.stringify(THEME)}; true`); await sleep(WAIT); const shot = await send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); fs.writeFileSync(OUT, Buffer.from(shot.data, 'base64')); const stat = await evaluate('JSON.stringify({members: document.querySelectorAll(".mcard").length, msgs: document.querySelectorAll(".row:not(.typing)").length, typing: document.querySelectorAll(".row.typing").length, strip: document.getElementById("strip").textContent.trim()})'); console.log('已保存 ' + OUT + ' ' + Math.round(fs.statSync(OUT).size / 1024) + 'KB'); console.log('页面状态:' + stat.result.value); } catch (e) { console.error('截图失败:' + e.message); process.exitCode = 1; } finally { try { ws?.close(); } catch { /* 忽略 */ } try { edge.kill(); } catch { /* 忽略 */ } await sleep(300); try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ } }