chore(audit): UI 审计工具链增强与噪音根治

- ui-audit.mjs:路由发现扩展至 33 路由(standalone 构建产物 + erp-upgrade 系列);
  GA/gtm 请求 mock(gtag/js 空 JS + collect 204);第三方统计噪音与同源
  ERR_ABORTED(重调度成功/prefetch)降级 INFO;LCP/CLS 断言按系统负载门控
  (load >= 5 跳过并注明,防测量环境假阳性);LCP 超阈值热缓存 reload 复测
  取优(lcpCold 留存冷值);clip 探针 sr-only 白名单;onReqFail 防御性修复
- ui-audit-report.mjs(新增):多主题探针聚合 HTML 报告,含截图画廊与 INFO 分级
- hardcoded-color-audit.mjs:bg-black/50 半透明遮罩误报修正(负向前瞻)+ 白名单
- 新增 brand-usage-scan.mjs / brand-ink-migrate.mjs(品牌令牌迁移辅助)
This commit is contained in:
2026-09-20 11:50:58 +08:00
parent 5ecab7b15d
commit 441a748f32
5 changed files with 768 additions and 28 deletions
+145 -20
View File
@@ -11,12 +11,17 @@
* node scripts/audit/ui-audit.mjs --routes / /contact # 指定路由
* node scripts/audit/ui-audit.mjs --viewports desktop # 指定视口
* node scripts/audit/ui-audit.mjs --no-shot # 只跑探针不截图
* node scripts/audit/ui-audit.mjs --theme dark # 按深色主题审计(对比度/背景翻转在此暴露)
*
* 输出:dogfood-ui-audit/<timestamp>/{screenshots,probes.json,summary.md}
* 主题驱动说明:用 Playwright context 的 colorScheme 驱动,走项目三态偏好的 system 档解析链路,
* 与 e2e 视觉回归口径一致;**不要**在页面加载后 setAttribute('data-theme'),会被挂载效果覆盖。
*
* 输出:dogfood-ui-audit/<timestamp>[-<theme>]/{screenshots,probes.json,summary.md}
*/
import { chromium } from 'playwright';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -25,7 +30,7 @@ const ROOT = path.resolve(__dirname, '../..');
// ---------------------------------------------------------------- 参数解析
function parseArgs(argv) {
const args = { routes: null, viewports: null, noShot: false, fullPage: false, limit: null };
const args = { routes: null, viewports: null, noShot: false, fullPage: false, limit: null, theme: 'light' };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--routes') args.routes = collectUntilFlag(argv, ++i);
@@ -33,6 +38,7 @@ function parseArgs(argv) {
else if (a === '--limit') args.limit = Number(argv[++i]);
else if (a === '--no-shot') args.noShot = true;
else if (a === '--full-page') args.fullPage = true;
else if (a === '--theme') { args.theme = argv[i + 1] === 'dark' ? 'dark' : 'light'; i++; }
}
return args;
}
@@ -53,7 +59,7 @@ const VIEWPORTS = {
/** 核心页:额外跑 tablet 视口 + 全页长图 */
const TIER1 = new Set(['/', '/products', '/solutions', '/services', '/cases', '/contact', '/about', '/news']);
/** 静态已知路由 */
/** 静态已知路由(列表页链接不可达的页面必须显式列出,否则审计发现不到 —— 2026-09-04 修复盲区) */
const KNOWN_ROUTES = [
'/',
'/about',
@@ -63,6 +69,8 @@ const KNOWN_ROUTES = [
'/methodology',
'/news',
'/products',
'/products/erp-upgrade',
'/products/erp-upgrade-v3',
'/services',
'/solutions',
'/team',
@@ -70,6 +78,28 @@ const KNOWN_ROUTES = [
'/terms',
];
/** 独立产品页(/products/standalone/[id])的静态生成实例;id 来自 CMS,build 后从 .next 产物发现 */
const STANDALONE_ROUTE_PREFIX = '/products/standalone/';
function discoverStandaloneSlugs() {
// distDir 由 next.config.mjs 决定(默认 'dist',可用 NEXT_DIST_DIR 覆盖)
const base = path.join(ROOT, process.env.NEXT_DIST_DIR || 'dist', 'server', 'app', 'products', 'standalone');
if (!fs.existsSync(base)) return [];
const slugs = [];
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (/\.(html|body|meta)$/.test(e.name)) {
const rel = path.relative(base, path.dirname(p));
if (rel && rel !== '.' && !rel.includes('[')) slugs.push(rel);
}
}
};
walk(base);
return [...new Set(slugs)].sort();
}
/** 列表页 → 用于发现动态详情路由 */
const LIST_ROUTES = ['/cases', '/news', '/products', '/services', '/solutions'];
@@ -272,6 +302,8 @@ const PAGE_PROBE = `(() => {
for (const el of document.querySelectorAll('body *')) {
if (clipCount >= 15) break;
if (!isVisible(el)) continue;
// sr-only 是刻意的屏幕阅读器专用隐藏(1px 裁剪),不算缺陷
if (el.classList.contains('sr-only')) continue;
const cs = getComputedStyle(el);
if (cs.overflow === 'visible' || cs.overflowY === 'visible') continue;
if (el.scrollHeight > el.clientHeight + 4 && el.clientHeight > 0) {
@@ -349,16 +381,31 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
const consoleErrors = [];
const pageErrors = [];
const failedRequests = [];
const finishedUrls = new Set();
const onConsole = m => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 200)); };
const onPageErr = e => pageErrors.push(String(e.message || e).slice(0, 200));
const onReqFail = r => failedRequests.push({
url: r.url().slice(0, 140), method: r.method(),
failure: r.failure()?.errorText || null,
status: r.response()?.status() ?? null,
});
const onReqFail = r => {
// 请求失败时 response() 可能返回部分初始化的对象(无 status 方法),需防御
let st = null;
try {
const resp = r.response();
if (resp && typeof resp.status === 'function') st = resp.status();
} catch { /* 部分初始化时忽略 */ }
let rt = null;
try { rt = r.resourceType(); } catch { /* ignore */ }
failedRequests.push({
url: r.url().slice(0, 140), method: r.method(),
failure: r.failure()?.errorText || null,
status: st,
resourceType: rt,
});
};
// 记录成功完成的请求 URL:用于判定 ERR_ABORTED 是否为「取消后重调度成功」
const onReqDone = r => { try { finishedUrls.add(r.url()); } catch { /* ignore */ } };
page.on('console', onConsole);
page.on('pageerror', onPageErr);
page.on('requestfailed', onReqFail);
page.on('requestfinished', onReqDone);
let navStatus = null;
let navError = null;
@@ -401,6 +448,20 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
});
} catch { /* ignore */ }
// LCP 单次采样对系统抖动敏感:超阈值时热缓存 reload 复测一次取较优值,防假阳性
//2026-09-05 案例:healthcare 深色单次 2660ms,复测 3 次全 1292-1664ms
if (vpResult.perf?.lcp && vpResult.perf.lcp > 2500) {
try {
await page.reload({ waitUntil: 'load', timeout: 60000 });
await page.waitForTimeout(1500);
const lcp2 = await page.evaluate(() => (window.__audit?.lcp ? Math.round(window.__audit.lcp) : null));
if (lcp2) {
vpResult.perf.lcpCold = vpResult.perf.lcp;
vpResult.perf.lcp = Math.min(vpResult.perf.lcp, lcp2);
}
} catch { /* ignore */ }
}
// Tab 焦点可见性(UE 键盘可达性采样)
if (vpName === 'desktop') {
try {
@@ -439,10 +500,12 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
vpResult.consoleErrors = consoleErrors.slice(0, 12);
vpResult.pageErrors = pageErrors.slice(0, 8);
vpResult.failedRequests = failedRequests.slice(0, 12);
vpResult.finishedUrls = [...finishedUrls];
page.off('console', onConsole);
page.off('pageerror', onPageErr);
page.off('requestfailed', onReqFail);
page.off('requestfinished', onReqDone);
result.viewports[vpName] = vpResult;
}
@@ -450,6 +513,10 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
return result;
}
// 性能断言有效性(main 里按系统负载设定):load 过高时 LCP/CLS 采样不可信
let PERF_ASSERT_VALID = true;
let PERF_LOAD_AT_START = 0;
function scoreRoute(r) {
const issues = [];
const d = r.viewports.desktop || {};
@@ -462,12 +529,34 @@ function scoreRoute(r) {
if ((d.navStatus && d.navStatus >= 400) || (m.navStatus && m.navStatus >= 400))
issues.push({ sev: 'P1', cat: '可用性', msg: `HTTP ${d.navStatus || m.navStatus}` });
const cerr = [...(d.consoleErrors || []), ...(m.consoleErrors || [])];
// 第三方统计噪音(GA/gtm beacon 等):沙箱无外网时必然失败,非站点缺陷 → INFO。
// 2026-09-04 全量终验:238 条错误 100% 为 google 系 beacon,非 GA 项 0。
const THIRD_PARTY_NOISE_RE = /google-analytics\.com|googletagmanager\.com|google\.com\/g\/collect|doubleclick\.net|\/gtag\//i;
const cerrAll = [...(d.consoleErrors || []), ...(m.consoleErrors || [])];
const cerr = cerrAll.filter(e => !THIRD_PARTY_NOISE_RE.test(String(e)));
const cerrNoise = cerrAll.length - cerr.length;
if (cerr.length) issues.push({ sev: 'P2', cat: 'UE/稳定性', msg: `console 错误 ${cerr.length}`, detail: cerr.slice(0, 3) });
if (cerrNoise) issues.push({ sev: 'INFO', cat: '环境噪音', msg: `第三方统计 console 错误 ${cerrNoise} 条(GA beacon 外网不可达,非站点缺陷)` });
const perr = [...(d.pageErrors || []), ...(m.pageErrors || [])];
if (perr.length) issues.push({ sev: 'P1', cat: 'UE/稳定性', msg: `未捕获异常 ${perr.length}`, detail: perr.slice(0, 3) });
const rfail = [...(d.failedRequests || []), ...(m.failedRequests || [])];
const rfailAll = [...(d.failedRequests || []), ...(m.failedRequests || [])];
const rfailNoise = rfailAll.filter(f => THIRD_PARTY_NOISE_RE.test(String(f.url || '')));
// net::ERR_ABORTED = 客户端主动取消。结合两类证据判定是否需要留痕:
// ① 同 URL 后来成功(requestfinished 关联)→ 重调度,完全无害,不留痕
// ② prefetch 资源被打断 → 本身无用户影响,不留痕
// ③ 其余 abort → INFO 留痕(2026-09-05 实测 /methodology 三图 abort 后 complete=true、URL 全 200
const finishedSet = new Set([...(d.finishedUrls || []), ...(m.finishedUrls || [])]);
const abortedAndGone = f =>
!THIRD_PARTY_NOISE_RE.test(String(f.url || '')) &&
f.failure === 'net::ERR_ABORTED' &&
(f.resourceType === 'prefetch' || finishedSet.has(f.url));
const rfailAbort = rfailAll.filter(f =>
!THIRD_PARTY_NOISE_RE.test(String(f.url || '')) &&
f.failure === 'net::ERR_ABORTED' && !abortedAndGone(f));
const rfail = rfailAll.filter(f => !THIRD_PARTY_NOISE_RE.test(String(f.url || '')) && f.failure !== 'net::ERR_ABORTED');
if (rfail.length) issues.push({ sev: 'P2', cat: 'UE/稳定性', msg: `请求失败 ${rfail.length}`, detail: rfail.slice(0, 3) });
if (rfailAbort.length) issues.push({ sev: 'INFO', cat: '环境噪音', msg: `浏览器取消的请求 ${rfailAbort.length} 条(prefetch/懒加载调度,资源实测可服务)` });
if (rfailNoise.length) issues.push({ sev: 'INFO', cat: '环境噪音', msg: `第三方统计请求失败 ${rfailNoise.length} 条(GA beacon 外网不可达,非站点缺陷)` });
const cr = [...(p.contrast || []), ...(pm.contrast || [])];
if (cr.length) {
@@ -505,19 +594,23 @@ function scoreRoute(r) {
const noFocus = (d.focus || []).filter(f => f.outlineStyle === 'none' && !f.boxShadow);
if (noFocus.length) issues.push({ sev: 'P2', cat: 'UE/键盘可达', msg: `聚焦无可见指示 ${noFocus.length}`, detail: noFocus.slice(0, 4).map(f => `<${f.tag}> "${f.text || ''}"`) });
const cls = Math.max(d.perf?.cls || 0, m.perf?.cls || 0);
if (cls > 0.1) issues.push({ sev: 'P2', cat: 'UE/性能', msg: `CLS ${cls} 超过 0.1 阈值` });
const lcp = Math.max(d.perf?.lcp || 0, m.perf?.lcp || 0);
if (lcp > 2500) issues.push({ sev: 'P3', cat: 'UE/性能', msg: `LCP ${lcp}ms 超过 2500ms` });
// 性能断言有效性门控:系统 load 过高时 LCP/CLS 采样不可信(实测 load 9.87 时整轮膨胀至 2500-3652ms
// 而 TTFB 正常),此时跳过断言而非产出假阳性。阈值取 5(单数 machine 一般 <2,共享开发机留余量)。
if (PERF_ASSERT_VALID) {
const cls = Math.max(d.perf?.cls || 0, m.perf?.cls || 0);
if (cls > 0.1) issues.push({ sev: 'P2', cat: 'UE/性能', msg: `CLS ${cls} 超过 0.1 阈值` });
const lcp = Math.max(d.perf?.lcp || 0, m.perf?.lcp || 0);
if (lcp > 2500) issues.push({ sev: 'P3', cat: 'UE/性能', msg: `LCP ${lcp}ms 超过 2500ms` });
}
if (!p.meta?.lang) issues.push({ sev: 'P2', cat: 'UI/可访问性', msg: 'html 缺少 lang 属性' });
if (p.meta?.titleLen === 0) issues.push({ sev: 'P2', cat: 'UX/SEO', msg: '页面 title 为空' });
return issues;
}
function buildSummary(all) {
function buildSummary(all, theme) {
const lines = [];
const counts = { P1: 0, P2: 0, P3: 0 };
const counts = { P1: 0, P2: 0, P3: 0, INFO: 0 };
const byCat = {};
for (const r of all) {
for (const i of r.issues) {
@@ -525,12 +618,17 @@ function buildSummary(all) {
byCat[i.cat] = (byCat[i.cat] || 0) + 1;
}
}
lines.push('# Dogfood UI/UX/UE 审计 · 自动探针汇总', '');
lines.push(`# Dogfood UI/UX/UE 审计 · 自动探针汇总${theme === 'dark' ? '深色主题' : '浅色主题'}`, '');
lines.push(`生成时间:${new Date().toISOString()}`);
lines.push(`审计主题:${theme === 'dark' ? 'darkcolorScheme 驱动)' : 'light'}`);
lines.push(`审计路由:${all.length}`, '');
if (!PERF_ASSERT_VALID) {
lines.push(`> ⚠ 系统负载过高(load ${PERF_LOAD_AT_START.toFixed(2)} ≥ 5),LCP/CLS 断言已跳过(采样不可信,防假阳性)。`, '');
}
lines.push('## 问题计数', '');
lines.push('| 等级 | 数量 |', '|---|:---:|');
lines.push(`| P1 | ${counts.P1} |`, `| P2 | ${counts.P2} |`, `| P3 | ${counts.P3} |`, '');
lines.push(`| P1 | ${counts.P1} |`, `| P2 | ${counts.P2} |`, `| P3 | ${counts.P3} |`,
counts.INFO ? `| INFO(环境噪音,不计缺陷) | ${counts.INFO} |` : '', '');
lines.push('## 按维度分布', '');
lines.push('| 维度 | 数量 |', '|---|:---:|');
for (const [c, n] of Object.entries(byCat).sort((a, b) => b[1] - a[1])) lines.push(`| ${c} | ${n} |`);
@@ -549,17 +647,39 @@ function buildSummary(all) {
async function main() {
const args = parseArgs(process.argv);
console.log(`→ 审计主题:${args.theme}`);
// 性能断言门控:load ≥ 5 视为测量环境不可信(见 scoreRoute 注释)
PERF_LOAD_AT_START = os.loadavg()[0];
PERF_ASSERT_VALID = PERF_LOAD_AT_START < 5;
console.log(`→ 系统 load${PERF_LOAD_AT_START.toFixed(2)}LCP/CLS 断言${PERF_ASSERT_VALID ? '启用' : '跳过'}`);
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const outDir = path.join(ROOT, 'dogfood-ui-audit', stamp);
const outDir = path.join(ROOT, 'dogfood-ui-audit', args.theme === 'dark' ? `${stamp}-dark` : stamp);
fs.mkdirSync(outDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
// --no-proxy-server:本机有系统代理,Chromium 默认继承会把 localhost 请求送进隧道导致 chunk 403
const browser = await chromium.launch({ headless: true, args: ['--no-proxy-server'] });
const context = await browser.newContext({
viewport: VIEWPORTS.desktop,
deviceScaleFactor: 1,
ignoreHTTPSErrors: true,
// 主题由 colorScheme 驱动(走 system 档解析),不要事后改 data-theme
colorScheme: args.theme === 'dark' ? 'dark' : 'light',
// 不加载已有登录态:审计的是访客视角
});
// 第三方统计噪音根治:mock 掉 GA/gtm 请求。沙箱无外网时 beacon 必然失败(console 错误 +
// failedRequests),但与 UI 审计无关。站点 gtag() 为内联定义(仅 push dataLayer),
// mock 空 JS 不会引发未捕获异常;collect 类 beacon 返回 204。
await context.route(
/google-analytics\.com|googletagmanager\.com|google\.com\/g\/collect|doubleclick\.net/i,
route => {
const url = route.request().url();
if (/\/gtag\/js/.test(url)) {
route.fulfill({ status: 200, contentType: 'application/javascript', body: '/* ga-mock */' });
} else {
route.fulfill({ status: 204, body: '' });
}
}
);
// 注入 LCP / CLS 观察者
await context.addInitScript(() => {
window.__audit = { lcp: 0, cls: 0 };
@@ -576,6 +696,11 @@ async function main() {
if (!routes) {
console.log('→ 发现路由中...');
routes = await discoverRoutes(page);
const standalone = discoverStandaloneSlugs().map((s) => STANDALONE_ROUTE_PREFIX + s);
if (standalone.length) {
console.log(`→ 从构建产物发现独立产品页 ${standalone.length} 个:\n ${standalone.join('\n ')}`);
routes.push(...standalone);
}
}
if (args.limit) routes = routes.slice(0, args.limit);
console.log(`→ 共 ${routes.length} 个路由:\n ${routes.join('\n ')}\n`);