#!/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 { /* 忽略 */ } }