Initial commit: 让多个 AI 智能体通过文件总线在共享聊天室里互相 @、协作与交接任务
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ui-audit.mjs — 可读性审计:把页面上每段文字的实际对比度算出来(WCAG)。
|
||||
*
|
||||
* 为什么要有这个:暗色界面"看不清"是能测量的,不该靠感觉争论。
|
||||
* 它会把每个可见文本元素的 foreground / 有效背景做真实合成,算出对比度,
|
||||
* 按最差排序,并指出字号、选择器、文本样本。
|
||||
*
|
||||
* 用法:node tools/ui-audit.mjs [url] [--theme dark|light] [--min 4.5] [--json]
|
||||
*/
|
||||
|
||||
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 THEME = String(flag('theme', 'dark'));
|
||||
const MIN = Number(flag('min', 4.5));
|
||||
const AS_JSON = argv.includes('--json');
|
||||
const PORT = 9335;
|
||||
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 AUIDT_JS = String.raw`(() => {
|
||||
const lum = (rgb) => {
|
||||
const c = rgb.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
|
||||
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
||||
};
|
||||
const parse = (s) => {
|
||||
const m = String(s).match(/rgba?\(([^)]+)\)/);
|
||||
if (!m) return null;
|
||||
const p = m[1].split(/[,\s/]+/).filter(Boolean).map(Number);
|
||||
return { rgb: [p[0], p[1], p[2]], a: p.length > 3 ? p[3] : 1 };
|
||||
};
|
||||
const over = (fg, bg) => fg.rgb.map((v, i) => v * fg.a + bg[i] * (1 - fg.a));
|
||||
const effBg = (el) => {
|
||||
const chain = [];
|
||||
let n = el;
|
||||
while (n && n.nodeType === 1) {
|
||||
const c = parse(getComputedStyle(n).backgroundColor);
|
||||
if (c && c.a > 0) chain.push(c);
|
||||
if (c && c.a >= 0.999) break;
|
||||
n = n.parentElement;
|
||||
}
|
||||
let base = [255, 255, 255];
|
||||
for (let i = chain.length - 1; i >= 0; i--) base = over(chain[i], base);
|
||||
return base;
|
||||
};
|
||||
const ratio = (a, b) => { const l1 = lum(a), l2 = lum(b); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); };
|
||||
const out = [];
|
||||
for (const el of document.querySelectorAll('body *')) {
|
||||
// 只看"自己直接带文字"的元素
|
||||
let text = '';
|
||||
for (const n of el.childNodes) if (n.nodeType === 3) text += n.textContent;
|
||||
text = text.trim();
|
||||
if (!text) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
if (r.width === 0 || r.height === 0 || cs.visibility === 'hidden' || cs.display === 'none' || Number(cs.opacity) < 0.4) continue;
|
||||
const fg = parse(cs.color);
|
||||
if (!fg) continue;
|
||||
const bg = effBg(el);
|
||||
const f = over(fg, bg);
|
||||
const size = parseFloat(cs.fontSize);
|
||||
const weight = Number(cs.fontWeight) || 400;
|
||||
const large = size >= 24 || (size >= 18.66 && weight >= 700);
|
||||
const cr = ratio(f, bg);
|
||||
out.push({
|
||||
sel: el.tagName.toLowerCase() + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : ''),
|
||||
text: text.replace(/\s+/g, ' ').slice(0, 34),
|
||||
size, weight, large,
|
||||
cr: Math.round(cr * 100) / 100,
|
||||
need: large ? 3 : 4.5,
|
||||
ok: cr >= (large ? 3 : 4.5),
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => a.cr - b.cr);
|
||||
return JSON.stringify(out);
|
||||
})()`;
|
||||
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'aig-audit-'));
|
||||
const edge = spawn(EDGE, ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
|
||||
'--disable-extensions', '--hide-scrollbars', '--window-size=1480,940',
|
||||
`--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 = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true })).result?.value;
|
||||
|
||||
await send('Runtime.enable');
|
||||
await send('Page.enable');
|
||||
await send('Page.navigate', { url: URL_ });
|
||||
for (let i = 0; i < 60; i++) { await sleep(250); if ((await evaluate('document.querySelectorAll(".mcard").length')) > 0) break; }
|
||||
await evaluate(`try { localStorage.setItem('aig.theme', ${JSON.stringify(THEME)}); } catch(e) {}; document.documentElement.dataset.theme = ${JSON.stringify(THEME)}; true`);
|
||||
await sleep(1500);
|
||||
const rows = JSON.parse(await evaluate(AUIDT_JS));
|
||||
|
||||
const bad = rows.filter((r) => !r.ok);
|
||||
const tiny = rows.filter((r) => r.size < 12);
|
||||
if (AS_JSON) {
|
||||
console.log(JSON.stringify({ theme: THEME, total: rows.length, failing: bad.length, tiny: tiny.length, worst: rows.slice(0, 25) }, null, 2));
|
||||
} else {
|
||||
console.log(`\n主题 ${THEME} · 参与统计的文本元素 ${rows.length} 个 · 不达标(<${MIN}:1 或大字号 <3:1)${bad.length} 个 · 字号 <12px 的 ${tiny.length} 个\n`);
|
||||
const head = ['对比度', '要求', '字号', '选择器', '文本'];
|
||||
const body = rows.slice(0, 22).map((r) => [`${r.cr}:1`, `${r.need}:1`, `${r.size}px`, r.sel, r.text]);
|
||||
const w = head.map((h, i) => Math.max(...[h, ...body.map((b) => b[i])].map((x) => String(x).length)));
|
||||
const line = (c) => c.map((x, i) => String(x).padEnd(w[i])).join(' ');
|
||||
console.log(line(head)); console.log(line(w.map((n) => '─'.repeat(n))));
|
||||
for (const b of body) console.log(line(b));
|
||||
const passing = rows.filter((r) => r.ok);
|
||||
console.log(`\n最低对比度 ${rows[0]?.cr}:1(${rows[0]?.sel})· 达标元素最低 ${passing.length ? Math.min(...passing.map((r) => r.cr)) : '—'}:1`);
|
||||
}
|
||||
process.exitCode = bad.length ? 1 : 0;
|
||||
} catch (e) {
|
||||
console.error('审计失败:' + e.message);
|
||||
process.exitCode = 2;
|
||||
} finally {
|
||||
try { ws?.close(); } catch { /* 忽略 */ }
|
||||
try { edge.kill(); } catch { /* 忽略 */ }
|
||||
await sleep(300);
|
||||
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* 忽略 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user