Spaces:
Sleeping
Sleeping
| 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); | |
| }); | |