Files
blog/blog-admin/tools/measure-badges.mjs
T

209 lines
8.4 KiB
JavaScript
Raw Normal View History

// 用本地 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();
}