2024-07-12
한어Русский языкEnglishFrançaisIndonesianSanskrit日本語DeutschPortuguêsΕλληνικάespañolItalianoSuomalainenLatina
Im Jahr 2022 bestand aufgrund der relativ begrenzten Teamberechtigungen auch die Notwendigkeit, Bilder zu generieren, ohne neue Seiten oder neue Dienste zu öffnen, und die Leistungsanforderungen waren zu diesem Zeitpunkt relativ hoch. Die Front-End-Generierungslösung von Html2canvas wurde eingeführt mehr als ein Dutzend Kerngeschäftsmodule und generierte und heruntergeladene Bilder innerhalb von 300 ms bis 2500 ms unter Web, H5, App und Electron PC App.
Dieses Jahr stießen wir auf die gleiche Nachfrage, aber da das Unternehmen einen großen visuellen Iframe mit Dutzenden von Diagrammen einbettete, hatte Html2canvas einige Schwierigkeiten, die Iframe-Situation zu lösen. Wir dachten auch über eine Lösung nach, bei der Teamberechtigungen zum Erstellen von Screenshots verwendet werden könnten die Serverseite, also startete Puppeteer.
Die Verwendung von Puppeteer zum Generieren von PDFs funktioniert nicht. Daher verwendet die Lösung Sharp, um das von Puppeteer generierte Bild manuell auf eine proportionale Größe der PDF-Seitengröße zu schneiden, und verwendet dann PDFKit Scale, um die ausgeschnittenen Bilder in gleichen Proportionen in die PDF-Datei zu stopfen.
Der Code ist eine POC-Lösung, bevor er in der Produktion verwendet werden kann. Daher muss der folgende Code nur als Referenz dienen.
Der Prozesscode hat eine geringe Referenzbedeutung. Das zugeschnittene Bild im Kerncode scrollt automatisch, wartet auf das Laden des angegebenen Iframes und ruft den Attributwert für das angegebene Element ab, was für die Referenz aussagekräftiger ist.
// 流程代码
import { ConsoleLogger } from '@nestjs/common';
import puppeteer, { Viewport } from 'puppeteer';
import { Browser, CookieParam } from 'puppeteer';
import * as helper from './helper';
const logger = new ConsoleLogger('WebViewer');
export enum TargetFileType {
png = 'png',
jpeg = 'jpeg',
webp = 'webp',
pdf = 'pdf',
}
export interface ShotParam {
type: TargetFileType;
url: string;
targetFile?: string;
cookies?: CookieParam[];
viewport?: Viewport;
timeout?: number;
}
export class WebViewer {
private browser: Browser | null = null;
private inited = false;
static defaultInstance = new WebViewer();
constructor(private readonly options?: WebViewerOptions) {
this.options = options || {
socketTimeout: 2 * 60 * 1000,
generateTimeout: 0,
};
}
async init(): Promise<void> {
this.browser = await puppeteer.launch({
// defaultViewport: { width: 1920, height: 1080 },
// headless: 'shell',
headless: false,
pipe: true,
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-first-run',
'--no-sandbox',
'--no-zygote',
'--full-memory-crash-report',
'--unlimited-storage',
],
});
this.inited = true;
}
async shot(param: ShotParam): Promise<Buffer> {
if (!this.inited) {
await this.init();
}
logger.log(
`Start to shot url: ${param.url}, type: ${
param.type
}, viewport: { heigth:${param.viewport?.height || 0}, width:${
param.viewport?.width || 0
}} `,
);
const page = await this.browser.newPage();
page.on('response', (response) => {
logger.debug(response.url());
});
page.on('close', () => {
logger.debug('Current page has been closed.');
});
if (param.cookies) {
await page.setCookie(...param.cookies);
}
await page.goto(param.url, {
timeout: param?.timeout || this.options.socketTimeout,
waitUntil: 'networkidle0',
});
await helper.waitForFrame(page);
const minWidth = 1920;
let width = await page.$eval('.html-table', (el) => el.scrollWidth + 36);
width = width > minWidth ? width : minWidth;
param.viewport = { width, height: 1080 };
await page.setViewport(param.viewport);
await helper.getValueFromElementDataset(
page,
'html',
'height',
async (value: string) => !Number.isNaN(Number(value)),
);
const bodyHandle = await page.$('body');
const { height: bodyHeight } = await bodyHandle.boundingBox();
param.viewport = { width, height: Math.floor(bodyHeight) + 1 };
await bodyHandle.dispose();
await page.setViewport(param.viewport);
await page.waitForSelector('#datart-rendered');
await helper.sleep(300);
let buffer = await page.screenshot();
if (param.type === 'pdf') {
buffer = await helper.generatePdf(buffer);
}
await helper.sleep(300);
await page.close();
return buffer;
}
async close(): Promise<void> {
await this.browser?.close();
}
}
export interface WebViewerOptions {
socketTimeout: number;
generateTimeout?: number;
}
// 核心代码
import { Page } from 'puppeteer';
import * as sharp from 'sharp';
import * as pdfkit from 'pdfkit';
import * as getStream from 'get-stream';
function waitForFrame(page: Page) {
let fulfill;
const promise = new Promise((resolve) => (fulfill = resolve));
checkFrame();
return promise;
function checkFrame() {
const frame = page.frames().find((f) => {
console.log(f.name());
return f.name() === 'datart';
});
if (frame) fulfill(frame);
else page.once('frameattached', checkFrame);
}
}
async function autoScroll(page: Page, selector: string) {
return page.evaluate((selector) => {
return new Promise((resolve) => {
//滚动的总高度
let totalHeight = 0;
//每次向下滚动的高度 100 px
const distance = 100;
const timer = setInterval(() => {
const dom = document.querySelector(selector);
if (!dom) {
return clearInterval(timer);
}
//页面的高度 包含滚动高度
const scrollHeight = dom.scrollHeight;
console.log(scrollHeight);
//滚动条向下滚动 distance
dom.scrollBy(0, distance);
totalHeight += distance;
//当滚动的总高度 大于 页面高度 说明滚到底了。也就是说到滚动条滚到底时,以上还会继续累加,直到超过页面高度
if (totalHeight >= scrollHeight) {
clearInterval(timer);
resolve(true);
}
}, 100);
});
}, selector);
}
async function getValueFromElementDataset(
page: Page,
selector: string,
key: string,
checkValue: (value: string) => Promise<boolean>,
) {
return new Promise((resolve) => {
const interval = setInterval(async () => {
const value = await page.$eval(
selector,
(el: HTMLElement, key: string) => {
return el.dataset[key];
},
key,
);
if (checkValue && !(await checkValue(value))) {
return;
} else {
clearInterval(interval);
resolve(value);
}
}, 500);
});
}
async function clipImage(
pageWidth: number,
pageHeight: number,
buffer: Buffer,
): Promise<{ images: Buffer[]; scale: number }> {
const imageOriginSharp = sharp(buffer);
const imageSharp = sharp(buffer).resize(pageWidth);
const imageBuffer = await imageSharp
.withMetadata()
.toBuffer({ resolveWithObject: true });
const imageOriginBuffer = await imageOriginSharp
.withMetadata()
.toBuffer({ resolveWithObject: true });
// const imageWidth = imageBuffer.info.width;
const imageHeight = imageBuffer.info.height;
const imageOriginWidth = imageOriginBuffer.info.width;
const imageOriginHeight = imageOriginBuffer.info.height;
const scale = imageOriginHeight / imageHeight;
const images: Buffer[] = [];
console.log({
imageOriginWidth,
imageOriginHeight,
scale,
});
let startY = 0;
while (startY < imageHeight) {
const height = Math.min(pageHeight, imageHeight - startY);
console.log(Math.ceil(height * scale), Math.ceil(startY * scale));
const imageOriginSharp = sharp(buffer);
const liteImage = await imageOriginSharp
.extract({
width: imageOriginWidth,
height: Math.ceil(height * scale),
left: 0,
top: Math.floor(startY * scale),
})
.toBuffer();
images.push(liteImage);
startY += height;
}
return { images, scale };
}
const generatePdf = (imageBuffer: Buffer) => {
return new Promise<Buffer>(async (resolve) => {
const doc = new pdfkit();
// 获取PDF页面的宽度和高度
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
// 图片按 page 高度等比放缩然后裁切为多份,然后通过 doc addpage 以及 doc.image 把每张图放入 pdf
const { images, scale } = await clipImage(
pageWidth,
pageHeight,
imageBuffer,
);
let index = 1;
for (const image of images) {
doc.image(image, 0, 0, {
width: pageWidth,
scale: 1 / scale,
});
if (index < images.length) {
doc.addPage();
doc.switchToPage(index);
index += 1;
}
}
doc.end();
const pdfBuffer = await getStream.buffer(doc);
resolve(pdfBuffer);
});
};
const sleep = (time: number) => new Promise((r) => setTimeout(r, time));
export {
sleep,
clipImage,
autoScroll,
generatePdf,
waitForFrame,
getValueFromElementDataset,
};