CatPtain commited on
Commit
a85c114
·
verified ·
1 Parent(s): b0cb0f2

Upload 10 files

Browse files
backend/src/services/screenshotService.js CHANGED
@@ -21,6 +21,23 @@ class ScreenshotService {
21
  this.playwrightPagePool = [];
22
  this.maxPoolSize = 3; // 最大页面池大小
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  // 字体配置
25
  this.googleFonts = [
26
  'Noto+Sans+SC:400,700',
@@ -31,17 +48,26 @@ class ScreenshotService {
31
 
32
  // 启动时预热浏览器
33
  this.warmupBrowsers();
 
 
 
34
  }
35
 
36
- // 预热浏览器实例
37
  async warmupBrowsers() {
38
  setTimeout(async () => {
39
  try {
40
- console.log('🔥 Warming up browser instances...');
41
  await this.initPuppeteer();
42
- console.log('✅ Browser warmup complete');
 
 
 
 
 
43
  } catch (error) {
44
- console.error('❌ Browser warmup failed:', error.message);
 
45
  }
46
  }, 1000); // 延迟1秒启动,避免与服务器启动冲突
47
  }
@@ -226,7 +252,7 @@ class ScreenshotService {
226
 
227
  // 监控浏览器健康状态
228
  startBrowserMonitoring() {
229
- const checkInterval = 5 * 60 * 1000; // 5分钟检查一次
230
 
231
  setInterval(async () => {
232
  if (!this.puppeteerBrowser) return;
@@ -239,8 +265,11 @@ class ScreenshotService {
239
  const pages = await this.puppeteerBrowser.pages();
240
  console.log(`🔍 Browser health check: ${pages.length} pages open`);
241
 
242
- // 如果页面过多,关闭多余页面
243
- if (pages.length > this.maxPoolSize * 2) {
 
 
 
244
  console.log(`⚠️ Too many pages open (${pages.length}), cleaning up...`);
245
 
246
  // 保留页面池中的页面,关闭其他页面
@@ -254,30 +283,63 @@ class ScreenshotService {
254
  }
255
  }
256
  } catch (error) {
257
- console.error('❌ Browser health check failed:', error.message);
 
258
 
259
- // 尝试重启浏览器
260
- await this.restartBrowser();
 
 
 
 
 
 
 
 
261
  }
262
  }, checkInterval);
263
  }
264
 
265
- // 重启浏览器
266
  async restartBrowser() {
267
- console.log('🔄 Attempting to restart browser...');
268
 
269
  try {
 
 
 
270
  // 清理现有资源
271
  await this.cleanup();
272
 
273
- // 重新初始化
274
  this.puppeteerReady = false;
275
- this.playwrightReady = false;
 
 
 
 
 
276
  await this.initPuppeteer();
277
 
278
- console.log('✅ Browser successfully restarted');
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  } catch (error) {
280
  console.error('❌ Browser restart failed:', error.message);
 
 
 
281
  }
282
  }
283
 
@@ -408,6 +470,80 @@ class ScreenshotService {
408
  }
409
  }
410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  // Generate screenshot
412
  async generateScreenshot(htmlContent, options = {}) {
413
  const {
@@ -418,37 +554,88 @@ class ScreenshotService {
418
  timeout = 15000
419
  } = options;
420
 
 
 
 
 
 
 
 
 
 
421
  console.log(`📸 Generating screenshot: ${width}x${height}, ${format}@${quality}%`);
422
 
423
- // Try Puppeteer first
424
- if (!this.puppeteerReady) {
425
- await this.initPuppeteer();
426
- }
427
 
428
- if (this.puppeteerReady) {
429
- try {
430
- return await this.generateWithPuppeteer(htmlContent, { format, quality, width, height, timeout });
431
- } catch (error) {
432
- console.warn('Puppeteer screenshot failed, trying Playwright:', error.message);
 
 
 
 
433
  }
434
- }
435
 
436
- // Fallback to Playwright
437
- if (!this.playwrightReady) {
438
- await this.initPlaywright();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  }
440
 
441
- if (this.playwrightReady) {
442
- try {
443
- return await this.generateWithPlaywright(htmlContent, { format, quality, width, height, timeout });
444
- } catch (error) {
445
- console.warn('Playwright screenshot failed:', error.message);
 
 
 
 
 
 
 
 
 
446
  }
447
  }
448
 
449
  // Final fallback: generate SVG
450
- console.warn('All screenshot methods failed, generating fallback SVG');
451
- return this.generateFallbackImage(width, height, 'Screenshot Service', 'Browser unavailable');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  }
453
 
454
  // Generate screenshot using Puppeteer with page pool optimization
@@ -531,9 +718,22 @@ class ScreenshotService {
531
  } finally {
532
  if (page) {
533
  if (pageFromPool && this.puppeteerPagePool.length < this.maxPoolSize) {
534
- // 重置页面状态
535
  try {
536
- await page.goto('about:blank').catch(() => {});
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  // 将页面放回池中
538
  this.puppeteerPagePool.push(page);
539
  console.log(`♻️ Returned Puppeteer page to pool (${this.puppeteerPagePool.length} total)`);
@@ -542,8 +742,23 @@ class ScreenshotService {
542
  await page.close().catch(console.error);
543
  }
544
  } else if (!pageFromPool) {
545
- // 关闭非池中的页面
546
- await page.close().catch(console.error);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  }
548
  }
549
  }
 
21
  this.playwrightPagePool = [];
22
  this.maxPoolSize = 3; // 最大页面池大小
23
 
24
+ // 错误容忍和重启控制
25
+ this.healthCheckFailures = 0;
26
+ this.maxHealthCheckFailures = 3; // 连续失败3次才重启
27
+ this.lastRestartTime = 0;
28
+ this.minRestartInterval = 5 * 60 * 1000; // 最小重启间隔5分钟
29
+
30
+ // 主浏览器错误计数和故障状态
31
+ this.puppeteerErrorCount = 0;
32
+ this.maxPuppeteerErrors = 5; // 主浏览器连续错误5次后标记为故障
33
+ this.puppeteerFailed = false; // 主浏览器是否彻底故障
34
+ this.lastPuppeteerError = 0;
35
+
36
+ // 截图缓存 - 避免重复生成
37
+ this.screenshotCache = new Map();
38
+ this.maxCacheSize = 50; // 最大缓存数量
39
+ this.cacheExpireTime = 10 * 60 * 1000; // 缓存10分钟过期
40
+
41
  // 字体配置
42
  this.googleFonts = [
43
  'Noto+Sans+SC:400,700',
 
48
 
49
  // 启动时预热浏览器
50
  this.warmupBrowsers();
51
+
52
+ // 启动主浏览器恢复检查
53
+ this.startPrimaryBrowserRecovery();
54
  }
55
 
56
+ // 预热浏览器实例 - 优先启动主浏览器
57
  async warmupBrowsers() {
58
  setTimeout(async () => {
59
  try {
60
+ console.log('🔥 Warming up primary browser (Puppeteer)...');
61
  await this.initPuppeteer();
62
+
63
+ if (this.puppeteerReady) {
64
+ console.log('✅ Primary browser warmup completed');
65
+ } else {
66
+ console.warn('⚠️ Primary browser failed to start, will use fallback when needed');
67
+ }
68
  } catch (error) {
69
+ console.error('❌ Primary browser warmup failed:', error.message);
70
+ this.puppeteerFailed = true;
71
  }
72
  }, 1000); // 延迟1秒启动,避免与服务器启动冲突
73
  }
 
252
 
253
  // 监控浏览器健康状态
254
  startBrowserMonitoring() {
255
+ const checkInterval = 15 * 60 * 1000; // 15分钟检查一次,减少检查频率
256
 
257
  setInterval(async () => {
258
  if (!this.puppeteerBrowser) return;
 
265
  const pages = await this.puppeteerBrowser.pages();
266
  console.log(`🔍 Browser health check: ${pages.length} pages open`);
267
 
268
+ // 重置失败计数器
269
+ this.healthCheckFailures = 0;
270
+
271
+ // 如果页面过多,关闭多余页面(提高阈值)
272
+ if (pages.length > this.maxPoolSize * 4) { // 从2倍提高到4倍
273
  console.log(`⚠️ Too many pages open (${pages.length}), cleaning up...`);
274
 
275
  // 保留页面池中的页面,关闭其他页面
 
283
  }
284
  }
285
  } catch (error) {
286
+ this.healthCheckFailures++;
287
+ console.error(`❌ Browser health check failed (${this.healthCheckFailures}/${this.maxHealthCheckFailures}):`, error.message);
288
 
289
+ // 只有连续失败多次且距离上次重启足够久才重启浏览器
290
+ if (this.healthCheckFailures >= this.maxHealthCheckFailures) {
291
+ const now = Date.now();
292
+ if (now - this.lastRestartTime > this.minRestartInterval) {
293
+ console.log('🔄 Multiple health check failures detected, attempting browser restart...');
294
+ await this.restartBrowser();
295
+ } else {
296
+ console.log('⏳ Browser restart skipped - too soon since last restart');
297
+ }
298
+ }
299
  }
300
  }, checkInterval);
301
  }
302
 
303
+ // 重启浏览器 - 优先恢复主浏览器
304
  async restartBrowser() {
305
+ console.log('🔄 Attempting to restart primary browser...');
306
 
307
  try {
308
+ // 记录重启时间
309
+ this.lastRestartTime = Date.now();
310
+
311
  // 清理现有资源
312
  await this.cleanup();
313
 
314
+ // 重新初始化主浏览器
315
  this.puppeteerReady = false;
316
+ this.puppeteerFailed = false;
317
+ this.puppeteerErrorCount = 0;
318
+
319
+ // 重置失败计数器
320
+ this.healthCheckFailures = 0;
321
+
322
  await this.initPuppeteer();
323
 
324
+ if (this.puppeteerReady) {
325
+ console.log('✅ Primary browser successfully restarted');
326
+ // 如果主浏览器恢复,可以关闭副浏览器以节省资源
327
+ if (this.playwrightBrowser) {
328
+ console.log('🔄 Closing secondary browser to save resources');
329
+ await this.playwrightBrowser.close().catch(console.error);
330
+ this.playwrightBrowser = null;
331
+ this.playwrightReady = false;
332
+ this.playwrightPagePool = [];
333
+ }
334
+ } else {
335
+ console.warn('⚠️ Primary browser restart failed, marking as failed');
336
+ this.puppeteerFailed = true;
337
+ }
338
  } catch (error) {
339
  console.error('❌ Browser restart failed:', error.message);
340
+ this.puppeteerFailed = true;
341
+ // 重启失败时也要重置计数器,避免无限重试
342
+ this.healthCheckFailures = 0;
343
  }
344
  }
345
 
 
470
  }
471
  }
