@@ -0,0 +1,606 @@
/**
* 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+mobile, tablet 仅核心页
* node scripts/audit/ui-audit.mjs --routes / /contact # 指定路由
* node scripts/audit/ui-audit.mjs --viewports desktop # 指定视口
* node scripts/audit/ui-audit.mjs --no-shot # 只跑探针不截图
*
* 输出:dogfood-ui-audit/<timestamp>/{screenshots,probes.json,summary.md}
*/
import { chromium } from 'playwright' ;
import fs from 'node:fs' ;
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 } ;
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 ;
}
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' ] ) ;
/** 静态已知路由 */
const KNOWN _ROUTES = [
'/' ,
'/about' ,
'/about/brand' ,
'/cases' ,
'/contact' ,
'/methodology' ,
'/news' ,
'/products' ,
'/services' ,
'/solutions' ,
'/team' ,
'/privacy' ,
'/terms' ,
] ;
/** 列表页 → 用于发现动态详情路由 */
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;
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 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 ,
} ) ;
page . on ( 'console' , onConsole ) ;
page . on ( 'pageerror' , onPageErr ) ;
page . on ( 'requestfailed' , onReqFail ) ;
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 */ }
// 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 ) ;
page . off ( 'console' , onConsole ) ;
page . off ( 'pageerror' , onPageErr ) ;
page . off ( 'requestfailed' , onReqFail ) ;
result . viewports [ vpName ] = vpResult ;
}
return result ;
}
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 } ` } ) ;
const cerr = [ ... ( d . consoleErrors || [ ] ) , ... ( m . consoleErrors || [ ] ) ] ;
if ( cerr . length ) issues . push ( { sev : 'P2' , cat : 'UE/稳定性' , msg : ` console 错误 ${ cerr . length } 条 ` , detail : cerr . slice ( 0 , 3 ) } ) ;
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 || [ ] ) ] ;
if ( rfail . length ) issues . push ( { sev : 'P2' , cat : 'UE/稳定性' , msg : ` 请求失败 ${ rfail . length } 条 ` , detail : rfail . slice ( 0 , 3 ) } ) ;
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 || '' } " ` ) } ) ;
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 ) {
const lines = [ ] ;
const counts = { P1 : 0 , P2 : 0 , P3 : 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 审计 · 自动探针汇总' , '' ) ;
lines . push ( ` 生成时间: ${ new Date ( ) . toISOString ( ) } ` ) ;
lines . push ( ` 审计路由: ${ all . length } 个 ` , '' ) ;
lines . push ( '## 问题计数' , '' ) ;
lines . push ( '| 等级 | 数量 |' , '|---|:---:|' ) ;
lines . push ( ` | P1 | ${ counts . P1 } | ` , ` | P2 | ${ counts . P2 } | ` , ` | P3 | ${ counts . P3 } | ` , '' ) ;
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 ) ;
const stamp = new Date ( ) . toISOString ( ) . replace ( /[:.]/g , '-' ) . slice ( 0 , 19 ) ;
const outDir = path . join ( ROOT , 'dogfood-ui-audit' , stamp ) ;
fs . mkdirSync ( outDir , { recursive : true } ) ;
const browser = await chromium . launch ( { headless : true } ) ;
const context = await browser . newContext ( {
viewport : VIEWPORTS . desktop ,
deviceScaleFactor : 1 ,
ignoreHTTPSErrors : true ,
// 不加载已有登录态:审计的是访客视角
} ) ;
// 注入 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 ) ;
}
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 ) ; } ) ;