// R59 关键信息卡(白底版)真实渲染验证:拆解 tab 默认卡视图 // 断言:① 卡片 SVG 渲染且含「内容结构」整卡(板块小节齐全)② 卡内首行文字不贴顶(gap≥20) // ③ 文本不越出 viewBox ④ 深浅两系均出卡;并截图存档 const CDP = 9223; const list = await fetch(`http://localhost:${CDP}/json`).then((r) => r.json()); const page = list.find((t) => t.type === 'page' && (t.url || '').includes('8900')) || list.find((t) => t.type === 'page'); if (!page) { console.log('ERR: no page'); process.exit(1); } const ws = new WebSocket(page.webSocketDebuggerUrl); let id = 0; const pending = new Map(); const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } }; await new Promise((r) => (ws.onopen = r)); const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); await send('Page.enable'); await send('Runtime.enable'); await send('Emulation.setDeviceMetricsOverride', { width: 860, height: 1000, deviceScaleFactor: 1, mobile: false }); const waitFor = async (expr, ms = 15000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; }; const fail = (m) => { console.log('FAIL@' + m); process.exit(1); }; const fs = await import('node:fs'); const AUDIT = `(() => { const svg = document.querySelector('.vk-view svg'); if (!svg) return { err: 'no svg in .vk-view' }; const vbH = svg.viewBox.baseVal.height; const rects = [...svg.querySelectorAll('rect')].map((r) => ({ x: Math.round(r.x.baseVal.value * 10) / 10, y: Math.round(r.y.baseVal.value * 10) / 10, w: Math.round(r.width.baseVal.value), h: Math.round(r.height.baseVal.value), fill: r.getAttribute('fill') })); const texts = [...svg.querySelectorAll('text')].map((t) => ({ x: +t.getAttribute('x'), y: +t.getAttribute('y'), size: +t.getAttribute('font-size'), wt: t.getAttribute('font-weight'), txt: (t.textContent || '').slice(0, 42) })); const titles = texts.filter((t) => t.txt.startsWith('▍')).map((t) => t.txt.slice(1)); const cards = rects.filter((r) => (r.fill || '').startsWith('#F') && r.w > 500 && r.h > 40); /* 整宽 tint 内容卡 */ const audits = cards.map((r) => { const inT = texts.filter((t) => t.y > r.y + 2 && t.y < r.y + r.h && t.x >= r.x - 1 && t.x <= r.x + r.w + 1).sort((a, b) => a.y - b.y); const first = inT[0]; return { cardTop: r.y, h: r.h, firstTxt: first ? first.txt : '', gap: first ? Math.round((first.y - r.y) * 10) / 10 : null }; }); const maxTextY = Math.max(...texts.map((t) => t.y)); /* 内容结构整卡内应出现的板块名(struct 板块头 text 加粗、位于该卡内且不重复) */ const structCard = cards[1]; /* 第二张卡(内容定位卡之后)可能为内容结构;改用标题定位 */ const titleYs = texts.filter((t) => t.txt.startsWith('▍')).map((t) => ({ t: t.txt.slice(1), y: t.y })); const cardByTitle = {}; cards.forEach((r) => { const t = titleYs.find((x) => x.y < r.y && r.y - x.y < 60); if (t) cardByTitle[t.t] = r; }); const secCard = cardByTitle['内容结构']; const secBoard = secCard ? texts.filter((t) => t.y > secCard.y && t.y < secCard.y + secCard.h && (t.wt || '') === '700' && t.x > secCard.x && t.x < secCard.x + 120).map((t) => t.txt) : []; const bgStops = [...svg.querySelectorAll('#bgGrad stop')].map((s) => s.getAttribute('stop-color')); return { vbH, titles, nCards: cards.length, audits, cardByTitle: Object.keys(cardByTitle), secBoard, maxTextY, over: maxTextY > vbH - 4, bgStops }; })()`; const VIDS = [93, 190]; const out = {}; for (const id of VIDS) { await send('Page.navigate', { url: `http://127.0.0.1:8900/#/video/${id}` }); /* 等路由渲染完成:hash 正确 + 视频信息卡渲染(旧 DOM 残留会误判,需等新内容) */ if (!(await waitFor(`location.hash === '#/video/${id}' && !!document.querySelector('#view .video-grid')`, 15000))) fail(`v${id} route`); await sleep(400); await ev(`[...document.querySelectorAll('#view .tabs .tab')].find((t) => t.textContent.trim() === '视频拆解').click()`); const hasCard = await waitFor(`!!document.querySelector('#view .vk-view svg')`, 12000); if (!hasCard) { out['v' + id] = { __err: 'no keycard svg (纯 md fallback?)', hasMd: await ev(`!!document.querySelector('.vk-view .md-body, .vk-text')`) }; continue; } await sleep(500); const audit = await ev(AUDIT); out['v' + id] = audit; /* 截图:滚动 svg 到顶部后按元素区域截图 */ await ev(`document.querySelector('.vk-view svg').scrollIntoView({ block: 'start' })`); await sleep(250); const rect = await ev(`(() => { const r = document.querySelector('.vk-view svg').getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })()`); if (rect && !rect.__err && rect.w > 100) { const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: Math.max(0, rect.x - 4), y: Math.max(0, rect.y - 4), width: Math.min(rect.w + 8, 1200), height: Math.min(rect.h + 8, 4000), scale: 1 } }); fs.writeFileSync(`tmp/r59_card_v${id}.png`, Buffer.from(shot.data, 'base64')); out['v' + id].shot = `tmp/r59_card_v${id}.png`; } } console.log(JSON.stringify(out, null, 1)); const v93 = out.v93, v190 = out.v190; const ok = v93 && !v93.__err && v190 && !v190.__err && v93.bgStops && v93.bgStops.length === 2 && v93.bgStops.every((c) => c === '#FFFFFF') && v190.bgStops && v190.bgStops.length === 2 && v190.bgStops.every((c) => c === '#FFFFFF') && v93.titles.includes('内容结构') && v190.titles.includes('内容结构') && v93.secBoard && v93.secBoard.some((b) => b.includes('开场钩子')) && v93.secBoard.some((b) => b.includes('中段叙事')) && v93.audits.every((a) => a.gap >= 20) && v190.audits.every((a) => a.gap >= 20) && !v93.over && !v190.over; console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ==='); ws.close(); process.exit(ok ? 0 : 1);