import puppeteer from 'puppeteer'; class ScreenshotService { constructor() { this.browser = null; this.isInitialized = false; this.maxRetries = 3; this.browserLaunchRetries = 0; this.maxBrowserLaunchRetries = 3; this.isClosing = false; } async initBrowser() { if (this.isClosing) { console.log('浏览器正在关闭中,等待重新初始化...'); this.browser = null; this.isClosing = false; } if (!this.browser) { try { console.log('初始化Puppeteer浏览器...'); this.browser = await puppeteer.launch({ headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--no-zygote', '--single-process', '--disable-gpu', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-features=TranslateUI', '--disable-extensions', '--hide-scrollbars', '--mute-audio', '--no-default-browser-check', '--disable-default-apps', '--disable-background-networking', '--disable-sync', '--metrics-recording-only', '--disable-domain-reliability', '--disable-component-extensions-with-background-pages', '--force-device-scale-factor=1', '--enable-precise-memory-info', '--disable-features=VizDisplayCompositor', '--run-all-compositor-stages-before-draw', '--disable-new-content-rendering-timeout', '--disable-ipc-flooding-protection', '--disable-hang-monitor', '--disable-prompt-on-repost', '--max_old_space_size=512' ], timeout: 30000, protocolTimeout: 30000 }); // 监听浏览器断开连接事件 this.browser.on('disconnected', () => { console.log('Puppeteer浏览器连接断开'); this.browser = null; this.isClosing = false; }); // 监听目标创建事件 this.browser.on('targetcreated', (target) => { console.log('新目标创建:', target.type()); }); // 监听目标销毁事件 this.browser.on('targetdestroyed', (target) => { console.log('目标销毁:', target.type()); }); console.log('Puppeteer浏览器初始化完成'); this.browserLaunchRetries = 0; } catch (error) { console.error('Puppeteer浏览器初始化失败:', error); this.browserLaunchRetries++; if (this.browserLaunchRetries <= this.maxBrowserLaunchRetries) { console.log(`尝试重新初始化浏览器 (${this.browserLaunchRetries}/${this.maxBrowserLaunchRetries})`); await this.delay(2000); // 等待2秒后重试 return this.initBrowser(); } else { throw new Error(`浏览器初始化失败,已重试${this.maxBrowserLaunchRetries}次: ${error.message}`); } } } // 验证浏览器是否仍然连接 try { await this.browser.version(); } catch (error) { console.warn('浏览器连接已断开,重新初始化:', error.message); this.browser = null; return this.initBrowser(); } return this.browser; } async closeBrowser() { if (this.browser && !this.isClosing) { try { this.isClosing = true; console.log('正在关闭Puppeteer浏览器...'); await this.browser.close(); console.log('Puppeteer浏览器已关闭'); } catch (error) { console.warn('关闭浏览器时出错:', error.message); } finally { this.browser = null; this.isClosing = false; } } } // 延迟函数 delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // 从HTML中提取PPT尺寸信息 extractPPTDimensions(htmlContent) { try { // 从JavaScript中提取PPT_DIMENSIONS const dimensionMatch = htmlContent.match(/window\.PPT_DIMENSIONS\s*=\s*{\s*width:\s*(\d+),\s*height:\s*(\d+)\s*}/); if (dimensionMatch) { return { width: parseInt(dimensionMatch[1]), height: parseInt(dimensionMatch[2]) }; } // 从viewport meta标签中提取 const viewportMatch = htmlContent.match(/width=(\d+),\s*height=(\d+)/); if (viewportMatch) { return { width: parseInt(viewportMatch[1]), height: parseInt(viewportMatch[2]) }; } // 从CSS中提取 const cssMatch = htmlContent.match(/width:\s*(\d+)px.*height:\s*(\d+)px/); if (cssMatch) { return { width: parseInt(cssMatch[1]), height: parseInt(cssMatch[2]) }; } // 默认尺寸 return { width: 960, height: 720 }; } catch (error) { console.warn('提取PPT尺寸失败,使用默认尺寸:', error.message); return { width: 960, height: 720 }; } } // 优化HTML内容以确保精确截图 optimizeHtmlForScreenshot(htmlContent, targetWidth, targetHeight) { console.log(`优化HTML for精确截图, 目标尺寸: ${targetWidth}x${targetHeight}`); // 在
标签后立即插入截图优化代码 const optimizedHtml = htmlContent.replace( /(]*>)/i, `$1 ` ); // 在前插入截图专用JavaScript const finalHtml = optimizedHtml.replace( /(<\/body>)/i, ` $1` ); console.log('HTML优化完成,已注入精确尺寸控制代码'); return finalHtml; } async generateScreenshotWithRetry(htmlContent, options = {}, retryCount = 0) { try { await this.initBrowser(); console.log(`开始生成截图... (尝试 ${retryCount + 1}/${this.maxRetries + 1})`); // 从HTML中提取PPT的精确尺寸 const dimensions = this.extractPPTDimensions(htmlContent); console.log(`检测到PPT尺寸: ${dimensions.width}x${dimensions.height}`); // 优化HTML内容以确保精确截图 const optimizedHtml = this.optimizeHtmlForScreenshot(htmlContent, dimensions.width, dimensions.height); // 创建新页面 let page = null; try { page = await this.browser.newPage(); // 设置页面超时 page.setDefaultTimeout(30000); page.setDefaultNavigationTimeout(30000); // 设置精确的viewport尺寸 await page.setViewport({ width: dimensions.width, height: dimensions.height, deviceScaleFactor: options.deviceScaleFactor || 2, // 高清截图 }); // 设置页面内容 await page.setContent(optimizedHtml, { waitUntil: ['load', 'domcontentloaded'], timeout: 30000 }); // 等待页面完全渲染 await page.waitForTimeout(2000); // 执行最终的尺寸验证和强制设置 await page.evaluate((targetWidth, targetHeight) => { const html = document.documentElement; const body = document.body; const container = document.querySelector('.slide-container'); // 最终强制设置,确保精确尺寸 const forceSize = (element, width, height) => { if (!element) return; element.style.width = width + 'px'; element.style.height = height + 'px'; element.style.minWidth = width + 'px'; element.style.minHeight = height + 'px'; element.style.maxWidth = width + 'px'; element.style.maxHeight = height + 'px'; element.style.margin = '0'; element.style.padding = '0'; element.style.border = 'none'; element.style.outline = 'none'; element.style.overflow = 'hidden'; element.style.transform = 'none'; if (element !== container) { element.style.position = 'fixed'; element.style.top = '0'; element.style.left = '0'; } }; forceSize(html, targetWidth, targetHeight); forceSize(body, targetWidth, targetHeight); if (container) { forceSize(container, targetWidth, targetHeight); container.style.position = 'fixed'; container.style.top = '0'; container.style.left = '0'; container.style.boxShadow = 'none'; container.style.zIndex = '1'; } // 验证尺寸设置结果 const verification = { html: html.offsetWidth + 'x' + html.offsetHeight, body: body.offsetWidth + 'x' + body.offsetHeight, container: container ? container.offsetWidth + 'x' + container.offsetHeight : 'none', target: targetWidth + 'x' + targetHeight }; console.log('最终尺寸验证:', verification); // 如果尺寸不匹配,记录警告 if (html.offsetWidth !== targetWidth || html.offsetHeight !== targetHeight) { console.warn('HTML尺寸不匹配目标尺寸!'); } return verification; }, dimensions.width, dimensions.height); // 再次等待以确保所有更改生效 await page.waitForTimeout(1000); // 执行截图,使用精确的剪裁区域 const screenshot = await page.screenshot({ type: options.format || 'jpeg', quality: options.quality || 95, clip: { x: 0, y: 0, width: dimensions.width, height: dimensions.height }, omitBackground: false, // 包含背景 captureBeyondViewport: false, // 不截取视口外内容 }); console.log(`截图成功生成,尺寸: ${dimensions.width}x${dimensions.height}, 数据大小: ${screenshot.length} 字节`); return screenshot; } finally { if (page) { try { await page.close(); } catch (error) { console.warn('关闭页面时出错:', error.message); } } } } catch (error) { console.error(`截图生成失败 (尝试 ${retryCount + 1}):`, error.message); // 如果是目标关闭错误或连接断开,重置浏览器 if (error.message.includes('Target closed') || error.message.includes('Connection closed') || error.message.includes('Protocol error')) { console.log('检测到浏览器连接问题,重置浏览器实例'); this.browser = null; this.isClosing = false; } // 如果还有重试机会,进行重试 if (retryCount < this.maxRetries) { console.log(`等待 ${(retryCount + 1) * 2} 秒后重试...`); await this.delay((retryCount + 1) * 2000); return this.generateScreenshotWithRetry(htmlContent, options, retryCount + 1); } throw new Error(`截图生成失败,已重试${this.maxRetries}次: ${error.message}`); } } async generateScreenshot(htmlContent, options = {}) { return this.generateScreenshotWithRetry(htmlContent, options, 0); } // 兼容旧方法名 async captureScreenshot(htmlContent, width, height, options = {}) { console.log('使用兼容方法captureScreenshot,建议使用generateScreenshot'); return this.generateScreenshot(htmlContent, options); } // 清理资源 async cleanup() { await this.closeBrowser(); } } // 创建单例实例 const screenshotService = new ScreenshotService(); // 进程退出时清理资源 process.on('exit', async () => { await screenshotService.cleanup(); }); process.on('SIGINT', async () => { await screenshotService.cleanup(); process.exit(0); }); process.on('SIGTERM', async () => { await screenshotService.cleanup(); process.exit(0); }); export default screenshotService;