472
 
473
+ // 生成缓存键
474
+ generateCacheKey(htmlContent, options) {
475
+ const { format, quality, width, height } = options;
476
+ // 使用内容和选项的哈希作为缓存键
477
+ const crypto = require('crypto');
478
+ const contentHash = crypto.createHash('md5').update(htmlContent).digest('hex').substring(0, 8);
479
+ return `${contentHash}_${width}x${height}_${format}_${quality}`;
480
+ }
481
+
482
+ // 清理过期缓存
483
+ cleanExpiredCache() {
484
+ const now = Date.now();
485
+ for (const [key, value] of this.screenshotCache.entries()) {
486
+ if (now - value.timestamp > this.cacheExpireTime) {
487
+ this.screenshotCache.delete(key);
488
+ }
489
+ }
490
+
491
+ // 如果缓存过多,删除最旧的
492
+ if (this.screenshotCache.size > this.maxCacheSize) {
493
+ const entries = Array.from(this.screenshotCache.entries());
494
+ entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
495
+ const toDelete = entries.slice(0, entries.length - this.maxCacheSize);
496
+ toDelete.forEach(([key]) => this.screenshotCache.delete(key));
497
+ }
498
+ }
499
+
500
+ // 主浏览器恢复检查 - 定期尝试恢复故障的主浏览器
501
+ startPrimaryBrowserRecovery() {
502
+ const recoveryInterval = 30 * 60 * 1000; // 30分钟检查一次恢复
503
+
504
+ setInterval(async () => {
505
+ // 只有在主浏览器故障且副浏览器正在运行时才尝试恢复
506
+ if (this.puppeteerFailed && this.playwrightReady) {
507
+ const now = Date.now();
508
+ const timeSinceLastError = now - this.lastPuppeteerError;
509
+
510
+ // 距离上次错误至少10分钟后才尝试恢复
511
+ if (timeSinceLastError > 10 * 60 * 1000) {
512
+ console.log('🔄 Attempting to recover primary browser...');
513
+
514
+ try {
515
+ // 重置状态
516
+ this.puppeteerFailed = false;
517
+ this.puppeteerErrorCount = 0;
518
+ this.puppeteerReady = false;
519
+
520
+ // 尝试重新初始化主浏览器
521
+ await this.initPuppeteer();
522
+
523
+ if (this.puppeteerReady) {
524
+ console.log('✅ Primary browser successfully recovered!');
525
+
526
+ // 关闭副浏览器以节省资源
527
+ if (this.playwrightBrowser) {
528
+ console.log('🔄 Closing secondary browser after primary recovery');
529
+ await this.playwrightBrowser.close().catch(console.error);
530
+ this.playwrightBrowser = null;
531
+ this.playwrightReady = false;
532
+ this.playwrightPagePool = [];
533
+ }
534
+ } else {
535
+ console.warn('⚠️ Primary browser recovery failed, will retry later');
536
+ this.puppeteerFailed = true;
537
+ }
538
+ } catch (error) {
539
+ console.error('❌ Primary browser recovery error:', error.message);
540
+ this.puppeteerFailed = true;
541
+ }
542
+ }
543
+ }
544
+ }, recoveryInterval);
545
+ }
546
+
547
  // Generate screenshot
