253 lines
8.7 KiB
JavaScript
253 lines
8.7 KiB
JavaScript
// headless_probe.js —— 无头 Chrome/Chromium 页面探针
|
|
//
|
|
// 用途:加载页面,抓取 DOM / 关键节点 rect / computed style / 截图。
|
|
// 只用于导出 fixture 和验收,不作为应用运行时依赖。
|
|
//
|
|
// 用法:
|
|
// node 3DTank/tools/headless_probe.js --url http://localhost:8080/ --out out/head-home
|
|
// node 3DTank/tools/headless_probe.js --url ... --eval-file probe.js --width 1296 --height 1006
|
|
//
|
|
// 默认 Chrome 路径:<repo>/chrome-win64/chrome.exe
|
|
// 可用环境变量 CHROME_PATH 覆盖。
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const http = require('http');
|
|
const { spawn } = require('child_process');
|
|
const WebSocket = require(path.join(__dirname, 'node_modules', 'ws'));
|
|
|
|
function arg(name, fallback) {
|
|
const i = process.argv.indexOf('--' + name);
|
|
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
|
}
|
|
function has(name) {
|
|
return process.argv.includes('--' + name);
|
|
}
|
|
|
|
const Url = arg('url', 'http://localhost:8080/');
|
|
const OutDir = path.resolve(arg('out', path.join(os.tmpdir(), 'tankic-probe')));
|
|
const Width = parseInt(arg('width', '1296'), 10);
|
|
const Height = parseInt(arg('height', '1006'), 10);
|
|
const WaitMs = parseInt(arg('wait', '8000'), 10);
|
|
const EvalFile = arg('eval-file', '');
|
|
const ShotName = arg('shot', 'page.png');
|
|
const Cookie = process.env.PROBE_COOKIE || arg('cookie', '');
|
|
const CookieDomain = arg('cookie-domain', '.3dtank.com');
|
|
const ChromePath =
|
|
process.env.CHROME_PATH || path.join(__dirname, '..', '..', 'chrome-win64', 'chrome.exe');
|
|
|
|
fs.mkdirSync(OutDir, { recursive: true });
|
|
|
|
function sleep(ms) {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
function httpJson(url) {
|
|
return new Promise((resolve, reject) => {
|
|
http
|
|
.get(url, (res) => {
|
|
let d = '';
|
|
res.on('data', (c) => (d += c));
|
|
res.on('end', () => {
|
|
try {
|
|
resolve(JSON.parse(d));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
})
|
|
.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function waitForDevTools(profileDir, timeoutMs = 20000) {
|
|
const file = path.join(profileDir, 'DevToolsActivePort');
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
if (fs.existsSync(file)) {
|
|
const [port, wsPath] = fs.readFileSync(file, 'utf8').split(/\r?\n/);
|
|
if (port) return { port: parseInt(port, 10), wsPath };
|
|
}
|
|
await sleep(150);
|
|
}
|
|
throw new Error('Chrome DevToolsActivePort not found');
|
|
}
|
|
|
|
class Cdp {
|
|
constructor(wsUrl) {
|
|
this.ws = new WebSocket(wsUrl);
|
|
this.id = 0;
|
|
this.pending = new Map();
|
|
this.events = new Map();
|
|
}
|
|
open() {
|
|
return new Promise((resolve, reject) => {
|
|
this.ws.on('open', resolve);
|
|
this.ws.on('error', reject);
|
|
this.ws.on('message', (raw) => {
|
|
const msg = JSON.parse(raw.toString());
|
|
if (msg.id && this.pending.has(msg.id)) {
|
|
const { resolve, reject } = this.pending.get(msg.id);
|
|
this.pending.delete(msg.id);
|
|
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
|
|
} else if (msg.method && this.events.has(msg.method)) {
|
|
for (const fn of this.events.get(msg.method)) fn(msg.params, msg.sessionId);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
send(method, params = {}, sessionId) {
|
|
const id = ++this.id;
|
|
const payload = { id, method, params };
|
|
if (sessionId) payload.sessionId = sessionId;
|
|
return new Promise((resolve, reject) => {
|
|
this.pending.set(id, { resolve, reject });
|
|
this.ws.send(JSON.stringify(payload));
|
|
});
|
|
}
|
|
on(method, fn) {
|
|
if (!this.events.has(method)) this.events.set(method, []);
|
|
this.events.get(method).push(fn);
|
|
}
|
|
close() {
|
|
try {
|
|
this.ws.close();
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
const DefaultProbe = `(() => {
|
|
const pick = (el) => {
|
|
const r = el.getBoundingClientRect();
|
|
const c = getComputedStyle(el);
|
|
return {
|
|
tag: el.tagName,
|
|
id: el.id || '',
|
|
cls: typeof el.className === 'string' ? el.className : '',
|
|
dataUi: el.getAttribute('data-ui') || '',
|
|
text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 120),
|
|
rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)],
|
|
style: {
|
|
display: c.display, position: c.position, width: c.width, height: c.height,
|
|
top: c.top, left: c.left, right: c.right, bottom: c.bottom,
|
|
margin: c.margin, padding: c.padding, border: c.border,
|
|
borderRight: c.borderRight, borderBottom: c.borderBottom,
|
|
background: c.background, backgroundColor: c.backgroundColor,
|
|
color: c.color, fontSize: c.fontSize, fontWeight: c.fontWeight,
|
|
lineHeight: c.lineHeight, fontFamily: c.fontFamily,
|
|
zIndex: c.zIndex, opacity: c.opacity, overflow: c.overflow,
|
|
backdropFilter: c.backdropFilter, transform: c.transform,
|
|
flex: c.flex, gap: c.gap, boxShadow: c.boxShadow, borderRadius: c.borderRadius,
|
|
},
|
|
};
|
|
};
|
|
const all = [...document.querySelectorAll('*')];
|
|
const targets = all.filter((el) => {
|
|
const cls = typeof el.className === 'string' ? el.className : '';
|
|
return (
|
|
el.hasAttribute('data-ui') ||
|
|
el.id === 'ms-top' ||
|
|
el.closest('#ms-top') ||
|
|
/Head|HEAD|header|top|vip|premium|group|rank|currency|logout|exit/i.test(cls)
|
|
);
|
|
});
|
|
return {
|
|
url: location.href,
|
|
title: document.title,
|
|
viewport: [innerWidth, innerHeight],
|
|
rootFontSize: getComputedStyle(document.querySelector('.ms-root') || document.documentElement).fontSize,
|
|
htmlFontSize: getComputedStyle(document.documentElement).fontSize,
|
|
bodyFontSize: getComputedStyle(document.body).fontSize,
|
|
html: document.documentElement.outerHTML,
|
|
text: document.body.innerText.slice(0, 2000),
|
|
nodes: targets.slice(0, 400).map(pick),
|
|
};
|
|
})()`;
|
|
|
|
(async () => {
|
|
if (!fs.existsSync(ChromePath)) throw new Error('Chrome not found: ' + ChromePath);
|
|
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'tankic-chrome-'));
|
|
const args = [
|
|
'--headless=new',
|
|
'--no-sandbox',
|
|
'--disable-gpu',
|
|
'--enable-unsafe-swiftshader',
|
|
'--hide-scrollbars',
|
|
'--remote-debugging-port=0',
|
|
'--user-data-dir=' + profile,
|
|
'--window-size=' + Width + ',' + Height,
|
|
'about:blank',
|
|
];
|
|
const child = spawn(ChromePath, args, { stdio: 'ignore' });
|
|
try {
|
|
const { port } = await waitForDevTools(profile);
|
|
const version = await httpJson('http://127.0.0.1:' + port + '/json/version');
|
|
const cdp = new Cdp(version.webSocketDebuggerUrl);
|
|
await cdp.open();
|
|
|
|
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
const s = (method, params) => cdp.send(method, params, sessionId);
|
|
|
|
await s('Page.enable');
|
|
cdp.on('Page.frameNavigated', (params) => {
|
|
if (params && params.frame) console.error('NAV ' + params.frame.url);
|
|
});
|
|
await s('Runtime.enable');
|
|
if (Cookie) {
|
|
await s('Network.enable');
|
|
const cookies = Cookie.split(';')
|
|
.map((x) => x.trim())
|
|
.filter(Boolean)
|
|
.map((pair) => {
|
|
const i = pair.indexOf('=');
|
|
return i > 0
|
|
? { name: pair.slice(0, i), value: pair.slice(i + 1), domain: CookieDomain, path: '/' }
|
|
: null;
|
|
})
|
|
.filter(Boolean);
|
|
await s('Network.setCookies', { cookies });
|
|
}
|
|
await s('Emulation.setDeviceMetricsOverride', {
|
|
width: Width,
|
|
height: Height,
|
|
deviceScaleFactor: 1,
|
|
mobile: false,
|
|
});
|
|
await s('Page.navigate', { url: Url });
|
|
await sleep(WaitMs);
|
|
|
|
const expr = EvalFile ? fs.readFileSync(path.resolve(EvalFile), 'utf8') : DefaultProbe;
|
|
const result = await s('Runtime.evaluate', {
|
|
expression: expr,
|
|
awaitPromise: true,
|
|
returnByValue: true,
|
|
userGesture: true,
|
|
});
|
|
if (result.exceptionDetails) {
|
|
throw new Error('Probe exception: ' + JSON.stringify(result.exceptionDetails));
|
|
}
|
|
|
|
const jsonPath = path.join(OutDir, 'probe.json');
|
|
fs.writeFileSync(jsonPath, JSON.stringify(result.result.value, null, 2), 'utf8');
|
|
|
|
const shot = await s('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
|
|
const shotPath = path.join(OutDir, ShotName);
|
|
fs.writeFileSync(shotPath, Buffer.from(shot.data, 'base64'));
|
|
|
|
console.log(JSON.stringify({ ok: true, json: jsonPath, screenshot: shotPath, url: Url }));
|
|
cdp.close();
|
|
} finally {
|
|
try {
|
|
child.kill();
|
|
} catch {}
|
|
await sleep(300);
|
|
try {
|
|
fs.rmSync(profile, { recursive: true, force: true });
|
|
} catch {}
|
|
}
|
|
})().catch((e) => {
|
|
console.error(JSON.stringify({ ok: false, error: String(e && e.stack ? e.stack : e) }));
|
|
process.exit(1);
|
|
});
|