504 lines
15 KiB
JavaScript
504 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from 'node:fs/promises';
|
|
|
|
const MAX_DEBUG_LOGS = 20;
|
|
const RENDER_SETTLE_POLL_MS = 500;
|
|
const RENDER_SETTLE_IDLE_MS = 2000;
|
|
const MAX_CAPTURE_VIEWPORT_HEIGHT = 12000;
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: node scripts/browser_capture.mjs --url <url> --token <x-de-token> --width 1920 --height 1080 --wait-seconds 0 --result-format 0 --output /abs/path/file.jpg
|
|
|
|
Options:
|
|
--url DataEase preview URL
|
|
--token X-DE-TOKEN to inject into localStorage as user.token
|
|
--width Browser viewport width in pixels
|
|
--height Browser viewport height in pixels
|
|
--wait-seconds Extra wait time after canvas is visible
|
|
--result-format 0=jpeg, 1=pdf
|
|
--output Absolute or relative output path
|
|
-h, --help Show this help message
|
|
`);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = {};
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const current = argv[index];
|
|
if (current === '-h' || current === '--help') {
|
|
args.help = true;
|
|
continue;
|
|
}
|
|
if (!current.startsWith('--')) {
|
|
throw new Error(`Unexpected argument: ${current}`);
|
|
}
|
|
const key = current.slice(2);
|
|
const value = argv[index + 1];
|
|
if (value == null || value.startsWith('--')) {
|
|
throw new Error(`Missing value for --${key}`);
|
|
}
|
|
args[key] = value;
|
|
index += 1;
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function parsePositiveInteger(value, name) {
|
|
const number = Number.parseInt(value, 10);
|
|
if (!Number.isFinite(number) || number <= 0) {
|
|
throw new Error(`${name} must be a positive integer`);
|
|
}
|
|
return number;
|
|
}
|
|
|
|
function parseWaitSeconds(value) {
|
|
const number = Number.parseInt(value ?? '0', 10);
|
|
if (!Number.isFinite(number) || number < 0) {
|
|
throw new Error('wait-seconds must be a non-negative integer');
|
|
}
|
|
return number;
|
|
}
|
|
|
|
async function waitForCanvasReady(page, selector, timeout) {
|
|
const locator = page.locator(selector).first();
|
|
await locator.waitFor({ state: 'visible', timeout });
|
|
await page.waitForFunction(
|
|
targetSelector => {
|
|
const element = document.querySelector(targetSelector);
|
|
if (!element) {
|
|
return false;
|
|
}
|
|
const rect = element.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
},
|
|
selector,
|
|
{ timeout }
|
|
);
|
|
await page.waitForTimeout(500);
|
|
return locator;
|
|
}
|
|
|
|
function buildWsCacheItem(value, now = Date.now(), expiresAt = 253402300799000) {
|
|
return JSON.stringify({
|
|
c: now,
|
|
e: expiresAt,
|
|
v: JSON.stringify(value)
|
|
});
|
|
}
|
|
|
|
async function collectDiagnostics(page, debugLogs) {
|
|
const state = await page.evaluate(() => {
|
|
const app = document.querySelector('#app');
|
|
const visible = element => {
|
|
if (!element) {
|
|
return false;
|
|
}
|
|
const style = window.getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || '1') > 0 && rect.width > 0 && rect.height > 0;
|
|
};
|
|
return {
|
|
href: location.href,
|
|
title: document.title,
|
|
hasCanvas: !!document.querySelector('.canvas-container'),
|
|
hasContent: !!document.querySelector('.content'),
|
|
hasEmpty: !!document.querySelector('.empty-background, .el-empty'),
|
|
visibleLoadingMasks: Array.from(document.querySelectorAll('.el-loading-mask,.ed-loading-mask,.v-loading-mask')).filter(visible).length,
|
|
unfinishedReportLoads: document.querySelectorAll('.report-load:not(.report-load-finish)').length,
|
|
bodyText: (document.body?.innerText || '').slice(0, 400),
|
|
appHtml: (app?.innerHTML || '').slice(0, 1200)
|
|
};
|
|
});
|
|
return {
|
|
...state,
|
|
logs: debugLogs.slice(-MAX_DEBUG_LOGS)
|
|
};
|
|
}
|
|
|
|
async function getRenderState(page, selector) {
|
|
return page.evaluate(targetSelector => {
|
|
const visible = element => {
|
|
if (!element) {
|
|
return false;
|
|
}
|
|
const style = window.getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || '1') > 0 && rect.width > 0 && rect.height > 0;
|
|
};
|
|
const canvas = document.querySelector(targetSelector);
|
|
const loadingSelectors = '.el-loading-mask,.ed-loading-mask,.v-loading-mask,[class*="loading-mask"]';
|
|
const visibleLoadingMasks = Array.from(document.querySelectorAll(loadingSelectors)).filter(visible).length;
|
|
const unfinishedReportLoads = document.querySelectorAll('.report-load:not(.report-load-finish)').length;
|
|
const box = canvas?.getBoundingClientRect();
|
|
return {
|
|
hasCanvas: !!canvas,
|
|
visibleLoadingMasks,
|
|
unfinishedReportLoads,
|
|
width: Math.round(box?.width || 0),
|
|
height: Math.round(box?.height || 0)
|
|
};
|
|
}, selector);
|
|
}
|
|
|
|
async function waitForRenderSettled(page, selector, timeout, debugLogs, getPendingRequests) {
|
|
const startedAt = Date.now();
|
|
let stableSince = 0;
|
|
let lastSnapshot = '';
|
|
|
|
while (Date.now() - startedAt < timeout) {
|
|
const state = await getRenderState(page, selector);
|
|
const pendingRequests = getPendingRequests();
|
|
const snapshot = JSON.stringify({ ...state, pendingRequests });
|
|
if (snapshot !== lastSnapshot) {
|
|
debugLogs.push(`render-state ${snapshot}`);
|
|
lastSnapshot = snapshot;
|
|
}
|
|
|
|
const settled =
|
|
state.hasCanvas &&
|
|
state.width > 0 &&
|
|
state.height > 0 &&
|
|
state.visibleLoadingMasks === 0 &&
|
|
state.unfinishedReportLoads === 0 &&
|
|
pendingRequests === 0;
|
|
|
|
if (settled) {
|
|
if (!stableSince) {
|
|
stableSince = Date.now();
|
|
}
|
|
if (Date.now() - stableSince >= RENDER_SETTLE_IDLE_MS) {
|
|
return state;
|
|
}
|
|
} else {
|
|
stableSince = 0;
|
|
}
|
|
|
|
await page.waitForTimeout(RENDER_SETTLE_POLL_MS);
|
|
}
|
|
|
|
const state = await getRenderState(page, selector);
|
|
throw new Error(
|
|
`render settle timeout: ${JSON.stringify({
|
|
...state,
|
|
pendingRequests: getPendingRequests()
|
|
})}`
|
|
);
|
|
}
|
|
|
|
async function expandScrollableCapture(page, selector, viewportWidth, viewportHeight) {
|
|
return page.evaluate(
|
|
({ targetSelector, maxViewportHeight, minViewportHeight, viewportWidth }) => {
|
|
const canvas = document.querySelector(targetSelector);
|
|
if (!canvas) {
|
|
return null;
|
|
}
|
|
|
|
const isScrollable = element => {
|
|
if (!element) {
|
|
return false;
|
|
}
|
|
const style = window.getComputedStyle(element);
|
|
const overflowY = style.overflowY;
|
|
return ['auto', 'scroll', 'overlay'].includes(overflowY) && element.scrollHeight - element.clientHeight > 2;
|
|
};
|
|
|
|
const applyStyle = (element, key, value) => {
|
|
const styleKey = key;
|
|
element.style[styleKey] = value;
|
|
};
|
|
|
|
let scrollableAncestor = null;
|
|
let current = canvas.parentElement;
|
|
while (current && current !== document.body) {
|
|
if (isScrollable(current)) {
|
|
scrollableAncestor = current;
|
|
break;
|
|
}
|
|
current = current.parentElement;
|
|
}
|
|
|
|
const scrollingElement = document.scrollingElement || document.documentElement;
|
|
const initialRect = canvas.getBoundingClientRect();
|
|
|
|
if (scrollableAncestor) {
|
|
scrollableAncestor.scrollTop = 0;
|
|
applyStyle(scrollableAncestor, 'overflowY', 'visible');
|
|
applyStyle(scrollableAncestor, 'height', `${scrollableAncestor.scrollHeight}px`);
|
|
applyStyle(scrollableAncestor, 'maxHeight', 'none');
|
|
}
|
|
|
|
if (scrollingElement) {
|
|
scrollingElement.scrollTop = 0;
|
|
}
|
|
window.scrollTo(0, 0);
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const fullHeight = Math.max(
|
|
Math.ceil(rect.height),
|
|
Math.ceil(canvas.scrollHeight || 0),
|
|
Math.ceil(scrollableAncestor?.scrollHeight || 0),
|
|
Math.ceil(document.documentElement.scrollHeight || 0)
|
|
);
|
|
|
|
return {
|
|
hadScrollableAncestor: Boolean(scrollableAncestor),
|
|
initialHeight: Math.ceil(initialRect.height),
|
|
finalHeight: Math.ceil(rect.height),
|
|
captureHeight: fullHeight,
|
|
viewportHeight: Math.min(Math.max(fullHeight, minViewportHeight), maxViewportHeight),
|
|
viewportWidth: viewportWidth
|
|
};
|
|
},
|
|
{
|
|
targetSelector: selector,
|
|
maxViewportHeight: MAX_CAPTURE_VIEWPORT_HEIGHT,
|
|
minViewportHeight: viewportHeight,
|
|
viewportWidth: viewportWidth
|
|
}
|
|
);
|
|
}
|
|
|
|
async function toPdfBuffer(pngBytes, PDFDocument) {
|
|
const pdfDoc = await PDFDocument.create();
|
|
const image = await pdfDoc.embedPng(pngBytes);
|
|
const page = pdfDoc.addPage([image.width, image.height]);
|
|
page.drawImage(image, {
|
|
x: 0,
|
|
y: 0,
|
|
width: image.width,
|
|
height: image.height
|
|
});
|
|
const pdfBytes = await pdfDoc.save();
|
|
return Buffer.from(pdfBytes);
|
|
}
|
|
|
|
async function main() {
|
|
let browser;
|
|
let page;
|
|
const debugLogs = [];
|
|
try {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
const url = args.url;
|
|
const token = args.token;
|
|
const output = args.output;
|
|
if (!url || !token || !output) {
|
|
throw new Error('url, token and output are required');
|
|
}
|
|
|
|
const width = parsePositiveInteger(args.width ?? '1920', 'width');
|
|
const height = parsePositiveInteger(args.height ?? '1080', 'height');
|
|
const waitSeconds = parseWaitSeconds(args['wait-seconds']);
|
|
const resultFormat = Number.parseInt(args['result-format'] ?? '0', 10);
|
|
if (![0, 1].includes(resultFormat)) {
|
|
throw new Error('result-format must be 0 or 1');
|
|
}
|
|
|
|
const [{ chromium }, { PDFDocument }] = await Promise.all([
|
|
import('playwright'),
|
|
import('pdf-lib')
|
|
]);
|
|
|
|
browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath: process.env.CHROMIUM_PATH || undefined
|
|
});
|
|
const context = await browser.newContext({
|
|
viewport: { width, height },
|
|
deviceScaleFactor: 1,
|
|
ignoreHTTPSErrors: true,
|
|
extraHTTPHeaders: {
|
|
'X-DE-TOKEN': token
|
|
}
|
|
});
|
|
await context.route('**/de2api/**', async route => {
|
|
const request = route.request();
|
|
const headers = {
|
|
...request.headers(),
|
|
'X-DE-TOKEN': token
|
|
};
|
|
const url = request.url();
|
|
if (url.includes('/de2api/outerParams/getOuterParamsInfo/')) {
|
|
try {
|
|
const response = await route.fetch({ headers });
|
|
if (response.status() < 500) {
|
|
await route.fulfill({ response });
|
|
return;
|
|
}
|
|
debugLogs.push(`outerParams fallback: ${response.status()} ${url}`);
|
|
} catch (error) {
|
|
debugLogs.push(`outerParams fallback error: ${error?.message || String(error)}`);
|
|
}
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json;charset=UTF-8',
|
|
body: JSON.stringify({
|
|
code: 0,
|
|
msg: 'success',
|
|
data: {
|
|
outerParamsInfoMap: {},
|
|
outerParamsInfoBaseMap: {}
|
|
}
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
await route.continue({ headers });
|
|
});
|
|
page = await context.newPage();
|
|
const selector = '.canvas-container';
|
|
const timeout = 120000;
|
|
const pendingRequests = new Set();
|
|
const trackRequest = request => {
|
|
const resourceType = request.resourceType();
|
|
if (!['fetch', 'xhr'].includes(resourceType)) {
|
|
return false;
|
|
}
|
|
return request.url().includes('/de2api/');
|
|
};
|
|
page.on('request', request => {
|
|
if (trackRequest(request)) {
|
|
pendingRequests.add(request);
|
|
}
|
|
});
|
|
page.on('requestfinished', request => {
|
|
pendingRequests.delete(request);
|
|
});
|
|
page.on('requestfailed', request => {
|
|
pendingRequests.delete(request);
|
|
});
|
|
page.on('response', response => {
|
|
if (response.status() >= 400 && response.url().includes('/de2api/')) {
|
|
debugLogs.push(`response ${response.status()} ${response.url()}`);
|
|
}
|
|
});
|
|
page.on('pageerror', error => {
|
|
debugLogs.push(`pageerror ${error.message}`);
|
|
});
|
|
page.on('console', message => {
|
|
if (message.type() === 'error') {
|
|
debugLogs.push(`console ${message.text()}`);
|
|
}
|
|
});
|
|
|
|
const now = Date.now();
|
|
await page.addInitScript(
|
|
({ injectedToken, cacheToken, cacheExp, cacheTime }) => {
|
|
localStorage.setItem('user.token', cacheToken);
|
|
localStorage.setItem('user.exp', cacheExp);
|
|
localStorage.setItem('user.time', cacheTime);
|
|
localStorage.setItem('__de_raw_token__', injectedToken);
|
|
},
|
|
{
|
|
injectedToken: token,
|
|
cacheToken: buildWsCacheItem(token, now),
|
|
cacheExp: buildWsCacheItem(now + 3600 * 1000, now),
|
|
cacheTime: buildWsCacheItem(now, now)
|
|
}
|
|
);
|
|
|
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
|
|
try {
|
|
await page.waitForLoadState('networkidle', { timeout: 10000 });
|
|
} catch {
|
|
// Preview pages may keep network connections alive; selector checks below are stricter.
|
|
}
|
|
|
|
const locator = await waitForCanvasReady(page, selector, timeout);
|
|
const renderState = await waitForRenderSettled(
|
|
page,
|
|
selector,
|
|
timeout,
|
|
debugLogs,
|
|
() => pendingRequests.size
|
|
);
|
|
const expandedCapture = await expandScrollableCapture(page, selector, width, height);
|
|
if (expandedCapture?.captureHeight > height) {
|
|
await page.setViewportSize({
|
|
width,
|
|
height: expandedCapture.viewportHeight
|
|
});
|
|
await page.waitForTimeout(500);
|
|
await waitForRenderSettled(
|
|
page,
|
|
selector,
|
|
timeout,
|
|
debugLogs,
|
|
() => pendingRequests.size
|
|
);
|
|
}
|
|
if (waitSeconds > 0) {
|
|
await page.waitForTimeout(waitSeconds * 1000);
|
|
}
|
|
const box = await locator.boundingBox();
|
|
if (!box || box.width < 1 || box.height < 1) {
|
|
throw new Error('canvas container is empty');
|
|
}
|
|
|
|
if (resultFormat === 1) {
|
|
const pngBytes = await locator.screenshot({
|
|
type: 'png',
|
|
animations: 'disabled'
|
|
});
|
|
const pdfBytes = await toPdfBuffer(pngBytes, PDFDocument);
|
|
await fs.writeFile(output, pdfBytes);
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
format: 'pdf',
|
|
width: Math.round(box.width),
|
|
height: Math.round(box.height),
|
|
renderState,
|
|
expandedCapture,
|
|
output
|
|
}));
|
|
return;
|
|
}
|
|
|
|
await locator.screenshot({
|
|
path: output,
|
|
type: 'jpeg',
|
|
quality: 90,
|
|
animations: 'disabled'
|
|
});
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
format: 'jpeg',
|
|
width: Math.round(box.width),
|
|
height: Math.round(box.height),
|
|
renderState,
|
|
expandedCapture,
|
|
output
|
|
}));
|
|
} catch (error) {
|
|
if (error && error.code === 'ERR_MODULE_NOT_FOUND') {
|
|
console.error('Missing runtime dependency. Please run `npm install` and `npx playwright install chromium` first.');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
try {
|
|
const diagnostics = page
|
|
? await collectDiagnostics(page, debugLogs)
|
|
: null;
|
|
if (diagnostics) {
|
|
console.error(`${error && error.stack ? error.stack : String(error)}\nDiagnostics: ${JSON.stringify(diagnostics)}`);
|
|
} else {
|
|
console.error(error && error.stack ? error.stack : String(error));
|
|
}
|
|
} catch {
|
|
console.error(error && error.stack ? error.stack : String(error));
|
|
}
|
|
process.exitCode = 1;
|
|
} finally {
|
|
if (browser) {
|
|
await browser.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
main();
|