548
  async generateScreenshot(htmlContent, options = {}) {
549
  const {
 
554
  timeout = 15000
555
  } = options;
556
 
557
+ // 检查缓存
558
+ const cacheKey = this.generateCacheKey(htmlContent, { format, quality, width, height });
559
+ const cached = this.screenshotCache.get(cacheKey);
560
+
561
+ if (cached && (Date.now() - cached.timestamp < this.cacheExpireTime)) {
562
+ console.log(`📋 Using cached screenshot: ${cacheKey}`);
563
+ return cached.data;
564
+ }
565
+
566
  console.log(`📸 Generating screenshot: ${width}x${height}, ${format}@${quality}%`);
567
 
568
+ let screenshot = null;
 
 
 
569
 
570
+ // 优先使用主浏览器 (Puppeteer)
571
+ if (!this.puppeteerFailed) {
572
+ // 如果主浏览器未就绪,尝试重启(但��是每次都重启)
573
+ if (!this.puppeteerReady) {
574
+ const now = Date.now();
575
+ if (now - this.lastRestartTime > this.minRestartInterval) {
576
+ console.log('🔄 Primary browser not ready, attempting restart...');
577
+ await this.initPuppeteer();
578
+ }
579
  }
 
580
 
581
+ if (this.puppeteerReady) {
582
+ try {
583
+ screenshot = await this.generateWithPuppeteer(htmlContent, { format, quality, width, height, timeout });
584
+ // 成功时重置错误计数
585
+ this.puppeteerErrorCount = 0;
586
+ } catch (error) {
587
+ this.puppeteerErrorCount++;
588
+ this.lastPuppeteerError = Date.now();
589
+
590
+ console.warn(`Puppeteer screenshot failed (${this.puppeteerErrorCount}/${this.maxPuppeteerErrors}):`, error.message);
591
+
592
+ // 如果错误次数过多,标记主浏览器为故障
593
+ if (this.puppeteerErrorCount >= this.maxPuppeteerErrors) {
594
+ console.error('❌ Primary browser marked as failed after multiple errors');
595
+ this.puppeteerFailed = true;
596
+ await this.cleanup(); // 清理故障的主浏览器
597
+ }
598
+ }
599
+ }
600
  }
601
 
602
+ // 只有在主浏览器故障或截图失败时才启动副浏览器
603
+ if (!screenshot && (this.puppeteerFailed || this.puppeteerErrorCount > 0)) {
604
+ console.log('🔄 Falling back to secondary browser (Playwright)...');
605
+
606
+ if (!this.playwrightReady) {
607
+ await this.initPlaywright();
608
+ }
609
+
610
+ if (this.playwrightReady) {
611
+ try {
612
+ screenshot = await this.generateWithPlaywright(htmlContent, { format, quality, width, height, timeout });
613
+ } catch (error) {
614
+ console.warn('Playwright screenshot failed:', error.message);
615
+ }
616
  }
617
  }
618
 
619
  // Final fallback: generate SVG
620
+ if (!screenshot) {
621
+ console.warn('All screenshot methods failed, generating fallback SVG');
622
+ screenshot = this.generateFallbackImage(width, height, 'Screenshot Service', 'Browser unavailable');
623
+ }
624
+
625
+ // 缓存结果(只缓存成功的截图,不缓存fallback)
626
+ if (screenshot && format !== 'svg') {
627
+ this.screenshotCache.set(cacheKey, {
628
+ data: screenshot,
629
+ timestamp: Date.now()
630
+ });
631
+
632
+ // 清理过期缓存
633
+ this.cleanExpiredCache();
634
+
635
+ console.log(`💾 Cached screenshot: ${cacheKey} (cache size: ${this.screenshotCache.size})`);
636
+ }
637
+
638
+ return screenshot;
639
  }
