FE_Test / server /services /screenshot.ts
GitHub Actions
Deploy from GitHub Actions [test] - 2025-10-31 10:18:25
5f2aab6
import { AppError, URLInputErrorType } from '@/lib/errors';
import { ConcurrencyLimiter, forceGarbageCollection, imageCache, isMemoryThresholdExceeded, performanceMonitor } from '@/lib/performance';
import fs from 'fs';
import path from 'path';
import puppeteer, { Browser } from 'puppeteer';
export interface CaptureOptions {
width?: number;
height?: number;
timeout?: number;
waitForSelector?: string;
dummyMode?: boolean;
}
export interface ScreenshotResult {
base64: string;
mimeType: string;
metadata: {
width: number;
height: number;
format: string;
size: number;
};
}
export interface ScreenshotError {
type: 'INVALID_URL' | 'SCREENSHOT_FAILED' | 'NETWORK_ERROR' | 'TIMEOUT' | 'UNSUPPORTED_SITE';
message: string;
details?: any;
}
// ブラウザプール管理用のインターフェース
interface BrowserPoolItem {
browser: Browser;
inUse: boolean;
lastUsed: number;
}
export class ScreenshotService {
private readonly defaultOptions: Required<CaptureOptions> = {
width: 512,
height: 768,
timeout: 30000,
waitForSelector: '',
dummyMode: false,
};
// パフォーマンス最適化のための設定
private readonly MAX_POOL_SIZE = 1; // メモリ節約のため1に制限
private readonly MAX_CONCURRENT_CAPTURES = 1; // 同時実行を1に制限
private readonly BROWSER_IDLE_TIMEOUT = 30000; // 30秒でアイドルブラウザを閉じる
private readonly MAX_RETRIES = 3; // リトライ回数
private readonly RETRY_DELAY = 1000; // リトライ間隔(ミリ秒)
private browserPool: BrowserPoolItem[] = [];
private currentConcurrentCaptures = 0;
private poolCleanupInterval: NodeJS.Timeout | null = null;
private concurrencyLimiter = new ConcurrencyLimiter(3);
constructor() {
// 定期的にアイドルブラウザをクリーンアップ
this.startPoolCleanup();
}
/**
* キャプチャオプションを正規化してバリデーション
*/
private normalizeOptions(options: CaptureOptions): Required<CaptureOptions> {
const width = this.validateDimension(options.width, this.defaultOptions.width, 'width');
const height = this.validateDimension(options.height, this.defaultOptions.height, 'height');
const timeout = this.validateTimeout(options.timeout, this.defaultOptions.timeout);
const waitForSelector = options.waitForSelector || this.defaultOptions.waitForSelector;
const dummyMode = options.dummyMode ?? this.defaultOptions.dummyMode;
return {
width,
height,
timeout,
waitForSelector,
dummyMode,
};
}
/**
* 寸法(width/height)のバリデーション
*/
private validateDimension(value: number | undefined, defaultValue: number, dimension: string): number {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value !== 'number' || isNaN(value)) {
return defaultValue;
}
if (value <= 0 || value > 4096) {
return defaultValue;
}
return Math.floor(value);
}
/**
* タイムアウト値のバリデーション
*/
private validateTimeout(value: number | undefined, defaultValue: number): number {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value !== 'number' || isNaN(value)) {
return defaultValue;
}
if (value <= 0 || value > 300000) {
// 最大5分
return defaultValue;
}
return Math.floor(value);
}
async captureScreenshot(url: string, options: CaptureOptions = {}): Promise<ScreenshotResult> {
// ダミーモードチェック
if (options.dummyMode) {
const dummyResult = await this.getDummyScreenshot(url);
if (dummyResult) {
return dummyResult;
}
}
// オプションの正規化とバリデーション
const normalizedOptions = this.normalizeOptions(options);
// キャッシュチェック
const cacheKey = `screenshot:${url}:${normalizedOptions.width}x${normalizedOptions.height}`;
const cachedResult = imageCache.get(cacheKey);
if (cachedResult) {
return JSON.parse(cachedResult);
}
// 同時実行数を制限
return this.concurrencyLimiter.execute(async () => {
return performanceMonitor.measureAsync('captureScreenshot', async () => {
// メモリチェック
if (isMemoryThresholdExceeded(0.85)) {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SCREENSHOT === 'true') {
console.log('Memory threshold exceeded, forcing garbage collection');
}
forceGarbageCollection();
await new Promise((resolve) => setTimeout(resolve, 100));
}
try {
// リトライ機能付きでスクリーンショットを取得
const result = await this.captureWithRetry(url, normalizedOptions);
// 結果をキャッシュ
imageCache.set(cacheKey, JSON.stringify(result));
return result;
} finally {
this.currentConcurrentCaptures--;
}
});
});
}
private async captureWithRetry(url: string, options: Required<CaptureOptions>, retryCount = 0): Promise<ScreenshotResult> {
try {
return await this.performCapture(url, options);
} catch (error) {
if (retryCount < this.MAX_RETRIES - 1) {
// リトライ可能なエラーかチェック
if (this.isRetryableError(error)) {
await new Promise((resolve) => setTimeout(resolve, this.RETRY_DELAY * (retryCount + 1)));
return this.captureWithRetry(url, options, retryCount + 1);
}
}
throw error;
}
}
private async performCapture(url: string, options: Required<CaptureOptions>): Promise<ScreenshotResult> {
const sanitizedUrl = this.sanitizeUrl(url);
if (!this.validateUrl(sanitizedUrl)) {
throw new AppError(`Invalid URL format: ${url}`, URLInputErrorType.INVALID_URL, 400);
}
// オプションは既に正規化済み
const captureOptions = options;
let browser: Browser | null = null;
let poolItem: BrowserPoolItem | null = null;
let page = null;
try {
// ブラウザプールから取得
poolItem = await this.getBrowserFromPool();
browser = poolItem.browser;
page = await browser.newPage();
// メモリ効率化のための設定
await page.setRequestInterception(true);
page.on('request', (request) => {
// 不要なリソースをブロック(パフォーマンス向上)
const resourceType = request.resourceType();
if (['font'].includes(resourceType)) {
// 動画やメディアファイルは読み込むが、フォントは除外
request.abort();
} else {
request.continue();
}
});
// Puppeteerプロトコルエラーを防ぐため、値を再度チェック
const viewportWidth = captureOptions.width;
const viewportHeight = captureOptions.height;
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SCREENSHOT === 'true') {
console.log(`[Screenshot] Setting viewport: ${viewportWidth}x${viewportHeight} (types: ${typeof viewportWidth}, ${typeof viewportHeight})`);
}
// 型とNaN/undefined/nullの最終チェック
if (typeof viewportWidth !== 'number' || isNaN(viewportWidth) || typeof viewportHeight !== 'number' || isNaN(viewportHeight)) {
throw new AppError(`Invalid viewport dimensions: ${viewportWidth}x${viewportHeight}`, URLInputErrorType.SCREENSHOT_FAILED, 400);
}
await page.setViewport({
width: viewportWidth,
height: viewportHeight,
deviceScaleFactor: 2,
});
// 動画とアニメーション有効化のための設定
await page.evaluateOnNewDocument(() => {
// 動画の自動再生を有効化
Object.defineProperty(navigator, 'mediaCapabilities', {
writable: true,
value: {
decodingInfo: () =>
Promise.resolve({
supported: true,
smooth: true,
powerEfficient: true,
}),
},
});
// CSS アニメーションを一時的に停止しない
Object.defineProperty(document, 'hidden', {
writable: true,
value: false,
});
Object.defineProperty(document, 'visibilityState', {
writable: true,
value: 'visible',
});
// Intersection Observer API のモック(動画の遅延読み込み対応)
(window as any).IntersectionObserver = class IntersectionObserver {
callback: any;
constructor(callback: any) {
this.callback = callback;
// すべての要素が表示されているとみなす
setTimeout(() => {
this.callback([
{
isIntersecting: true,
intersectionRatio: 1,
target: { style: {} },
},
]);
}, 100);
}
observe() {}
unobserve() {}
disconnect() {}
};
});
// ナビゲーション設定(メモリ効率化)
await page.goto(sanitizedUrl, {
waitUntil: 'domcontentloaded', // networkidle2より高速
timeout: captureOptions.timeout,
});
// ページロード後に2秒待機(レンダリング完了のため)
await new Promise((resolve) => setTimeout(resolve, 2000));
// モーダルや同意ボタンを自動的にクリック(早めに処理)
await this.handleModalAndConsent(page);
// Cookie同意ボタンクリック後の処理を待つ
await new Promise((resolve) => setTimeout(resolve, 1500));
// 2回目の試行(一部サイトではページロード後に遅延表示される)
await this.handleModalAndConsent(page);
await new Promise((resolve) => setTimeout(resolve, 1000));
// 動画の再生とアニメーション開始を待つ処理
try {
await page.evaluate(() => {
// 動画要素を強制的に表示・再生
const videos = document.querySelectorAll('video');
videos.forEach((video: any) => {
if (video) {
video.style.visibility = 'visible';
video.style.opacity = '1';
video.muted = true; // ミュートにして自動再生を有効化
video.play().catch(() => {}); // 再生エラーは無視
}
});
// 遅延読み込み画像を強制表示
const lazyImages = document.querySelectorAll('img[data-src], img[loading="lazy"]');
lazyImages.forEach((img: any) => {
if (img.dataset.src) {
img.src = img.dataset.src;
}
img.loading = 'eager';
});
// CSS アニメーションの開始を促進
const animatedElements = document.querySelectorAll('[class*="animate"], [style*="animation"], [style*="transition"]');
animatedElements.forEach((el: any) => {
if (el.style.animationPlayState === 'paused') {
el.style.animationPlayState = 'running';
}
});
});
} catch (evalError) {
// ページナビゲーションによるエラーは警告ログのみ
if (evalError instanceof Error && evalError.message.includes('Execution context was destroyed')) {
console.warn('[Screenshot] Page navigation detected, skipping media initialization');
} else {
console.warn('[Screenshot] Failed to initialize media elements:', evalError);
}
}
// モーダル処理後に追加で3秒待機(動画とアニメーション等の完了を待つ)
await new Promise((resolve) => setTimeout(resolve, 3000));
// 追加の待機が必要な場合
if (captureOptions.waitForSelector) {
await page.waitForSelector(captureOptions.waitForSelector, {
timeout: Math.min(5000, captureOptions.timeout / 2),
});
}
const screenshotBuffer = await page.screenshot({
type: 'jpeg',
quality: 80,
fullPage: false,
});
const base64 = await this.processImageInMemory(Buffer.from(screenshotBuffer));
return {
base64,
mimeType: 'image/jpeg',
metadata: {
width: captureOptions.width,
height: captureOptions.height,
format: 'jpeg',
size: screenshotBuffer.length,
},
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
if (error instanceof Error) {
if (error.message.includes('timeout')) {
throw new AppError('Screenshot capture timed out', URLInputErrorType.TIMEOUT, 408, error);
} else if (error.message.includes('net::') || error.message.includes('ERR_')) {
throw new AppError('Network error occurred', URLInputErrorType.NETWORK_ERROR, 503, error);
}
}
throw new AppError('Failed to capture screenshot', URLInputErrorType.SCREENSHOT_FAILED, 500, error);
} finally {
// ページをクローズしてメモリを解放
if (page) {
await page.close().catch(() => {});
}
// ブラウザをプールに返却
if (poolItem) {
this.releaseBrowserToPool(poolItem);
}
// メモリ使用量が高い場合はガベージコレクションを促進
if (global.gc) {
global.gc();
}
}
}
private async getBrowserFromPool(): Promise<BrowserPoolItem> {
// 利用可能なブラウザを探す
const availableItem = this.browserPool.find((item) => !item.inUse);
if (availableItem) {
availableItem.inUse = true;
availableItem.lastUsed = Date.now();
return availableItem;
}
// プールが満杯でない場合は新しいブラウザを作成
if (this.browserPool.length < this.MAX_POOL_SIZE) {
const browser = await this.launchBrowser();
const newItem: BrowserPoolItem = {
browser,
inUse: true,
lastUsed: Date.now(),
};
this.browserPool.push(newItem);
return newItem;
}
// プールが満杯の場合は空きを待つ
await this.waitForAvailableBrowser();
return this.getBrowserFromPool();
}
private releaseBrowserToPool(item: BrowserPoolItem): void {
item.inUse = false;
item.lastUsed = Date.now();
}
private async launchBrowser(): Promise<Browser> {
const launchOptions: any = {
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
// メモリ最適化のための追加オプション
'--disable-web-security',
'--disable-features=IsolateOrigins',
'--disable-site-isolation-trials',
'--disable-features=site-per-process',
'--single-process', // HuggingFace Spacesでのメモリ効率化
'--no-zygote',
'--disable-extensions',
'--disable-default-apps',
'--mute-audio',
'--no-first-run',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-features=TranslateUI',
'--disable-ipc-flooding-protection',
// メモリ制限設定
'--memory-pressure-off',
'--js-flags=--max-old-space-size=128', // V8メモリ制限を更に削減(128MBに制限)
'--max_old_space_size=128', // Node.jsプロセス全体のメモリ制限
],
};
if (process.env.PUPPETEER_EXECUTABLE_PATH) {
launchOptions.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
}
return await puppeteer.launch(launchOptions);
}
private validateUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return ['http:', 'https:'].includes(urlObj.protocol);
} catch {
return false;
}
}
private sanitizeUrl(url: string): string {
const trimmedUrl = url.trim();
if (!trimmedUrl.startsWith('http://') && !trimmedUrl.startsWith('https://')) {
return `https://${trimmedUrl}`;
}
return trimmedUrl;
}
private async processImageInMemory(buffer: Buffer): Promise<string> {
const base64 = buffer.toString('base64');
return `data:image/jpeg;base64,${base64}`;
}
private isRetryableError(error: any): boolean {
if (error instanceof AppError) {
return [URLInputErrorType.NETWORK_ERROR, URLInputErrorType.TIMEOUT].includes(error.code as URLInputErrorType);
}
return false;
}
/**
* Cookie同意ボタンを自動的にクリックする
* 条件: (button要素 または IDやクラス名にcookieが含まれる) かつ (同意系のテキストが含まれる)
*/
private async handleModalAndConsent(page: any): Promise<void> {
try {
// 複数回試行する(モーダルが遅延表示される場合があるため)
for (let attempt = 0; attempt < 3; attempt++) {
// JavaScriptで直接評価(シンプルな条件で検索)
const clicked = await page.evaluate(() => {
// 同意系のテキスト
const consentTexts = ['同意', 'accept', 'agree', '承諾', '承認', '許可', 'allow', 'ok', '受け入れる'];
// 除外すべきテキスト(設定系)
const excludeTexts = ['設定', 'settings', 'configure', 'customize', 'manage', 'preferences', 'options'];
// すべての要素を取得
const allElements = document.querySelectorAll('*');
for (const elem of Array.from(allElements)) {
const htmlElem = elem as HTMLElement;
const tagName = elem.tagName.toLowerCase();
const elemId = String(htmlElem.id || '').toLowerCase();
const elemClass = String(htmlElem.className || '').toLowerCase();
const elemText = String(htmlElem.textContent || '')
.toLowerCase()
.trim();
// 条件1: button要素、またはIDやクラス名にcookieが含まれる
const isTargetElement = tagName === 'button' || elemId.includes('cookie') || elemClass.includes('cookie');
// 条件2: 同意系のテキストが含まれる
const hasConsentText = consentTexts.some((text) => elemText.includes(text.toLowerCase()));
// 条件3: 除外すべきテキストが含まれていない
const hasExcludeText = excludeTexts.some((text) => elemText.includes(text.toLowerCase()));
// 条件1と2を満たし、条件3(除外)に該当せず、表示されている要素をクリック
if (isTargetElement && hasConsentText && !hasExcludeText && htmlElem.offsetParent !== null) {
console.log(`[Cookie Consent] Found element - Tag: ${tagName}, ID: "${elemId}", Class: "${elemClass}", Text: "${elemText}"`);
try {
htmlElem.click();
return true;
} catch (e) {
console.log('[Cookie Consent] Failed to click:', e);
}
}
}
return false;
});
if (clicked) {
await new Promise((resolve) => setTimeout(resolve, 1000));
break;
}
// 次の試行前に待機
if (attempt < 2) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
} catch (error) {
console.log('[Cookie Consent] Error during consent handling:', error);
}
}
private async waitForAvailableSlot(): Promise<void> {
while (this.currentConcurrentCaptures >= this.MAX_CONCURRENT_CAPTURES) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
private async waitForAvailableBrowser(): Promise<void> {
while (!this.browserPool.some((item) => !item.inUse)) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* ダミーモード用のスクリーンショットを取得
*/
private async getDummyScreenshot(url: string): Promise<ScreenshotResult | null> {
// URLからダミーファイル名を決定
const urlToFileMap: Record<string, string> = {
'https://www.dentsu.co.jp/': 'dentsu',
'https://www.dentsudigital.co.jp/': 'dentsudigital',
'https://dentsu-ho.com/': 'dentsu-ho',
'https://www.dentsusoken.com/': 'dentsusoken',
'https://www.dentsulive.co.jp/': 'dentsulive',
'https://www.dentsu-crx.co.jp/': 'dentsu-crx',
'https://www.septeni-holdings.co.jp/': 'septeni',
'https://www.dentsuprc.co.jp/': 'dentsuprc',
'https://www.dc1.dentsu.co.jp/jp/': 'dc1',
};
// URLを正規化
const normalizedUrl = url.endsWith('/') ? url : url + '/';
const fileName = urlToFileMap[normalizedUrl];
if (!fileName) {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SCREENSHOT === 'true') {
console.log(`[Screenshot] No dummy screenshot available for ${url}`);
}
return null;
}
try {
// JSONファイルから読み込み
const jsonPath = path.join(process.cwd(), 'public', 'dummy', 'screenshot', `${fileName}.json`);
if (!fs.existsSync(jsonPath)) {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SCREENSHOT === 'true') {
console.log(`[Screenshot] Dummy screenshot file not found: ${jsonPath}`);
}
return null;
}
const jsonContent = fs.readFileSync(jsonPath, 'utf-8');
const data = JSON.parse(jsonContent);
// base64データにプレフィックスを追加
const result: ScreenshotResult = {
base64: `data:image/jpeg;base64,${data.base64}`,
mimeType: data.mimeType || 'image/jpeg',
metadata: data.metadata,
};
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SCREENSHOT === 'true') {
console.log(`[Screenshot] Loaded dummy screenshot for ${url} from ${fileName}.json`);
}
return result;
} catch (error) {
console.error(`[Screenshot] Error loading dummy screenshot:`, error);
return null;
}
}
private startPoolCleanup(): void {
this.poolCleanupInterval = setInterval(() => {
this.cleanupIdleBrowsers();
}, 10000); // 10秒ごとにチェック
}
private async cleanupIdleBrowsers(): Promise<void> {
const now = Date.now();
const itemsToRemove: BrowserPoolItem[] = [];
for (const item of this.browserPool) {
if (!item.inUse && now - item.lastUsed > this.BROWSER_IDLE_TIMEOUT) {
itemsToRemove.push(item);
}
}
for (const item of itemsToRemove) {
try {
await item.browser.close();
} catch (error) {
console.error('Error closing browser:', error);
}
const index = this.browserPool.indexOf(item);
if (index !== -1) {
this.browserPool.splice(index, 1);
}
}
}
// サービス終了時のクリーンアップ
// HTMLコンテンツから直接スクリーンショットを生成
// HTMLコンテンツから直接スクリーンショットを生成
async captureFromHtml(htmlContent: string, options: CaptureOptions = {}): Promise<ScreenshotResult> {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SCREENSHOT === 'true') {
console.log(`[Screenshot] captureFromHtml called with HTML length: ${htmlContent.length}, dummyMode: ${options.dummyMode}`);
}
// ダミーモードチェック
if (options.dummyMode) {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SCREENSHOT === 'true') {
console.log(`[Screenshot] Dummy mode enabled for HTML capture`);
}
const dummyResult = await this.getDummyScreenshot('html-capture');
if (dummyResult) {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SCREENSHOT === 'true') {
console.log(`[Screenshot] Using dummy screenshot for HTML capture`);
}
return dummyResult;
}
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SCREENSHOT === 'true') {
console.log(`[Screenshot] No dummy found, falling back to real screenshot`);
}
}
// オプションの正規化とバリデーション
const normalizedOptions = this.normalizeOptions(options);
// 同時実行数を制限
return this.concurrencyLimiter.execute(async () => {
return performanceMonitor.measureAsync('captureFromHtml', async () => {
// メモリチェック
if (isMemoryThresholdExceeded(0.85)) {
if (process.env.NODE_ENV === 'development' || process.env.DEBUG_SCREENSHOT === 'true') {
console.log('Memory threshold exceeded, forcing garbage collection');
}
forceGarbageCollection();
await new Promise((resolve) => setTimeout(resolve, 100));
}
await this.waitForAvailableSlot();
await this.waitForAvailableBrowser();
const poolItem = await this.getBrowserFromPool();
if (!poolItem) {
throw new Error('Failed to get browser from pool');
}
this.currentConcurrentCaptures++;
let page: any = null;
try {
// 新しいページを作成
page = await poolItem.browser.newPage();
// ビューポートの設定(メモリ節約のためdeviceScaleFactorを下げる)
await page.setViewport({
width: Math.min(normalizedOptions.width, 412), // 最大幅を412に制限
height: Math.min(normalizedOptions.height, 800), // 最大高さを800に制限
deviceScaleFactor: 1, // 2から1に変更してメモリ使用量を削減
});
// HTMLコンテンツを設定
await page.setContent(htmlContent, {
waitUntil: 'networkidle0',
timeout: normalizedOptions.timeout || 30000,
});
// 追加の待機時間(コンテンツレンダリング用)
if (normalizedOptions.waitForSelector) {
await page.waitForSelector(normalizedOptions.waitForSelector, {
timeout: 5000,
});
}
// スクロールしてlazyロード要素を読み込む
await page.evaluate(() => {
return new Promise<void>((resolve) => {
let totalHeight = 0;
const distance = 100;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0); // トップに戻る
resolve();
}
}, 100);
});
});
await new Promise((resolve) => setTimeout(resolve, 500));
// スクリーンショット取得(メモリ効率化のため品質を下げる)
const screenshotBuffer = await page.screenshot({
fullPage: false, // fullPageを無効にしてメモリ使用量削減
type: 'jpeg',
quality: 50, // 品質をさらに下げてメモリ使用量削減
clip: {
x: 0,
y: 0,
width: Math.min(normalizedOptions.width, 412),
height: Math.min(normalizedOptions.height, 1000),
},
});
// 画像処理
const processedImage = await this.processImageInMemory(screenshotBuffer);
const result: ScreenshotResult = {
base64: processedImage,
mimeType: 'image/jpeg',
metadata: {
width: normalizedOptions.width,
height: normalizedOptions.height,
format: 'jpeg',
size: screenshotBuffer.length,
},
};
return result;
} catch (error) {
console.error('[Screenshot] Error capturing from HTML:', error);
throw error;
} finally {
// クリーンアップ
if (page) {
try {
await page.close();
} catch (closeError) {
console.error('Error closing page:', closeError);
}
}
this.currentConcurrentCaptures--;
this.releaseBrowserToPool(poolItem);
// メモリ解放を強制
forceGarbageCollection();
}
});
});
}
async cleanup(): Promise<void> {
if (this.poolCleanupInterval) {
clearInterval(this.poolCleanupInterval);
}
for (const item of this.browserPool) {
try {
await item.browser.close();
} catch (error) {
console.error('Error closing browser during cleanup:', error);
}
}
this.browserPool = [];
}
}
export const screenshotService = new ScreenshotService();
// プロセス終了時のクリーンアップ
process.on('exit', () => {
screenshotService.cleanup().catch(() => {});
});
process.on('SIGINT', async () => {
await screenshotService.cleanup();
process.exit(0);
});
process.on('SIGTERM', async () => {
await screenshotService.cleanup();
process.exit(0);
});