Files

153 lines
7.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 { /* 忽略 */ }
}