// 用本地 Chromium 无头渲染评论页,精确测量各徽章的盒子尺寸(CDP,无需第三方依赖) import { spawn } from 'node:child_process'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe'; const PORT = 9333; const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html'; const userDir = mkdtempSync(join(tmpdir(), 'cdp-badge-')); const chrome = spawn(CHROME, [ '--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`, '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--window-size=1280,900', 'about:blank', ], { stdio: 'ignore' }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function waitPort() { for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) return await r.json(); } catch { /* not ready */ } await sleep(300); } throw new Error('chrome 调试端口未就绪'); } function cdp(wsUrl) { return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl); let id = 0; const pending = new Map(); ws.addEventListener('message', (ev) => { const msg = JSON.parse(ev.data); if (msg.id && pending.has(msg.id)) { const { res, rej } = pending.get(msg.id); pending.delete(msg.id); msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result); } }); ws.addEventListener('error', reject); ws.addEventListener('open', () => { resolve({ send(method, params = {}) { return new Promise((res, rej) => { const mid = ++id; pending.set(mid, { res, rej }); ws.send(JSON.stringify({ id: mid, method, params })); }); }, close: () => ws.close(), }); }); }); } const MEASURE = `(() => { const px = (n) => Math.round(n * 100) / 100; const list = document.querySelector('.atk-list'); if (list) list.scrollIntoView({ block: 'start' }); const info = (el) => { if (!el) return null; const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return { text: (el.textContent || '').trim(), cls: el.className.replace(' atk-cmt-badge', ''), offW: el.offsetWidth, offH: el.offsetHeight, w: px(r.width), h: px(r.height), top: px(r.top), fontSize: cs.fontSize, lineHeight: cs.lineHeight, fontWeight: cs.fontWeight, padding: cs.padding, border: cs.borderTopWidth, display: cs.display, marginLeft: cs.marginLeft, marginRight: cs.marginRight, parent: el.parentElement ? el.parentElement.className : null, }; }; const heads = [...document.querySelectorAll('.atk-header')]; const out = { headerCount: heads.length, samples: [] }; heads.slice(0, 4).forEach((h, i) => { const badges = [...h.querySelectorAll('.atk-badge, .atk-pinned-badge, .atk-title-badge, .atk-rank-badge, .atk-link-badge, .atk-feed-badge, .atk-region-badge')]; if (!badges.length) return; out.samples.push({ i, hidden: !!h.closest('[style*="display: none"]'), innerWidth: window.innerWidth, badges: badges.map(info), }); }); return JSON.stringify(out, null, 1); })()`; try { await waitPort(); // 新建标签页 const created = await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' }); const tab = await created.json(); const c = await cdp(tab.webSocketDebuggerUrl); await c.send('Runtime.enable'); await c.send('Page.enable'); await sleep(6000); // 等评论异步加载 // 强制日间模式并把评论区滚到视口 await c.send('Runtime.evaluate', { expression: `(() => { document.documentElement.setAttribute('data-theme', 'light'); const el = document.querySelector('.atk-comment .atk-header') || document.querySelector('.atk-list'); if (el) el.scrollIntoView({ block: 'center' }); return 'ok'; })()`, returnByValue: true, }); await sleep(1200); const res = await c.send('Runtime.evaluate', { expression: MEASURE, returnByValue: true, awaitPromise: false }); console.log(res.result.value); const shot = await c.send('Page.captureScreenshot', { format: 'png' }); const { writeFileSync } = await import('node:fs'); writeFileSync(process.argv[3] || 'badge-measure.png', Buffer.from(shot.data, 'base64')); // 额外抓一张"徽章行"特写(放大 3 倍):先滚动,等布局稳定再取 rect await c.send('Runtime.evaluate', { expression: `(() => { const hs = [...document.querySelectorAll('.atk-header')]; const h = hs.find((x) => x.querySelector('.atk-title-badge') || x.querySelector('.atk-link-badge')) || hs[0]; if (h) h.scrollIntoView({ block: 'center' }); return 'ok'; })()`, returnByValue: true, }); await sleep(600); const rectRes = await c.send('Runtime.evaluate', { expression: `(() => { const hs = [...document.querySelectorAll('.atk-header')]; const h = hs.find((x) => x.querySelector('.atk-title-badge') || x.querySelector('.atk-link-badge')) || hs[0]; if (!h) return 'null'; const r = h.getBoundingClientRect(); return JSON.stringify({ x: Math.max(0, r.x - 8), y: Math.max(0, r.y - 8), width: Math.min(window.innerWidth - Math.max(0, r.x - 8), r.width + 16), height: r.height + 16 }); })()`, returnByValue: true, }); if (rectRes.result.value !== 'null') { const clip = JSON.parse(rectRes.result.value); const shot2 = await c.send('Page.captureScreenshot', { format: 'png', clip: { ...clip, scale: 3 } }); writeFileSync(String(process.argv[3] || 'badge-measure.png').replace(/\.png$/, '.row.png'), Buffer.from(shot2.data, 'base64')); } // 聚焦某个选择器截图(第 4 个参数,例如 .atk-fl-apply),并把它的几何也打印出来 const focusSel = process.argv[4]; if (focusSel) { const fRes = await c.send('Runtime.evaluate', { expression: `(() => { const el = document.querySelector(${JSON.stringify(focusSel)}); if (!el) return 'null'; el.scrollIntoView({ block: 'center' }); return 'ok'; })()`, returnByValue: true, }); if (fRes.result.value === 'ok') { await sleep(700); const gRes = await c.send('Runtime.evaluate', { expression: `(() => { const el = document.querySelector(${JSON.stringify(focusSel)}); const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); const chain = (node) => { const out = []; let n = node; while (n && n !== document.body) { const s = getComputedStyle(n); const b = n.getBoundingClientRect(); out.push({ tag: n.tagName.toLowerCase() + '.' + (n.className || '').toString().split(' ').slice(0, 2).join('.'), x: Math.round(b.x), w: Math.round(b.width), ml: s.marginLeft, mr: s.marginRight, pl: s.paddingLeft, pr: s.paddingRight, bl: s.borderLeftWidth, disp: s.display, style: n.getAttribute('style') || '', }); n = n.parentElement; } return out; }; const other = [...document.querySelectorAll('.atk-content')].find((x) => !x.closest('.atk-fl-apply')); const normal = [...document.querySelectorAll('.atk-comment-wrap, .atk-comment')].find((x) => !x.querySelector('.atk-fl-apply')); return JSON.stringify({ card: { x: Math.round(r.x), w: Math.round(r.width), h: Math.round(r.height) }, cardStyle: { margin: cs.margin, padding: cs.padding, width: cs.width }, cardChain: chain(el), otherContent: other ? { x: Math.round(other.getBoundingClientRect().x), w: Math.round(other.getBoundingClientRect().width) } : null, otherChain: other ? chain(other) : null, normalCommentChain: normal ? chain(normal) : null, }, null, 1); })()`, returnByValue: true, }); console.log('FOCUS', gRes.result.value); const shotF = await c.send('Page.captureScreenshot', { format: 'png' }); writeFileSync(String(process.argv[3] || 'badge-measure.png').replace(/\.png$/, '.focus.png'), Buffer.from(shotF.data, 'base64')); } } c.close(); } catch (e) { console.error('ERROR', e.message); } finally { chrome.kill(); }