384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
const fs = require('fs/promises');
|
|
const path = require('path');
|
|
const { pathToFileURL } = require('url');
|
|
const { chromium } = require('playwright-core');
|
|
|
|
const WORKSPACE = path.resolve(__dirname, '..');
|
|
const BASE = 'https://ltjt.yunzhi.run/system/Business/';
|
|
|
|
const DEFAULT_GROUPS = [
|
|
{ groupNo: 'LW-260713A-A', ddid: 14021, tid: 13944 },
|
|
{ groupNo: 'LW-260714A-A', ddid: 14022, tid: 13945 },
|
|
{ groupNo: 'LW-260715A-A', ddid: 14023, tid: 13946 },
|
|
];
|
|
|
|
const EXPORT_SPECS = [
|
|
{ key: 'liantai-confirm', href: group => `orders_confirm_new.asp?did=${group.ddid}&tid=${group.tid}` },
|
|
{ key: 'xingyou-confirm', href: group => `orders_confirm_news.asp?did=${group.ddid}&tid=${group.tid}` },
|
|
{ key: 'job-order', href: group => `teams_beian1.asp?did=${group.ddid}` },
|
|
];
|
|
|
|
const chromeCandidates = [
|
|
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
|
|
].filter(Boolean);
|
|
|
|
function nowStamp() {
|
|
const d = new Date();
|
|
const pad = n => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
}
|
|
|
|
function parseArgs() {
|
|
const args = new Map();
|
|
for (const raw of process.argv.slice(2)) {
|
|
const [key, ...rest] = raw.replace(/^--/, '').split('=');
|
|
args.set(key, rest.length ? rest.join('=') : 'true');
|
|
}
|
|
return {
|
|
skipExport: args.get('skip-export') === 'true',
|
|
skipConvert: args.get('skip-convert') === 'true',
|
|
render: args.get('render') || 'sample', // none | sample | all
|
|
exportMethod: args.get('export-method') || 'auto', // auto | request | browser
|
|
convertConcurrency: Number(args.get('convert-concurrency') || 1),
|
|
profile: args.get('profile') || path.join(WORKSPACE, 'diagnostics', 'erp-playwright-profile'),
|
|
groupsFile: args.get('groups'),
|
|
sourceDir: args.get('source-dir'),
|
|
};
|
|
}
|
|
|
|
async function findChrome() {
|
|
for (const p of chromeCandidates) {
|
|
try {
|
|
await fs.access(p);
|
|
return p;
|
|
} catch {}
|
|
}
|
|
throw new Error('Chrome executable not found');
|
|
}
|
|
|
|
async function mkdirs(...dirs) {
|
|
for (const dir of dirs) await fs.mkdir(dir, { recursive: true });
|
|
}
|
|
|
|
async function timed(label, timings, fn) {
|
|
const start = performance.now();
|
|
try {
|
|
const result = await fn();
|
|
const ms = Math.round(performance.now() - start);
|
|
timings.push({ label, ms, ok: true });
|
|
return result;
|
|
} catch (err) {
|
|
const ms = Math.round(performance.now() - start);
|
|
timings.push({ label, ms, ok: false, error: String(err && err.stack || err) });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function loadGroups(groupsFile) {
|
|
if (!groupsFile) return DEFAULT_GROUPS;
|
|
const text = await fs.readFile(groupsFile, 'utf8');
|
|
const parsed = JSON.parse(text);
|
|
if (!Array.isArray(parsed)) throw new Error('groups file must contain an array');
|
|
return parsed;
|
|
}
|
|
|
|
async function requestExport(request, url, file, startedAt) {
|
|
const response = await request.get(url, { timeout: 20000 });
|
|
const body = await response.body();
|
|
await fs.writeFile(file, body);
|
|
return {
|
|
method: 'request',
|
|
status: response.status(),
|
|
url,
|
|
file,
|
|
bytes: body.length,
|
|
contentType: response.headers()['content-type'] || '',
|
|
contentDisposition: response.headers()['content-disposition'] || '',
|
|
ms: Date.now() - startedAt,
|
|
ok: response.ok() && body.length > 5000,
|
|
};
|
|
}
|
|
|
|
async function browserExport(page, url, file, startedAt) {
|
|
const downloadPromise = page.waitForEvent('download', { timeout: 12000 }).catch(() => null);
|
|
let gotoError = null;
|
|
let response = null;
|
|
try {
|
|
response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
} catch (err) {
|
|
gotoError = err;
|
|
}
|
|
const download = await downloadPromise;
|
|
if (download) {
|
|
await download.saveAs(file);
|
|
const stat = await fs.stat(file);
|
|
return {
|
|
method: 'download',
|
|
url,
|
|
file,
|
|
bytes: stat.size,
|
|
suggestedFilename: download.suggestedFilename(),
|
|
ms: Date.now() - startedAt,
|
|
ok: stat.size > 5000,
|
|
};
|
|
}
|
|
if (gotoError && String(gotoError).includes('Download is starting')) {
|
|
throw new Error(`Download started but Playwright did not expose file: ${url}`);
|
|
}
|
|
if (gotoError) throw gotoError;
|
|
if (!response) throw new Error(`No response: ${url}`);
|
|
const body = await response.body();
|
|
await fs.writeFile(file, body);
|
|
return {
|
|
method: 'response-body',
|
|
status: response.status(),
|
|
url,
|
|
file,
|
|
bytes: body.length,
|
|
contentType: response.headers()['content-type'] || '',
|
|
ms: Date.now() - startedAt,
|
|
ok: response.ok() && body.length > 5000,
|
|
};
|
|
}
|
|
|
|
async function exportOne(context, page, downloadDir, group, spec, method) {
|
|
const url = BASE + spec.href(group);
|
|
const file = path.join(downloadDir, `${group.groupNo}_${spec.key}.doc`);
|
|
const startedAt = Date.now();
|
|
const withMeta = record => ({ groupNo: group.groupNo, key: spec.key, ...record });
|
|
if (method === 'request' || method === 'auto') {
|
|
try {
|
|
const record = await requestExport(context.request, url, file, startedAt);
|
|
if (record.ok || method === 'request') return withMeta(record);
|
|
} catch (err) {
|
|
if (method === 'request') throw err;
|
|
}
|
|
}
|
|
return withMeta(await browserExport(page, url, file, startedAt));
|
|
}
|
|
|
|
async function exportDocs(run, groups, executablePath) {
|
|
const context = await chromium.launchPersistentContext(run.profile, {
|
|
executablePath,
|
|
headless: false,
|
|
acceptDownloads: true,
|
|
downloadsPath: run.downloadsDir,
|
|
viewport: { width: 1440, height: 900 },
|
|
args: ['--disable-extensions-except=', '--disable-component-extensions-with-background-pages'],
|
|
});
|
|
const page = context.pages()[0] || await context.newPage();
|
|
page.setDefaultTimeout(60000);
|
|
const results = [];
|
|
for (const group of groups) {
|
|
for (const spec of EXPORT_SPECS) {
|
|
const start = Date.now();
|
|
const record = await exportOne(context, page, run.downloadsDir, group, spec, run.exportMethod).catch(err => ({
|
|
groupNo: group.groupNo,
|
|
key: spec.key,
|
|
error: String(err && err.stack || err),
|
|
ms: Date.now() - start,
|
|
ok: false,
|
|
}));
|
|
results.push(record);
|
|
console.log(`[export] ${record.groupNo} ${record.key} ${record.ok ? 'ok' : 'fail'} ${record.ms || 0}ms`);
|
|
}
|
|
}
|
|
await context.close();
|
|
return results;
|
|
}
|
|
|
|
async function copySources(sourceDir, outputDir) {
|
|
const copied = [];
|
|
const names = (await fs.readdir(sourceDir)).filter(n => n.toLowerCase().endsWith('.doc')).sort();
|
|
for (const name of names) {
|
|
const src = path.join(sourceDir, name);
|
|
const dest = path.join(outputDir, name);
|
|
await fs.copyFile(src, dest);
|
|
copied.push(dest);
|
|
}
|
|
return copied;
|
|
}
|
|
|
|
async function mapLimit(items, limit, worker) {
|
|
const results = new Array(items.length);
|
|
let index = 0;
|
|
const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
|
while (index < items.length) {
|
|
const current = index++;
|
|
results[current] = await worker(items[current], current);
|
|
}
|
|
});
|
|
await Promise.all(runners);
|
|
return results;
|
|
}
|
|
|
|
async function convertDocs(run, executablePath) {
|
|
const browser = await chromium.launch({ executablePath, headless: true });
|
|
const docs = (await fs.readdir(run.outputsDir)).filter(n => n.toLowerCase().endsWith('.doc')).sort();
|
|
const results = await mapLimit(docs, run.convertConcurrency, async (name) => {
|
|
const page = await browser.newPage({ viewport: { width: 1240, height: 1754 } });
|
|
const startedAt = Date.now();
|
|
try {
|
|
const src = path.join(run.outputsDir, name);
|
|
const html = path.join(run.renderHtmlDir, name.replace(/\.doc$/i, '.html'));
|
|
const pdf = path.join(run.outputsDir, name.replace(/\.doc$/i, '.pdf'));
|
|
await fs.copyFile(src, html);
|
|
await page.goto(pathToFileURL(html).href, { waitUntil: 'load', timeout: 60000 });
|
|
await page.emulateMedia({ media: 'print' });
|
|
await page.pdf({
|
|
path: pdf,
|
|
format: 'A4',
|
|
printBackground: true,
|
|
margin: { top: '10mm', right: '8mm', bottom: '10mm', left: '8mm' },
|
|
preferCSSPageSize: true,
|
|
});
|
|
const srcStat = await fs.stat(src);
|
|
const pdfStat = await fs.stat(pdf);
|
|
const record = {
|
|
source: src,
|
|
pdf,
|
|
sourceBytes: srcStat.size,
|
|
pdfBytes: pdfStat.size,
|
|
ms: Date.now() - startedAt,
|
|
ok: pdfStat.size > 1000,
|
|
};
|
|
console.log(`[convert] ${name} ${record.ms}ms`);
|
|
return record;
|
|
} finally {
|
|
await page.close().catch(() => {});
|
|
}
|
|
});
|
|
await browser.close();
|
|
return results;
|
|
}
|
|
|
|
async function verifyPdfs(run, executablePath, renderMode) {
|
|
const pdfs = (await fs.readdir(run.outputsDir)).filter(n => n.toLowerCase().endsWith('.pdf')).sort();
|
|
const selected = renderMode === 'all'
|
|
? pdfs
|
|
: renderMode === 'sample'
|
|
? pdfs.filter((_, index) => index === 0 || index === Math.floor(pdfs.length / 2) || index === pdfs.length - 1)
|
|
: [];
|
|
const quick = [];
|
|
for (const name of pdfs) {
|
|
const p = path.join(run.outputsDir, name);
|
|
const stat = await fs.stat(p);
|
|
quick.push({ pdf: p, bytes: stat.size, ok: stat.size > 1000 });
|
|
}
|
|
const renders = [];
|
|
if (selected.length) {
|
|
const browser = await chromium.launch({ executablePath, headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1200, height: 1600 } });
|
|
for (const name of selected) {
|
|
const startedAt = Date.now();
|
|
const pdf = path.join(run.outputsDir, name);
|
|
const png = path.join(run.rendersDir, name.replace(/\.pdf$/i, '.png'));
|
|
await page.goto(pathToFileURL(pdf).href, { waitUntil: 'networkidle', timeout: 60000 });
|
|
await page.screenshot({ path: png, fullPage: false });
|
|
const stat = await fs.stat(png);
|
|
renders.push({ pdf, png, bytes: stat.size, ms: Date.now() - startedAt, ok: stat.size > 1000 });
|
|
console.log(`[render] ${name} ${Date.now() - startedAt}ms`);
|
|
}
|
|
await browser.close();
|
|
}
|
|
return { quick, renders, renderMode };
|
|
}
|
|
|
|
function summarize(profile) {
|
|
const sum = arr => arr.reduce((acc, x) => acc + (x.ms || 0), 0);
|
|
const stageMs = label => profile.timings.find(x => x.label === label)?.ms || 0;
|
|
const exportMs = stageMs('export-docs');
|
|
const convertMs = stageMs('convert-docs-to-pdf');
|
|
const renderMs = stageMs('verify-pdfs');
|
|
const totalMs = profile.timings.reduce((acc, x) => acc + (x.ms || 0), 0);
|
|
return {
|
|
totalMeasuredMs: totalMs,
|
|
exportMs,
|
|
convertMs,
|
|
renderMs,
|
|
exportWorkMs: sum(profile.exports || []),
|
|
convertWorkMs: sum(profile.conversions || []),
|
|
renderWorkMs: sum(profile.pdfVerification?.renders || []),
|
|
docs: profile.conversions?.length || profile.exports?.length || 0,
|
|
exportAvgMs: profile.exports?.length ? Math.round(exportMs / profile.exports.length) : 0,
|
|
convertAvgMs: profile.conversions?.length ? Math.round(convertMs / profile.conversions.length) : 0,
|
|
renderAvgMs: profile.pdfVerification?.renders?.length ? Math.round(renderMs / profile.pdfVerification.renders.length) : 0,
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs();
|
|
const runRoot = path.join(WORKSPACE, 'diagnostics', 'perf', `run-${nowStamp()}`);
|
|
const run = {
|
|
root: runRoot,
|
|
downloadsDir: path.join(runRoot, 'downloads'),
|
|
outputsDir: path.join(runRoot, 'outputs'),
|
|
renderHtmlDir: path.join(runRoot, 'render-html'),
|
|
rendersDir: path.join(runRoot, 'renders'),
|
|
profile: args.profile,
|
|
exportMethod: args.exportMethod,
|
|
convertConcurrency: Math.max(1, args.convertConcurrency || 1),
|
|
};
|
|
await mkdirs(run.downloadsDir, run.outputsDir, run.renderHtmlDir, run.rendersDir);
|
|
|
|
const groups = await loadGroups(args.groupsFile);
|
|
const executablePath = await findChrome();
|
|
const timings = [];
|
|
const profile = {
|
|
startedAt: new Date().toISOString(),
|
|
args,
|
|
run,
|
|
groups,
|
|
timings,
|
|
};
|
|
|
|
if (!args.skipExport) {
|
|
profile.exports = await timed('export-docs', timings, () => exportDocs(run, groups, executablePath));
|
|
for (const record of profile.exports.filter(r => r.file && r.ok)) {
|
|
await fs.copyFile(record.file, path.join(run.outputsDir, path.basename(record.file)));
|
|
}
|
|
} else {
|
|
const source = args.sourceDir || path.join(WORKSPACE, 'diagnostics', 'erp-downloads');
|
|
profile.copiedSources = await timed('copy-existing-docs', timings, () => copySources(source, run.outputsDir));
|
|
}
|
|
|
|
if (!args.skipConvert) {
|
|
profile.conversions = await timed('convert-docs-to-pdf', timings, () => convertDocs(run, executablePath));
|
|
}
|
|
|
|
profile.pdfVerification = await timed('verify-pdfs', timings, () => verifyPdfs(run, executablePath, args.render));
|
|
profile.summary = summarize(profile);
|
|
profile.finishedAt = new Date().toISOString();
|
|
|
|
const jsonPath = path.join(runRoot, 'performance.json');
|
|
await fs.writeFile(jsonPath, JSON.stringify(profile, null, 2), 'utf8');
|
|
const md = [
|
|
'# ERP Performance Profile',
|
|
'',
|
|
`- Run: ${runRoot}`,
|
|
`- Export files: ${profile.exports?.length || 0}`,
|
|
`- Converted PDFs: ${profile.conversions?.length || 0}`,
|
|
`- Render mode: ${args.render}`,
|
|
'',
|
|
'## Summary',
|
|
'',
|
|
`- Export total: ${(profile.summary.exportMs / 1000).toFixed(1)}s, avg ${profile.summary.exportAvgMs}ms/file`,
|
|
`- PDF convert total: ${(profile.summary.convertMs / 1000).toFixed(1)}s, avg ${profile.summary.convertAvgMs}ms/file`,
|
|
`- PDF render total: ${(profile.summary.renderMs / 1000).toFixed(1)}s, avg ${profile.summary.renderAvgMs}ms/file`,
|
|
'',
|
|
'## Files',
|
|
'',
|
|
`- JSON: ${jsonPath}`,
|
|
`- Outputs: ${run.outputsDir}`,
|
|
].join('\n');
|
|
await fs.writeFile(path.join(runRoot, 'summary.md'), md, 'utf8');
|
|
console.log(JSON.stringify({ runRoot, summary: profile.summary }, null, 2));
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|