640
 
641
  // Generate screenshot using Puppeteer with page pool optimization
 
718
  } finally {
719
  if (page) {
720
  if (pageFromPool && this.puppeteerPagePool.length < this.maxPoolSize) {
721
+ // 重置页面状态并放回池中
722
  try {
723
+ // 清理页面状态,但不导航到about:blank(减少操作)
724
+ await page.evaluate(() => {
725
+ // 清理页面内容但保持基本结构
726
+ if (document.body) document.body.innerHTML = '';
727
+ if (document.head) {
728
+ // 保留基本的meta标签和字体链接
729
+ const links = document.head.querySelectorAll('link[rel="stylesheet"]');
730
+ const metas = document.head.querySelectorAll('meta');
731
+ document.head.innerHTML = '';
732
+ metas.forEach(meta => document.head.appendChild(meta));
733
+ links.forEach(link => document.head.appendChild(link));
734
+ }
735
+ });
736
+
737
  // 将页面放回池中
738
  this.puppeteerPagePool.push(page);
739
  console.log(`♻️ Returned Puppeteer page to pool (${this.puppeteerPagePool.length} total)`);
 
742
  await page.close().catch(console.error);
743
  }
744
  } else if (!pageFromPool) {
745
+ // 如果池已满,尝试将新页面也加入池中(替换最旧的)
746
+ if (this.puppeteerPagePool.length >= this.maxPoolSize) {
747
+ const oldPage = this.puppeteerPagePool.shift();
748
+ await oldPage.close().catch(console.error);
749
+ }
750
+
751
+ try {
752
+ // 重置新页面状态
753
+ await page.evaluate(() => {
754
+ if (document.body) document.body.innerHTML = '';
755
+ });
756
+ this.puppeteerPagePool.push(page);
757
+ console.log(`♻️ Added new page to pool (${this.puppeteerPagePool.length} total)`);
758
+ } catch (e) {
759
+ console.warn('⚠️ Failed to add page to pool, closing it:', e.message);
760
+ await page.close().catch(console.error);
761
+ }
762
  }
763
  }
764
  }