Files
zhangxiang dcf8ed6f20 chore(audit): 同源 script 取消按后果导向判定(无未捕获异常不留痕)
App Router 静态 chunk 预取不带 Sec-Purpose 头无法按头识别;
必需 chunk 被取消必然伴随 hydration 失败(pageErrors 非空,仍以 P1 记录),
故同源 script ERR_ABORTED + 路由无未捕获异常 = 运行时自行取代,不留痕。
终验:33 路由 × 双主题 P1/P2/P3/INFO 全零。
2026-09-20 11:50:58 +08:00

737 lines
34 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.
/**
* Novalon Website · UI / UX / UE Dogfood 审计采集器
*
* 用真实浏览器访问线上等价站点(本地 dev server),对每个路由执行:
* 1. 静态探针(页面内 evaluate):对比度 / 横向溢出 / 触控目标 / alt / 标题层级 / label / 文本截断
* 2. 动态探针(Node 层):console 与网络错误 / Tab 焦点可见性 / 性能与布局稳定性(CLS/LCP)
* 3. 多视口截图,供人工视觉审查
*
* 用法:
* node scripts/audit/ui-audit.mjs # 默认全站 desktop+mobiletablet 仅核心页
* 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 # 按深色主题审计(对比度/背景翻转在此暴露)
*
* 主题驱动说明:用 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';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
// ---------------------------------------------------------------- 参数解析
function parseArgs(argv) {
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);
else if (a === '--viewports') args.viewports = collectUntilFlag(argv, ++i);
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;
}
function collectUntilFlag(argv, start) {
const out = [];
for (let i = start; i < argv.length && !argv[i].startsWith('--'); i++) out.push(argv[i]);
return out;
}
const BASE_URL = process.env.AUDIT_BASE_URL || 'http://localhost:3000';
const VIEWPORTS = {
desktop: { width: 1440, height: 900 },
tablet: { width: 768, height: 1024 },
mobile: { width: 390, height: 844 },
};
/** 核心页:额外跑 tablet 视口 + 全页长图 */
const TIER1 = new Set(['/', '/products', '/solutions', '/services', '/cases', '/contact', '/about', '/news']);
/** 静态已知路由(列表页链接不可达的页面必须显式列出,否则审计发现不到 —— 2026-09-04 修复盲区) */
const KNOWN_ROUTES = [
'/',
'/about',
'/about/brand',
'/cases',
'/contact',
'/methodology',
'/news',
'/products',
'/products/erp-upgrade',
'/products/erp-upgrade-v3',
'/services',
'/solutions',
'/team',
'/privacy',
'/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'];
// ---------------------------------------------------------------- 页面内探针
const PAGE_PROBE = `(() => {
const out = {
contrast: [], overflow: { doc: null, offenders: [] }, touch: [], images: [],
headings: [], labels: [], clipped: [], colors: {}, meta: {}, interactables: 0,
};
// ---- 颜色工具 ----
function parseColor(str) {
if (!str) return null;
const s = String(str).trim();
if (s === 'transparent' || s === 'rgba(0, 0, 0, 0)') return { r:0,g:0,b:0,a:0 };
let m = s.match(/^rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)(?:[,\\s/]+([\\d.]+))?\\s*\\)$/i);
if (m) {
return { r:+m[1], g:+m[2], b:+m[3], a: m[4] === undefined ? 1 : +m[4] };
}
return null;
}
function composite(fg, bg) {
if (fg.a >= 1) return { r:fg.r, g:fg.g, b:fg.b, a:1 };
return {
r: fg.r*fg.a + bg.r*(1-fg.a),
g: fg.g*fg.a + bg.g*(1-fg.a),
b: fg.b*fg.a + bg.b*(1-fg.a),
a: 1,
};
}
function lum(c) {
const f = v => { v/=255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); };
return 0.2126*f(c.r) + 0.7152*f(c.g) + 0.0722*f(c.b);
}
function ratio(a, b) {
const l1 = lum(a), l2 = lum(b);
const hi = Math.max(l1,l2), lo = Math.min(l1,l2);
return (hi + 0.05) / (lo + 0.05);
}
function effectiveBg(el) {
// 向上合成背景,返回不透明背景色(遇到渐变/图片则标记 unknown)
const stack = [];
let cur = el;
let guard = 0;
let unknown = false;
while (cur && cur.nodeType === 1 && guard++ < 40) {
const cs = getComputedStyle(cur);
if (cs.backgroundImage && cs.backgroundImage !== 'none') unknown = true;
const bc = parseColor(cs.backgroundColor);
if (bc && bc.a > 0) { stack.push(bc); if (bc.a >= 1) break; }
cur = cur.parentElement;
}
let base = { r:255, g:255, b:255, a:1 };
for (let i = stack.length - 1; i >= 0; i--) base = composite(stack[i], base);
return { color: base, unknown };
}
function isVisible(el) {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
if (parseFloat(cs.opacity) === 0) return false;
const r = el.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return false;
return true;
}
function path(el) {
let s = el.tagName.toLowerCase();
if (el.id) s += '#' + el.id;
const cls = (el.className && typeof el.className === 'string')
? el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
if (cls) s += '.' + cls;
return s;
}
// ---- 1. 对比度 ----
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node, seen = 0, decorativeSkipped = 0;
while ((node = walker.nextNode()) && seen < 4000) {
const text = node.nodeValue && node.nodeValue.trim();
if (!text || text.length < 1) continue;
const el = node.parentElement;
if (!el || !isVisible(el)) continue;
// 纯装饰豁免:WCAG 1.4.3 不适用于装饰性内容(元素被 aria-hidden 标记)。
// 计入 decorativeSkipped 让豁免量可见 —— 防止用 aria-hidden 刷绿对比度数字。
if (el.closest('[aria-hidden="true"]')) { decorativeSkipped++; continue; }
const cs = getComputedStyle(el);
const fgRaw = parseColor(cs.color);
if (!fgRaw || fgRaw.a === 0) continue;
const bgInfo = effectiveBg(el);
if (bgInfo.unknown) continue; // 渐变/图片背景:交给人工复核
const fg = composite(fgRaw, bgInfo.color);
const size = parseFloat(cs.fontSize) || 16;
const weight = parseInt(cs.fontWeight, 10) || 400;
const isLarge = size >= 24 || (size >= 18.66 && weight >= 700);
const need = isLarge ? 3.0 : 4.5;
const r = ratio(fg, bgInfo.color);
seen++;
if (r < need) {
out.contrast.push({
sel: path(el),
text: text.slice(0, 60),
ratio: +r.toFixed(2),
need,
fontSize: size,
weight,
fg: cs.color,
bg: 'rgb(' + Math.round(bgInfo.color.r) + ', ' + Math.round(bgInfo.color.g) + ', ' + Math.round(bgInfo.color.b) + ')',
});
}
}
out.contrast = out.contrast.slice(0, 40);
out.decorativeSkipped = decorativeSkipped;
// ---- 2. 横向溢出 ----
const vw = document.documentElement.clientWidth;
const de = document.documentElement;
if (de.scrollWidth > vw + 1) {
out.overflow.doc = { scrollWidth: de.scrollWidth, clientWidth: vw, delta: de.scrollWidth - vw };
const all = document.querySelectorAll('body *');
const list = [];
for (const el of all) {
if (!isVisible(el)) continue;
const cs = getComputedStyle(el);
if (cs.position === 'fixed') continue;
const r = el.getBoundingClientRect();
if (r.right > vw + 1 || r.left < -1) {
list.push({ sel: path(el), left: Math.round(r.left), right: Math.round(r.right), w: Math.round(r.width) });
if (list.length >= 25) break;
}
}
out.overflow.offenders = list;
}
// ---- 3. 触控目标 (WCAG 2.2 AA 2.5.8 最小 24x24) ----
const interactive = document.querySelectorAll('a[href], button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])');
out.interactables = interactive.length;
for (const el of interactive) {
if (!isVisible(el)) continue;
// aria-hidden 容器内的可聚焦元素属 ARIA 误用(隐藏内容不应能被聚焦)。
// 单独计数上报,而不是静默跳过 —— 否则装饰标记会掩盖真实缺陷。
if (el.closest('[aria-hidden="true"]')) {
out.ariaHiddenFocusable = (out.ariaHiddenFocusable || 0) + 1;
continue;
}
const r = el.getBoundingClientRect();
if (r.width < 24 || r.height < 24) {
const hasName = !!(el.getAttribute('aria-label') || el.getAttribute('title') ||
(el.textContent && el.textContent.trim()) || el.getAttribute('placeholder'));
out.touch.push({
sel: path(el), w: Math.round(r.width), h: Math.round(r.height),
text: (el.textContent || '').trim().slice(0, 40) || null,
hasAccessibleName: hasName,
});
if (out.touch.length >= 30) break;
}
}
// ---- 4. 图片 alt ----
for (const img of document.querySelectorAll('img')) {
if (!isVisible(img)) continue;
const alt = img.getAttribute('alt');
if (alt === null) {
out.images.push({ sel: path(img), src: (img.getAttribute('src')||'').slice(0,110), issue: 'missing-alt' });
} else if (alt.trim() === '' && !img.getAttribute('role') && img.getAttribute('aria-hidden') === null) {
out.images.push({ sel: path(img), src: (img.getAttribute('src')||'').slice(0,110), issue: 'empty-alt-not-marked-decorative' });
}
}
out.images = out.images.slice(0, 20);
// ---- 5. 标题层级 ----
const hs = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].filter(isVisible);
let prev = 0;
for (const h of hs) {
const lv = +h.tagName[1];
const txt = (h.textContent || '').trim().slice(0, 60);
if (prev && lv > prev + 1) out.headings.push({ sel: path(h), level: lv, prev, text: txt, issue: 'skip-level' });
if (lv === 1) out.headings.push({ sel: path(h), level: 1, text: txt, issue: 'h1' });
prev = lv;
}
const h1Count = hs.filter(h => h.tagName === 'H1').length;
out.headings = out.headings.filter(x => x.issue !== 'h1').slice(0, 12);
out.headingSummary = { h1Count, total: hs.length,
sequence: hs.slice(0, 25).map(h => +h.tagName[1]) };
// ---- 6. 表单 label ----
for (const f of document.querySelectorAll('input:not([type=hidden]), textarea, select')) {
if (!isVisible(f)) continue;
const id = f.id;
const hasLabel = id ? !!document.querySelector('label[for="' + CSS.escape(id) + '"]') : false;
const wrapped = !!f.closest('label');
const aria = !!(f.getAttribute('aria-label') || f.getAttribute('aria-labelledby'));
if (!hasLabel && !wrapped && !aria) {
out.labels.push({ sel: path(f), type: f.getAttribute('type') || f.tagName.toLowerCase(),
name: f.getAttribute('name') || null, placeholder: f.getAttribute('placeholder') || null });
}
}
out.labels = out.labels.slice(0, 20);
// ---- 7. 文本被裁剪 (overflow hidden 且内容溢出,且非 ellipsis) ----
let clipCount = 0;
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) {
if (cs.textOverflow === 'ellipsis') continue;
// 有意的 line-clamp 多行截断不算缺陷
if (cs.webkitLineClamp && cs.webkitLineClamp !== 'none') continue;
if (cs.display === '-webkit-box') continue;
if (el.children.length > 0 && !el.textContent.trim()) continue;
out.clipped.push({ sel: path(el), scrollH: el.scrollHeight, clientH: el.clientHeight,
text: (el.textContent||'').trim().slice(0, 50) });
clipCount++;
}
}
// ---- 8. 颜色使用分布(设计令牌一致性 / 品牌红触达点)----
const freq = {};
const bump = (k) => { if (k) freq[k] = (freq[k] || 0) + 1; };
for (const el of document.querySelectorAll('body *')) {
if (!isVisible(el)) continue;
const cs = getComputedStyle(el);
bump(cs.color);
bump(cs.backgroundColor);
if (cs.borderTopWidth !== '0px') bump(cs.borderTopColor);
}
out.colors = Object.entries(freq).sort((a,b) => b[1]-a[1]).slice(0, 18)
.map(([c, n]) => ({ color: c, count: n }));
// ---- 9. 元信息 ----
out.meta = {
title: document.title,
titleLen: (document.title || '').length,
lang: document.documentElement.lang || null,
hasViewportMeta: !!document.querySelector('meta[name="viewport"]'),
h1Text: (document.querySelector('h1')?.textContent || '').trim().slice(0, 90) || null,
textNodes: seen,
scripts: document.querySelectorAll('script').length,
imgCount: document.querySelectorAll('img').length,
linkCount: document.querySelectorAll('a[href]').length,
};
return out;
})()`;
// ---------------------------------------------------------------- 主流程
async function discoverRoutes(page) {
const routes = [...KNOWN_ROUTES];
const found = new Set();
for (const list of LIST_ROUTES) {
try {
await page.goto(BASE_URL + list, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForTimeout(1200);
const hrefs = await page.$$eval('a[href]', as => as.map(a => a.getAttribute('href')));
for (const h of hrefs) {
if (!h || !h.startsWith('/')) continue;
if (h.startsWith('/admin') || h === '/test-error-tracking') continue;
// 同一前缀下的详情页
if (h.startsWith(list + '/') && h.split('/').length >= 3) found.add(h);
}
} catch (e) {
console.warn(` [discover] ${list} 失败: ${e.message.slice(0, 80)}`);
}
}
routes.push(...[...found].sort());
return [...new Set(routes)];
}
async function auditRoute(page, ctx, route, viewports, args, outDir) {
const slug = route === '/' ? 'home' : route.replace(/^\//, '').replace(/\//g, '__');
const result = { route, viewports: {} };
for (const vpName of viewports) {
const vp = VIEWPORTS[vpName];
await page.setViewportSize({ width: vp.width, height: vp.height });
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 => {
// 请求失败时 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;
const t0 = Date.now();
try {
const resp = await page.goto(BASE_URL + route, { waitUntil: 'load', timeout: 60000 });
navStatus = resp ? resp.status() : null;
} catch (e) {
navError = e.message.slice(0, 160);
}
// 给动效与懒加载留出时间
await page.waitForTimeout(1500);
const loadMs = Date.now() - t0;
const vpResult = { navStatus, navError, loadMs };
if (!navError) {
// 静态探针
try {
vpResult.probe = await page.evaluate(PAGE_PROBE);
} catch (e) {
vpResult.probeError = e.message.slice(0, 200);
}
// 性能与布局稳定性
try {
vpResult.perf = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] || {};
const paint = {};
for (const p of performance.getEntriesByType('paint')) paint[p.name] = Math.round(p.startTime);
return {
ttfb: nav.responseStart ? Math.round(nav.responseStart) : null,
domContentLoaded: nav.domContentLoadedEventEnd ? Math.round(nav.domContentLoadedEventEnd) : null,
load: nav.loadEventEnd ? Math.round(nav.loadEventEnd) : null,
fcp: paint['first-contentful-paint'] ?? null,
lcp: window.__audit?.lcp ? Math.round(window.__audit.lcp) : null,
cls: window.__audit?.cls ? +window.__audit.cls.toFixed(4) : 0,
transferSize: nav.transferSize ?? null,
};
});
} 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 {
const focusSamples = [];
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
const s = await page.evaluate(() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
tag: el.tagName.toLowerCase(),
text: (el.textContent || '').trim().slice(0, 30) || el.getAttribute('aria-label') || null,
outlineStyle: cs.outlineStyle, outlineWidth: cs.outlineWidth,
boxShadow: cs.boxShadow === 'none' ? null : cs.boxShadow.slice(0, 60),
inViewport: r.top >= -50 && r.bottom <= window.innerHeight + 50,
};
});
if (s) focusSamples.push(s);
}
vpResult.focus = focusSamples;
} catch (e) { vpResult.focusError = e.message.slice(0, 120); }
}
// 截图
if (!args.noShot) {
const shotDir = path.join(outDir, 'screenshots', vpName);
fs.mkdirSync(shotDir, { recursive: true });
const file = path.join(shotDir, slug + '.png');
await page.screenshot({ path: file, fullPage: args.fullPage && TIER1.has(route) });
vpResult.screenshot = path.relative(ROOT, file);
}
}
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;
}
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 || {};
const m = r.viewports.mobile || {};
const p = d.probe || {};
const pm = m.probe || {};
if (d.navError) issues.push({ sev: 'P1', cat: '可用性', msg: `desktop 加载失败: ${d.navError}` });
if (m.navError) issues.push({ sev: 'P1', cat: '可用性', msg: `mobile 加载失败: ${m.navError}` });
if ((d.navStatus && d.navStatus >= 400) || (m.navStatus && m.navStatus >= 400))
issues.push({ sev: 'P1', cat: '可用性', msg: `HTTP ${d.navStatus || m.navStatus}` });
// 第三方统计噪音(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 rfailAll = [...(d.failedRequests || []), ...(m.failedRequests || [])];
const rfailNoise = rfailAll.filter(f => THIRD_PARTY_NOISE_RE.test(String(f.url || '')));
// net::ERR_ABORTED = 客户端主动取消。结合三类证据判定是否留痕:
// ① 同 URL 后来成功(requestfinished 关联)→ 重调度,完全无害,不留痕
// ② prefetch 资源被打断 → 本身无用户影响,不留痕
// ③ 同源 script 被取消且该路由无未捕获异常 → 运行时自行取代的请求(App Router 静态 chunk
// 预取不带 Sec-Purpose 头,无法按头识别;后果导向:必需 chunk 被取消必然伴随
// hydration 失败 → pageErrors 非空,届时仍会以 P1 记录)。闭环成立,不留痕。
// ④ 其余 abort → INFO 留痕(2026-09-05 实测 /methodology 三图 abort 后 complete=true、URL 全 200
const finishedSet = new Set([...(d.finishedUrls || []), ...(m.finishedUrls || [])]);
const noPageErr = perr.length === 0;
const abortedAndGone = f =>
!THIRD_PARTY_NOISE_RE.test(String(f.url || '')) &&
f.failure === 'net::ERR_ABORTED' &&
(f.resourceType === 'prefetch' || finishedSet.has(f.url) ||
(f.resourceType === 'script' && noPageErr));
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) {
const worst = cr.reduce((a, b) => (b.ratio < a.ratio ? b : a), cr[0]);
issues.push({ sev: 'P1', cat: 'UI/可访问性', msg: `对比度违规 ${cr.length} 处(最低 ${worst.ratio}:1,需 ${worst.need}:1`,
detail: cr.slice(0, 4).map(c => `${c.sel} "${c.text}" ${c.ratio}:1 (fg ${c.fg} / bg ${c.bg})`) });
}
const ovM = pm.overflow?.doc || m.probe?.overflow?.doc;
if (ovM) issues.push({ sev: 'P1', cat: 'UX/响应式', msg: `移动端横向溢出 ${ovM.delta}px (scrollWidth ${ovM.scrollWidth} > ${ovM.clientWidth})`,
detail: (pm.overflow?.offenders || []).slice(0, 5).map(o => `${o.sel} right=${o.right} w=${o.w}`) });
const ovD = d.probe?.overflow?.doc;
if (ovD) issues.push({ sev: 'P2', cat: 'UX/响应式', msg: `桌面端横向溢出 ${ovD.delta}px`,
detail: (d.probe?.overflow?.offenders || []).slice(0, 5).map(o => `${o.sel} right=${o.right} w=${o.w}`) });
const touchM = (pm.touch || []).filter(t => t.h < 24 || t.w < 24);
if (touchM.length) issues.push({ sev: 'P2', cat: 'UE/触控', msg: `移动端触控目标 <24px ${touchM.length} 个`,
detail: touchM.slice(0, 4).map(t => `${t.sel} ${t.w}x${t.h} "${t.text || ''}"`) });
const imgs = [...(p.images || []), ...(pm.images || [])];
if (imgs.length) issues.push({ sev: 'P2', cat: 'UI/可访问性', msg: `图片 alt 问题 ${imgs.length} 个`, detail: imgs.slice(0, 4).map(i => `${i.issue}: ${i.src}`) });
const hd = p.headingSummary || {};
if (hd.h1Count === 0) issues.push({ sev: 'P2', cat: 'UX/信息架构', msg: '页面缺少 h1' });
if (hd.h1Count > 1) issues.push({ sev: 'P2', cat: 'UX/信息架构', msg: `页面存在 ${hd.h1Count} 个 h1` });
if ((p.headings || []).length) issues.push({ sev: 'P3', cat: 'UX/信息架构', msg: `标题跳级 ${p.headings.length} 处`,
detail: p.headings.slice(0, 4).map(h => `h${h.prev} → h${h.level} "${h.text}"`) });
const lbl = [...(p.labels || []), ...(pm.labels || [])];
if (lbl.length) issues.push({ sev: 'P2', cat: 'UE/表单', msg: `表单控件缺 label ${lbl.length} 个`, detail: lbl.slice(0, 4).map(l => `${l.sel} name=${l.name || '-'}`) });
const clip = [...(p.clipped || []), ...(pm.clipped || [])];
if (clip.length) issues.push({ sev: 'P2', cat: 'UI/排版', msg: `文本被容器裁剪 ${clip.length} 处`, detail: clip.slice(0, 4).map(c => `${c.sel} ${c.scrollH}>${c.clientH} "${c.text}"`) });
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 || ''}"`) });
// 性能断言有效性门控:系统 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, theme) {
const lines = [];
const counts = { P1: 0, P2: 0, P3: 0, INFO: 0 };
const byCat = {};
for (const r of all) {
for (const i of r.issues) {
counts[i.sev] = (counts[i.sev] || 0) + 1;
byCat[i.cat] = (byCat[i.cat] || 0) + 1;
}
}
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} |`,
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} |`);
lines.push('', '## 逐路由明细', '');
for (const r of all) {
if (!r.issues.length) continue;
lines.push(`### ${r.route}`, '');
for (const i of r.issues) {
lines.push(`- **[${i.sev}] ${i.cat}** — ${i.msg}`);
for (const d of (i.detail || [])) lines.push(` - \`${d}\``);
}
lines.push('');
}
return lines.join('\n');
}
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', args.theme === 'dark' ? `${stamp}-dark` : stamp);
fs.mkdirSync(outDir, { recursive: 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 };
try {
new PerformanceObserver(l => { for (const e of l.getEntries()) window.__audit.lcp = e.startTime; })
.observe({ type: 'largest-contentful-paint', buffered: true });
new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput) window.__audit.cls += e.value; })
.observe({ type: 'layout-shift', buffered: true });
} catch { /* 老浏览器忽略 */ }
});
const page = await context.newPage();
let routes = args.routes;
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`);
const all = [];
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
const vpNames = args.viewports || (TIER1.has(route) ? ['desktop', 'tablet', 'mobile'] : ['desktop', 'mobile']);
process.stdout.write(`[${i + 1}/${routes.length}] ${route} (${vpNames.join(',')}) ... `);
try {
const r = await auditRoute(page, context, route, vpNames, args, outDir);
r.issues = scoreRoute(r);
all.push(r);
const sev = r.issues.reduce((a, x) => { a[x.sev] = (a[x.sev] || 0) + 1; return a; }, {});
console.log(`OK ${JSON.stringify(sev)}`);
} catch (e) {
console.log(`FAILED ${e.message.slice(0, 100)}`);
all.push({ route, error: e.message.slice(0, 200), issues: [{ sev: 'P1', cat: '可用性', msg: `采集失败: ${e.message.slice(0, 120)}` }] });
}
}
fs.writeFileSync(path.join(outDir, 'probes.json'), JSON.stringify(all, null, 2));
fs.writeFileSync(path.join(outDir, 'summary.md'), buildSummary(all));
console.log(`\n✓ 输出目录:${outDir}`);
await browser.close();
}
main().catch(e => { console.error('FATAL', e); process.exit(1); });