#!/usr/bin/env node /** * scripts/verify-v14.mjs —— v14 验收(只量任务书「一」的四条;「二」是查证,见 docs/v14-report.md)。 * * node scripts/verify-v14.mjs --tag before 只打印、不判分 * node scripts/verify-v14.mjs 判分:任一项不达标 exit 1 * --json out.json * * 量什么(/pool 每张 kf-opportunity-card): * - 含「未填写」字样的元素数(跨 shadow,取文本节点所在元素) * - 卡高(host 高)、行内极差 * - 卡内最大连续空白:叶子文字矩形按 top 排,相邻两块竖直间隙的最大值(同 v13 算法) * - 有描述的卡 .one 文本仍在 */ import { writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const argv = process.argv.slice(2); const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d); const TAG = arg('--tag', 'after'); const JUDGE = TAG !== 'before'; const JSON_OUT = arg('--json', ''); if (!process.env.CHROME_PATH) { const pinned = '/opt/hermes/.playwright/chromium_headless_shell-1243/chrome-headless-shell-linux64/chrome-headless-shell'; const { existsSync } = await import('node:fs'); if (existsSync(pinned)) process.env.CHROME_PATH = pinned; } const { startServer, launch, DEEP } = await import('./v11b-lib.mjs'); const srv = await startServer(Number(process.env.VERIFY_PORT || 4195)); const browser = await launch(); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.goto(new URL('/pool', srv.base).href, { waitUntil: 'networkidle' }); await page.waitForFunction(() => { const a = document.querySelector('kf-app'); const v = a?.shadowRoot?.querySelector('view-pool'); return v && v.shadowRoot && v.shadowRoot.children.length > 0; }, null, { timeout: 15000 }); await page.waitForTimeout(400); const r = await page.evaluate(`(() => { ${DEEP} const root = appR().querySelector('view-pool').shadowRoot; const cards = [...root.querySelectorAll('kf-opportunity-card')].filter((c) => c.getBoundingClientRect().height > 0); const allTextEls = (node, acc = []) => { for (const el of node.querySelectorAll ? node.querySelectorAll('*') : []) { if ([...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.includes('未填写'))) acc.push(el); if (el.shadowRoot) allTextEls(el.shadowRoot, acc); } return acc; }; const info = cards.map((card) => { const sr = card.shadowRoot; const face = sr.querySelector('kf-glass-card')?.shadowRoot?.querySelector('.face') || sr.querySelector('.face'); const c = face || sr.querySelector('.wrap'); const one = sr.querySelector('.one'); const textAll = [...c.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())); const inner = textAll.filter((e) => !textAll.some((o) => o !== e && e.contains(o))); const boxes = inner.map((e) => { const rr = e.getBoundingClientRect(); return { top: Math.round(rr.top), bottom: Math.round(rr.bottom) }; }) .filter((b) => b.bottom > b.top).sort((a, b) => a.top - b.top); let maxGap = 0, gapAt = '', lo = null, hi = null; for (const b of boxes) { if (hi == null) { lo = b.top; hi = b.bottom; continue; } if (b.bottom <= lo) { const g = lo - b.bottom; if (g > maxGap) { maxGap = g; gapAt = b.bottom + '→' + lo; } } lo = Math.min(lo, b.top); hi = Math.max(hi, b.bottom); } return { title: (sr.querySelector('h3')?.textContent || '').trim().slice(0, 12), h: Math.round(card.getBoundingClientRect().height), top: Math.round(card.getBoundingClientRect().top), hasOne: !!one, oneTxt: one ? one.textContent.trim().slice(0, 20) : null, maxGap, gapAt }; }); const placeholderEls = allTextEls(root).length; return { n: cards.length, cards: info, placeholderEls }; })()`); const hs = r.cards.map((c) => c.h); const rowSpread = new Map(); for (const c of r.cards) { const k = c.top; rowSpread.set(k, [...(rowSpread.get(k) || []), c.h]); } let maxRowDiff = 0; for (const v of rowSpread.values()) maxRowDiff = Math.max(maxRowDiff, Math.max(...v) - Math.min(...v)); const withOne = r.cards.filter((c) => c.hasOne), withoutOne = r.cards.filter((c) => !c.hasOne); const maxGap = Math.max(0, ...r.cards.map((c) => c.maxGap)); console.log(`· /pool 卡片数 ${r.n};卡高 [${hs.join(',')}];行内极差 ${maxRowDiff}px;全部极差 ${Math.max(...hs) - Math.min(...hs)}px`); console.log(`· 含「未填写」元素数 ${r.placeholderEls}`); console.log(`· 有描述 ${withOne.length} 张(.one 文本:${withOne.map((c) => c.oneTxt).join(' | ') || '无'});无描述 ${withoutOne.length} 张 ${withoutOne.map((c) => c.title).join(',')}`); console.log(`· 卡内最大连续空白:${r.cards.map((c) => c.title + '=' + c.maxGap + (c.gapAt ? '@' + c.gapAt : '')).join(', ')}`); if (errors.length) console.log(`· 页面错误 ${errors.length}:${errors.slice(0, 3).join(' / ')}`); const ok = r.placeholderEls === 0 && maxRowDiff <= 2 && maxGap <= 24 && withOne.length + withoutOne.length === r.n && errors.length === 0; const out = { tag: TAG, at: new Date().toISOString(), ...r, maxRowDiff, maxGap, ok }; if (JSON_OUT) writeFileSync(JSON_OUT, JSON.stringify(out, null, 2)); console.log(JUDGE ? (ok ? '判分:4/4 达标' : `判分:不达标(placeholder=${r.placeholderEls} rowDiff=${maxRowDiff} maxGap=${maxGap})`) : '基线(不判分)'); await browser.close(); srv.kill(); process.exit(JUDGE && !ok ? 1 : 0);