|
import puppeteer from 'puppeteer';
|
|
import playwright from 'playwright';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import crypto from 'crypto';
|
|
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
class ScreenshotService {
|
|
constructor() {
|
|
this.puppeteerBrowser = null;
|
|
this.playwrightBrowser = null;
|
|
this.isInitializing = false;
|
|
this.puppeteerReady = false;
|
|
this.playwrightReady = false;
|
|
|
|
|
|
this.puppeteerPagePool = [];
|
|
this.playwrightPagePool = [];
|
|
this.maxPoolSize = 3;
|
|
|
|
|
|
this.healthCheckFailures = 0;
|
|
this.maxHealthCheckFailures = 3;
|
|
this.lastRestartTime = 0;
|
|
this.minRestartInterval = 5 * 60 * 1000;
|
|
|
|
|
|
this.puppeteerErrorCount = 0;
|
|
this.maxPuppeteerErrors = 5;
|
|
this.puppeteerFailed = false;
|
|
this.lastPuppeteerError = 0;
|
|
|
|
|
|
this.screenshotCache = new Map();
|
|
this.maxCacheSize = 50;
|
|
this.cacheExpireTime = 10 * 60 * 1000;
|
|
|
|
|
|
this.googleFonts = [
|
|
'Noto+Sans+SC:400,700',
|
|
'Noto+Serif+SC:400,700'
|
|
];
|
|
|
|
console.log('📸 Screenshot service initialized (optimized version)');
|
|
|
|
|
|
this.warmupBrowsers();
|
|
|
|
|
|
this.startPrimaryBrowserRecovery();
|
|
}
|
|
|
|
|
|
async warmupBrowsers() {
|
|
setTimeout(async () => {
|
|
try {
|
|
console.log('🔥 Warming up primary browser (Puppeteer)...');
|
|
await this.initPuppeteer();
|
|
|
|
if (this.puppeteerReady) {
|
|
console.log('✅ Primary browser warmup completed');
|
|
} else {
|
|
console.warn('⚠️ Primary browser failed to start, will use fallback when needed');
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Primary browser warmup failed:', error.message);
|
|
this.puppeteerFailed = true;
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
|
|
async initPuppeteer() {
|
|
if (this.puppeteerBrowser || this.isInitializing) return;
|
|
|
|
this.isInitializing = true;
|
|
|
|
try {
|
|
console.log('🚀 Starting Puppeteer browser (optimized)...');
|
|
|
|
|
|
const launchOptions = {
|
|
headless: 'new',
|
|
args: [
|
|
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
|
|
|
|
'--disable-dev-shm-usage',
|
|
'--disable-extensions',
|
|
'--no-first-run',
|
|
'--no-default-browser-check',
|
|
'--disable-default-apps',
|
|
'--disable-features=TranslateUI',
|
|
'--disable-ipc-flooding-protection',
|
|
'--disable-features=VizDisplayCompositor',
|
|
'--disable-gpu-sandbox',
|
|
|
|
|
|
'--disable-dbus',
|
|
'--disable-field-trial-config',
|
|
'--disable-translate',
|
|
'--disable-web-security',
|
|
'--allow-running-insecure-content',
|
|
|
|
|
|
'--font-render-hinting=none',
|
|
'--enable-font-antialiasing',
|
|
'--force-color-profile=srgb',
|
|
'--disable-software-rasterizer',
|
|
'--enable-gpu-rasterization',
|
|
'--enable-oop-rasterization',
|
|
'--disable-crash-reporter',
|
|
'--disable-logging',
|
|
|
|
|
|
'--js-flags=--max-old-space-size=512',
|
|
'--memory-pressure-off',
|
|
|
|
|
|
'--disable-background-networking',
|
|
'--disable-background-mode',
|
|
'--disable-renderer-backgrounding',
|
|
'--disable-backgrounding-occluded-windows',
|
|
'--disable-background-timer-throttling',
|
|
|
|
|
|
'--disable-breakpad',
|
|
'--disable-component-update',
|
|
'--disable-domain-reliability',
|
|
'--disable-sync',
|
|
'--disable-hang-monitor',
|
|
'--disable-prompt-on-repost',
|
|
'--disable-client-side-phishing-detection',
|
|
'--disable-component-extensions-with-background-pages',
|
|
'--blink-settings=imagesEnabled=true',
|
|
'--disable-gpu-process-crash-limit',
|
|
'--disable-features=VizDisplayCompositor,AudioServiceOutOfProcess',
|
|
'--no-zygote',
|
|
'--disable-accelerated-2d-canvas',
|
|
'--disable-accelerated-jpeg-decoding',
|
|
'--disable-accelerated-mjpeg-decode',
|
|
'--disable-accelerated-video-decode'
|
|
],
|
|
timeout: 60000,
|
|
ignoreHTTPSErrors: true,
|
|
defaultViewport: null,
|
|
handleSIGINT: false,
|
|
handleSIGTERM: false,
|
|
handleSIGHUP: false,
|
|
dumpio: false,
|
|
protocolTimeout: 60000
|
|
};
|
|
|
|
|
|
const isLowMemoryEnv = process.env.LOW_MEMORY === 'true';
|
|
if (isLowMemoryEnv) {
|
|
console.log('🔧 Applying low memory optimizations');
|
|
launchOptions.args.push(
|
|
'--disable-javascript',
|
|
'--disable-images',
|
|
'--disable-css-animations'
|
|
);
|
|
}
|
|
|
|
this.puppeteerBrowser = await puppeteer.launch(launchOptions);
|
|
this.puppeteerReady = true;
|
|
console.log('✅ Puppeteer browser started successfully');
|
|
|
|
|
|
await this.initPagePool();
|
|
|
|
|
|
this.startBrowserMonitoring();
|
|
|
|
|
|
process.on('exit', () => this.cleanup());
|
|
process.on('SIGINT', () => this.cleanup());
|
|
process.on('SIGTERM', () => this.cleanup());
|
|
|
|
} catch (error) {
|
|
console.error('❌ Puppeteer initialization failed:', error.message);
|
|
this.puppeteerReady = false;
|
|
} finally {
|
|
this.isInitializing = false;
|
|
}
|
|
}
|
|
|
|
|
|
async initPagePool() {
|
|
if (!this.puppeteerReady || !this.puppeteerBrowser) return;
|
|
|
|
try {
|
|
console.log(`🔄 Initializing page pool (size: ${this.maxPoolSize})...`);
|
|
|
|
|
|
for (const page of this.puppeteerPagePool) {
|
|
await page.close().catch(console.error);
|
|
}
|
|
this.puppeteerPagePool = [];
|
|
|
|
|
|
for (let i = 0; i < this.maxPoolSize; i++) {
|
|
const page = await this.puppeteerBrowser.newPage();
|
|
|
|
|
|
await page.setRequestInterception(true);
|
|
page.on('request', (request) => {
|
|
|
|
const resourceType = request.resourceType();
|
|
const url = request.url();
|
|
|
|
|
|
if (resourceType === 'document' ||
|
|
resourceType === 'stylesheet' ||
|
|
resourceType === 'script' ||
|
|
resourceType === 'font' ||
|
|
url.includes('svg') ||
|
|
url.includes('data:image/svg') ||
|
|
resourceType === 'image') {
|
|
request.continue();
|
|
} else if (resourceType === 'media' && !url.includes('ads')) {
|
|
|
|
request.continue();
|
|
} else {
|
|
|
|
request.abort();
|
|
}
|
|
});
|
|
|
|
|
|
await this.injectGoogleFonts(page);
|
|
|
|
|
|
await page.setViewport({ width: 1000, height: 562 });
|
|
|
|
|
|
this.puppeteerPagePool.push(page);
|
|
}
|
|
|
|
console.log(`✅ Page pool initialized with ${this.puppeteerPagePool.length} pages`);
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize page pool:', error.message);
|
|
}
|
|
}
|
|
|
|
|
|
async injectGoogleFonts(page) {
|
|
try {
|
|
|
|
const googleFontsUrl = `https://fonts.googleapis.com/css2?family=${this.googleFonts.join('&family=')}&display=swap`;
|
|
|
|
|
|
await page.evaluateOnNewDocument((fontsUrl) => {
|
|
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = fontsUrl;
|
|
link.crossOrigin = 'anonymous';
|
|
|
|
|
|
const addFontLink = () => {
|
|
if (document.head) {
|
|
document.head.appendChild(link);
|
|
console.log('Google Fonts injected successfully');
|
|
} else {
|
|
|
|
setTimeout(addFontLink, 10);
|
|
}
|
|
};
|
|
|
|
|
|
addFontLink();
|
|
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
* {
|
|
font-family: 'Noto Sans SC', 'Microsoft YaHei', 'SimHei', sans-serif !important;
|
|
}
|
|
svg text {
|
|
font-family: 'Noto Sans SC', 'Microsoft YaHei', 'SimHei', sans-serif !important;
|
|
}
|
|
`;
|
|
|
|
const addBackupStyle = () => {
|
|
if (document.head) {
|
|
document.head.appendChild(style);
|
|
} else {
|
|
setTimeout(addBackupStyle, 10);
|
|
}
|
|
};
|
|
|
|
addBackupStyle();
|
|
}, googleFontsUrl);
|
|
} catch (error) {
|
|
console.warn('⚠️ Failed to inject Google Fonts:', error.message);
|
|
}
|
|
}
|
|
|
|
|
|
startBrowserMonitoring() {
|
|
const checkInterval = 15 * 60 * 1000;
|
|
|
|
setInterval(async () => {
|
|
if (!this.puppeteerBrowser) return;
|
|
|
|
try {
|
|
|
|
await this.puppeteerBrowser.version();
|
|
|
|
|
|
const pages = await this.puppeteerBrowser.pages();
|
|
console.log(`🔍 Browser health check: ${pages.length} pages open`);
|
|
|
|
|
|
this.healthCheckFailures = 0;
|
|
|
|
|
|
if (pages.length > this.maxPoolSize * 4) {
|
|
console.log(`⚠️ Too many pages open (${pages.length}), cleaning up...`);
|
|
|
|
|
|
const poolPageIds = this.puppeteerPagePool.map(p => p.target()._targetId);
|
|
|
|
for (const page of pages) {
|
|
const pageId = page.target()._targetId;
|
|
if (!poolPageIds.includes(pageId)) {
|
|
await page.close().catch(console.error);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.healthCheckFailures++;
|
|
console.error(`❌ Browser health check failed (${this.healthCheckFailures}/${this.maxHealthCheckFailures}):`, error.message);
|
|
|
|
|
|
if (this.healthCheckFailures >= this.maxHealthCheckFailures) {
|
|
const now = Date.now();
|
|
if (now - this.lastRestartTime > this.minRestartInterval) {
|
|
console.log('🔄 Multiple health check failures detected, attempting browser restart...');
|
|
await this.restartBrowser();
|
|
} else {
|
|
console.log('⏳ Browser restart skipped - too soon since last restart');
|
|
}
|
|
}
|
|
}
|
|
}, checkInterval);
|
|
}
|
|
|
|
|
|
async restartBrowser() {
|
|
console.log('🔄 Attempting to restart primary browser...');
|
|
|
|
try {
|
|
|
|
this.lastRestartTime = Date.now();
|
|
|
|
|
|
await this.cleanup();
|
|
|
|
|
|
this.puppeteerReady = false;
|
|
this.puppeteerFailed = false;
|
|
this.puppeteerErrorCount = 0;
|
|
|
|
|
|
this.healthCheckFailures = 0;
|
|
|
|
await this.initPuppeteer();
|
|
|
|
if (this.puppeteerReady) {
|
|
console.log('✅ Primary browser successfully restarted');
|
|
|
|
if (this.playwrightBrowser) {
|
|
console.log('🔄 Closing secondary browser to save resources');
|
|
await this.playwrightBrowser.close().catch(console.error);
|
|
this.playwrightBrowser = null;
|
|
this.playwrightReady = false;
|
|
this.playwrightPagePool = [];
|
|
}
|
|
} else {
|
|
console.warn('⚠️ Primary browser restart failed, marking as failed');
|
|
this.puppeteerFailed = true;
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Browser restart failed:', error.message);
|
|
this.puppeteerFailed = true;
|
|
|
|
this.healthCheckFailures = 0;
|
|
}
|
|
}
|
|
|
|
|
|
async initPlaywright() {
|
|
if (this.playwrightBrowser) return;
|
|
|
|
try {
|
|
console.log('🎭 Starting Playwright browser (optimized)...');
|
|
|
|
|
|
const launchOptions = {
|
|
headless: true,
|
|
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || '/usr/bin/chromium-browser',
|
|
args: [
|
|
|
|
'--no-sandbox',
|
|
'--disable-setuid-sandbox',
|
|
|
|
|
|
'--disable-dev-shm-usage',
|
|
'--disable-gpu',
|
|
'--disable-extensions',
|
|
'--js-flags=--max-old-space-size=512',
|
|
'--single-process',
|
|
'--disable-background-networking',
|
|
|
|
|
|
'--disable-dbus',
|
|
'--disable-features=VizDisplayCompositor',
|
|
'--disable-software-rasterizer',
|
|
'--disable-background-timer-throttling',
|
|
'--disable-backgrounding-occluded-windows',
|
|
'--disable-renderer-backgrounding',
|
|
'--disable-field-trial-config',
|
|
'--disable-ipc-flooding-protection',
|
|
'--no-first-run',
|
|
'--no-default-browser-check',
|
|
'--disable-default-apps',
|
|
'--disable-component-update',
|
|
'--disable-sync',
|
|
'--disable-translate',
|
|
'--disable-web-security',
|
|
'--allow-running-insecure-content',
|
|
|
|
|
|
'--font-render-hinting=none'
|
|
],
|
|
ignoreDefaultArgs: ['--enable-automation'],
|
|
chromiumSandbox: false,
|
|
handleSIGINT: false,
|
|
handleSIGTERM: false,
|
|
handleSIGHUP: false,
|
|
timeout: 15000
|
|
};
|
|
|
|
console.log('🔧 Using Chromium executable:', launchOptions.executablePath);
|
|
|
|
this.playwrightBrowser = await playwright.chromium.launch(launchOptions);
|
|
|
|
|
|
await this.initPlaywrightPagePool();
|
|
|
|
this.playwrightReady = true;
|
|
console.log('✅ Playwright browser started successfully');
|
|
|
|
} catch (error) {
|
|
console.error('❌ Playwright initialization failed:', error.message);
|
|
this.playwrightReady = false;
|
|
}
|
|
}
|
|
|
|
|
|
async initPlaywrightPagePool() {
|
|
if (!this.playwrightBrowser) return;
|
|
|
|
try {
|
|
console.log(`🔄 Initializing Playwright page pool...`);
|
|
|
|
|
|
for (const page of this.playwrightPagePool) {
|
|
await page.close().catch(console.error);
|
|
}
|
|
this.playwrightPagePool = [];
|
|
|
|
|
|
for (let i = 0; i < this.maxPoolSize; i++) {
|
|
const context = await this.playwrightBrowser.newContext({
|
|
viewport: { width: 1000, height: 562 },
|
|
ignoreHTTPSErrors: true
|
|
});
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
|
await this.injectPlaywrightGoogleFonts(page);
|
|
|
|
|
|
this.playwrightPagePool.push(page);
|
|
}
|
|
|
|
console.log(`✅ Playwright page pool initialized with ${this.playwrightPagePool.length} pages`);
|
|
} catch (error) {
|
|
console.error('❌ Failed to initialize Playwright page pool:', error.message);
|
|
}
|
|
}
|
|
|
|
|
|
async injectPlaywrightGoogleFonts(page) {
|
|
try {
|
|
|
|
const googleFontsUrl = `https://fonts.googleapis.com/css2?family=${this.googleFonts.join('&family=')}&display=swap`;
|
|
|
|
|
|
await page.addInitScript(({ fontsUrl }) => {
|
|
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = fontsUrl;
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.head.appendChild(link);
|
|
});
|
|
}, { fontsUrl: googleFontsUrl });
|
|
} catch (error) {
|
|
console.warn('⚠️ Failed to inject Google Fonts to Playwright:', error.message);
|
|
}
|
|
}
|
|
|
|
|
|
generateCacheKey(htmlContent, options) {
|
|
const { format, quality, width, height } = options;
|
|
|
|
const contentHash = crypto.createHash('md5').update(htmlContent).digest('hex').substring(0, 8);
|
|
return `${contentHash}_${width}x${height}_${format}_${quality}`;
|
|
}
|
|
|
|
|
|
cleanExpiredCache() {
|
|
const now = Date.now();
|
|
for (const [key, value] of this.screenshotCache.entries()) {
|
|
if (now - value.timestamp > this.cacheExpireTime) {
|
|
this.screenshotCache.delete(key);
|
|
}
|
|
}
|
|
|
|
|
|
if (this.screenshotCache.size > this.maxCacheSize) {
|
|
const entries = Array.from(this.screenshotCache.entries());
|
|
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
const toDelete = entries.slice(0, entries.length - this.maxCacheSize);
|
|
toDelete.forEach(([key]) => this.screenshotCache.delete(key));
|
|
}
|
|
}
|
|
|
|
|
|
startPrimaryBrowserRecovery() {
|
|
const recoveryInterval = 30 * 60 * 1000;
|
|
|
|
setInterval(async () => {
|
|
|
|
if (this.puppeteerFailed && this.playwrightReady) {
|
|
const now = Date.now();
|
|
const timeSinceLastError = now - this.lastPuppeteerError;
|
|
|
|
|
|
if (timeSinceLastError > 10 * 60 * 1000) {
|
|
console.log('🔄 Attempting to recover primary browser...');
|
|
|
|
try {
|
|
|
|
this.puppeteerFailed = false;
|
|
this.puppeteerErrorCount = 0;
|
|
this.puppeteerReady = false;
|
|
|
|
|
|
await this.initPuppeteer();
|
|
|
|
if (this.puppeteerReady) {
|
|
console.log('✅ Primary browser successfully recovered!');
|
|
|
|
|
|
if (this.playwrightBrowser) {
|
|
console.log('🔄 Closing secondary browser after primary recovery');
|
|
await this.playwrightBrowser.close().catch(console.error);
|
|
this.playwrightBrowser = null;
|
|
this.playwrightReady = false;
|
|
this.playwrightPagePool = [];
|
|
}
|
|
} else {
|
|
console.warn('⚠️ Primary browser recovery failed, will retry later');
|
|
this.puppeteerFailed = true;
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Primary browser recovery error:', error.message);
|
|
this.puppeteerFailed = true;
|
|
}
|
|
}
|
|
}
|
|
}, recoveryInterval);
|
|
}
|
|
|
|
|
|
async generateScreenshot(htmlContent, options = {}) {
|
|
const {
|
|
format = 'jpeg',
|
|
quality = 90,
|
|
width = 1000,
|
|
height = 562,
|
|
timeout = 30000
|
|
} = options;
|
|
|
|
|
|
const cacheKey = this.generateCacheKey(htmlContent, { format, quality, width, height });
|
|
const cached = this.screenshotCache.get(cacheKey);
|
|
|
|
if (cached && (Date.now() - cached.timestamp < this.cacheExpireTime)) {
|
|
console.log(`📋 Using cached screenshot: ${cacheKey}`);
|
|
return cached.data;
|
|
}
|
|
|
|
console.log(`📸 Generating screenshot: ${width}x${height}, ${format}@${quality}%`);
|
|
|
|
let screenshot = null;
|
|
|
|
|
|
if (!this.puppeteerFailed) {
|
|
|
|
if (!this.puppeteerReady) {
|
|
const now = Date.now();
|
|
if (now - this.lastRestartTime > this.minRestartInterval) {
|
|
console.log('🔄 Primary browser not ready, attempting restart...');
|
|
await this.initPuppeteer();
|
|
}
|
|
}
|
|
|
|
if (this.puppeteerReady) {
|
|
try {
|
|
screenshot = await this.generateWithPuppeteer(htmlContent, { format, quality, width, height, timeout });
|
|
|
|
this.puppeteerErrorCount = 0;
|
|
} catch (error) {
|
|
this.puppeteerErrorCount++;
|
|
this.lastPuppeteerError = Date.now();
|
|
|
|
console.warn(`Puppeteer screenshot failed (${this.puppeteerErrorCount}/${this.maxPuppeteerErrors}):`, error.message);
|
|
|
|
|
|
if (this.puppeteerErrorCount >= this.maxPuppeteerErrors) {
|
|
console.error('❌ Primary browser marked as failed after multiple errors');
|
|
this.puppeteerFailed = true;
|
|
await this.cleanup();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
if (!screenshot && (this.puppeteerFailed || this.puppeteerErrorCount > 0)) {
|
|
console.log('🔄 Falling back to secondary browser (Playwright)...');
|
|
|
|
if (!this.playwrightReady) {
|
|
await this.initPlaywright();
|
|
}
|
|
|
|
if (this.playwrightReady) {
|
|
try {
|
|
screenshot = await this.generateWithPlaywright(htmlContent, { format, quality, width, height, timeout });
|
|
} catch (error) {
|
|
console.warn('Playwright screenshot failed:', error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
if (!screenshot) {
|
|
console.warn('All screenshot methods failed, generating fallback SVG');
|
|
screenshot = this.generateFallbackImage(width, height, 'Screenshot Service', 'Browser unavailable');
|
|
}
|
|
|
|
|
|
if (screenshot && format !== 'svg') {
|
|
this.screenshotCache.set(cacheKey, {
|
|
data: screenshot,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
|
|
this.cleanExpiredCache();
|
|
|
|
console.log(`💾 Cached screenshot: ${cacheKey} (cache size: ${this.screenshotCache.size})`);
|
|
}
|
|
|
|
return screenshot;
|
|
}
|
|
|
|
|
|
async generateWithPuppeteer(htmlContent, options) {
|
|
const { format, quality, width, height, timeout } = options;
|
|
let page = null;
|
|
let pageFromPool = false;
|
|
|
|
try {
|
|
|
|
if (this.puppeteerPagePool && this.puppeteerPagePool.length > 0) {
|
|
page = this.puppeteerPagePool.shift();
|
|
pageFromPool = true;
|
|
console.log(`📄 Using page from Puppeteer pool (${this.puppeteerPagePool.length} remaining)`);
|
|
} else {
|
|
|
|
page = await this.puppeteerBrowser.newPage();
|
|
console.log(`📄 Created new Puppeteer page (pool empty)`);
|
|
|
|
|
|
await page.setRequestInterception(true);
|
|
page.on('request', (request) => {
|
|
|
|
const resourceType = request.resourceType();
|
|
if (resourceType === 'media' || resourceType === 'font') {
|
|
|
|
request.continue();
|
|
} else if (resourceType === 'stylesheet' || resourceType === 'script' || resourceType === 'image') {
|
|
|
|
request.continue();
|
|
} else {
|
|
|
|
request.abort();
|
|
}
|
|
});
|
|
|
|
|
|
await this.injectGoogleFonts(page);
|
|
}
|
|
|
|
|
|
await page.setViewport({ width, height });
|
|
|
|
|
|
await page.setContent(htmlContent, {
|
|
waitUntil: 'networkidle0',
|
|
timeout: Math.min(timeout, 20000)
|
|
});
|
|
|
|
|
|
await page.evaluate(() => {
|
|
return document.fonts ? document.fonts.ready : Promise.resolve();
|
|
});
|
|
|
|
|
|
await page.evaluate(() => {
|
|
return new Promise(resolve => {
|
|
const svgs = document.querySelectorAll('svg');
|
|
if (svgs.length === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
console.log(`Found ${svgs.length} SVG elements, waiting for rendering...`);
|
|
|
|
|
|
const checkSVGRendered = (svg) => {
|
|
try {
|
|
|
|
const bbox = svg.getBBox();
|
|
return bbox && bbox.width > 0 && bbox.height > 0;
|
|
} catch (e) {
|
|
|
|
return svg.children.length > 0;
|
|
}
|
|
};
|
|
|
|
|
|
const waitForSVGs = () => {
|
|
let allRendered = true;
|
|
|
|
svgs.forEach(svg => {
|
|
if (!checkSVGRendered(svg)) {
|
|
allRendered = false;
|
|
}
|
|
});
|
|
|
|
if (allRendered) {
|
|
console.log('All SVG elements rendered successfully');
|
|
resolve();
|
|
} else {
|
|
|
|
setTimeout(waitForSVGs, 100);
|
|
}
|
|
};
|
|
|
|
|
|
waitForSVGs();
|
|
|
|
|
|
setTimeout(() => {
|
|
console.log('SVG rendering timeout, proceeding anyway');
|
|
resolve();
|
|
}, 5000);
|
|
});
|
|
});
|
|
|
|
|
|
await page.waitForTimeout(1500);
|
|
|
|
|
|
if (global.gc && Math.random() < 0.1) {
|
|
global.gc();
|
|
}
|
|
|
|
|
|
const screenshotOptions = {
|
|
type: format,
|
|
quality: format === 'jpeg' ? Math.max(quality, 90) : undefined,
|
|
fullPage: false,
|
|
omitBackground: format === 'png',
|
|
clip: { x: 0, y: 0, width, height },
|
|
|
|
deviceScaleFactor: 2
|
|
};
|
|
|
|
const screenshot = await page.screenshot(screenshotOptions);
|
|
|
|
console.log(`✅ Puppeteer screenshot generated: ${screenshot.length} bytes`);
|
|
return screenshot;
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Puppeteer screenshot failed: ${error.message}`);
|
|
throw error;
|
|
} finally {
|
|
if (page) {
|
|
if (pageFromPool && this.puppeteerPagePool.length < this.maxPoolSize) {
|
|
|
|
try {
|
|
|
|
await page.evaluate(() => {
|
|
|
|
if (document.body) document.body.innerHTML = '';
|
|
if (document.head) {
|
|
|
|
const links = document.head.querySelectorAll('link[rel="stylesheet"]');
|
|
const metas = document.head.querySelectorAll('meta');
|
|
document.head.innerHTML = '';
|
|
metas.forEach(meta => document.head.appendChild(meta));
|
|
links.forEach(link => document.head.appendChild(link));
|
|
}
|
|
});
|
|
|
|
|
|
this.puppeteerPagePool.push(page);
|
|
console.log(`♻️ Returned Puppeteer page to pool (${this.puppeteerPagePool.length} total)`);
|
|
} catch (e) {
|
|
console.warn('⚠️ Failed to reset page, closing it:', e.message);
|
|
await page.close().catch(console.error);
|
|
}
|
|
} else if (!pageFromPool) {
|
|
|
|
if (this.puppeteerPagePool.length >= this.maxPoolSize) {
|
|
const oldPage = this.puppeteerPagePool.shift();
|
|
await oldPage.close().catch(console.error);
|
|
}
|
|
|
|
try {
|
|
|
|
await page.evaluate(() => {
|
|
if (document.body) document.body.innerHTML = '';
|
|
});
|
|
this.puppeteerPagePool.push(page);
|
|
console.log(`♻️ Added new page to pool (${this.puppeteerPagePool.length} total)`);
|
|
} catch (e) {
|
|
console.warn('⚠️ Failed to add page to pool, closing it:', e.message);
|
|
await page.close().catch(console.error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
async generateWithPlaywright(htmlContent, options) {
|
|
const { format, quality, width, height, timeout } = options;
|
|
let page = null;
|
|
let pageFromPool = false;
|
|
|
|
try {
|
|
|
|
if (this.playwrightPagePool && this.playwrightPagePool.length > 0) {
|
|
page = this.playwrightPagePool.shift();
|
|
pageFromPool = true;
|
|
console.log(`📄 Using page from Playwright pool (${this.playwrightPagePool.length} remaining)`);
|
|
} else {
|
|
|
|
const context = await this.playwrightBrowser.newContext({
|
|
viewport: { width, height },
|
|
ignoreHTTPSErrors: true
|
|
});
|
|
page = await context.newPage();
|
|
console.log(`📄 Created new Playwright page (pool empty)`);
|
|
|
|
|
|
await this.injectPlaywrightGoogleFonts(page);
|
|
}
|
|
|
|
|
|
await page.setViewportSize({ width, height });
|
|
|
|
|
|
await page.setContent(htmlContent, {
|
|
waitUntil: 'networkidle',
|
|
timeout: Math.min(timeout, 10000)
|
|
});
|
|
|
|
|
|
await page.evaluate(() => {
|
|
return document.fonts ? document.fonts.ready : Promise.resolve();
|
|
});
|
|
|
|
|
|
await page.waitForTimeout(200);
|
|
|
|
|
|
if (global.gc && Math.random() < 0.1) {
|
|
global.gc();
|
|
}
|
|
|
|
|
|
const screenshotOptions = {
|
|
type: format,
|
|
quality: format === 'jpeg' ? quality : undefined,
|
|
fullPage: false,
|
|
omitBackground: format === 'png',
|
|
clip: { x: 0, y: 0, width, height }
|
|
};
|
|
|
|
const screenshot = await page.screenshot(screenshotOptions);
|
|
|
|
console.log(`✅ Playwright screenshot generated: ${screenshot.length} bytes`);
|
|
return screenshot;
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Playwright screenshot failed: ${error.message}`);
|
|
throw error;
|
|
} finally {
|
|
if (page) {
|
|
if (pageFromPool && this.playwrightPagePool.length < this.maxPoolSize) {
|
|
|
|
try {
|
|
await page.goto('about:blank').catch(() => {});
|
|
|
|
this.playwrightPagePool.push(page);
|
|
console.log(`♻️ Returned Playwright page to pool (${this.playwrightPagePool.length} total)`);
|
|
} catch (e) {
|
|
console.warn('⚠️ Failed to reset page, closing it:', e.message);
|
|
await page.close().catch(console.error);
|
|
}
|
|
} else if (!pageFromPool) {
|
|
|
|
await page.close().catch(console.error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
generateFallbackImage(width = 1000, height = 562, title = 'PPT Screenshot', subtitle = '', message = '') {
|
|
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" style="stop-color:#f8f9fa;stop-opacity:1" />
|
|
<stop offset="100%" style="stop-color:#e9ecef;stop-opacity:1" />
|
|
</linearGradient>
|
|
<pattern id="dots" patternUnits="userSpaceOnUse" width="20" height="20">
|
|
<circle cx="2" cy="2" r="1" fill="#dee2e6" opacity="0.5"/>
|
|
</pattern>
|
|
</defs>
|
|
|
|
<!-- Background -->
|
|
<rect width="100%" height="100%" fill="url(#bg)"/>
|
|
<rect width="100%" height="100%" fill="url(#dots)"/>
|
|
|
|
<!-- Border -->
|
|
<rect x="20" y="20" width="${width-40}" height="${height-40}"
|
|
fill="none" stroke="#dee2e6" stroke-width="3"
|
|
stroke-dasharray="15,10" rx="10"/>
|
|
|
|
<!-- Icon -->
|
|
<g transform="translate(${width/2}, ${height*0.25})">
|
|
<circle cx="0" cy="0" r="30" fill="#6c757d" opacity="0.3"/>
|
|
<rect x="-15" y="-10" width="30" height="20" rx="3" fill="#6c757d"/>
|
|
<rect x="-12" y="-7" width="24" height="3" fill="white"/>
|
|
<rect x="-12" y="-2" width="24" height="3" fill="white"/>
|
|
<rect x="-12" y="3" width="16" height="3" fill="white"/>
|
|
</g>
|
|
|
|
<!-- Title -->
|
|
<text x="${width/2}" y="${height*0.45}"
|
|
text-anchor="middle"
|
|
font-family="Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, sans-serif"
|
|
font-size="28"
|
|
font-weight="bold"
|
|
fill="#495057">${title}</text>
|
|
|
|
${subtitle ? `
|
|
<!-- Subtitle -->
|
|
<text x="${width/2}" y="${height*0.55}"
|
|
text-anchor="middle"
|
|
font-family="Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, sans-serif"
|
|
font-size="18"
|
|
fill="#6c757d">${subtitle}</text>
|
|
` : ''}
|
|
|
|
${message ? `
|
|
<!-- Message -->
|
|
<text x="${width/2}" y="${height*0.65}"
|
|
text-anchor="middle"
|
|
font-family="Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, sans-serif"
|
|
font-size="14"
|
|
fill="#adb5bd">${message}</text>
|
|
` : ''}
|
|
|
|
<!-- Footer -->
|
|
<text x="${width/2}" y="${height*0.85}"
|
|
text-anchor="middle"
|
|
font-family="Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, sans-serif"
|
|
font-size="12"
|
|
fill="#ced4da">PPT Screenshot Service • ${new Date().toLocaleString('zh-CN')}</text>
|
|
|
|
<!-- Dimensions info -->
|
|
<text x="${width/2}" y="${height*0.92}"
|
|
text-anchor="middle"
|
|
font-family="Microsoft YaHei, PingFang SC, Hiragino Sans GB, Source Han Sans SC, Noto Sans SC, WenQuanYi Micro Hei, SimHei, SimSun, Arial, sans-serif"
|
|
font-size="10"
|
|
fill="#ced4da">Size: ${width} × ${height}</text>
|
|
</svg>`;
|
|
|
|
return Buffer.from(svg, 'utf-8');
|
|
}
|
|
|
|
|
|
getStatus() {
|
|
return {
|
|
puppeteerReady: this.puppeteerReady,
|
|
playwrightReady: this.playwrightReady,
|
|
environment: process.env.NODE_ENV || 'development',
|
|
isHuggingFace: !!process.env.SPACE_ID
|
|
};
|
|
}
|
|
|
|
|
|
async cleanup() {
|
|
console.log('🧹 Cleaning up screenshot service...');
|
|
|
|
if (this.puppeteerBrowser) {
|
|
try {
|
|
await this.puppeteerBrowser.close();
|
|
this.puppeteerBrowser = null;
|
|
this.puppeteerReady = false;
|
|
console.log('✅ Puppeteer browser closed');
|
|
} catch (error) {
|
|
console.error('Error closing Puppeteer browser:', error);
|
|
}
|
|
}
|
|
|
|
if (this.playwrightBrowser) {
|
|
try {
|
|
await this.playwrightBrowser.close();
|
|
this.playwrightBrowser = null;
|
|
this.playwrightReady = false;
|
|
console.log('✅ Playwright browser closed');
|
|
} catch (error) {
|
|
console.error('Error closing Playwright browser:', error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export default new ScreenshotService(); |