From 381e5ef7cbe02ab89bed37f748968f7fdc088712 Mon Sep 17 00:00:00 2001 From: Yongsik Im Date: Wed, 11 Feb 2026 18:42:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Git=20diff=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=8B=9C=EA=B0=81=ED=99=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chromium 자동 다운로드 및 캐싱 기능 추가 - puppeteerSetup.ts: Chromium 바이너리 관리 유틸리티 - diffRenderer.ts: 초기화 및 준비 상태 확인 API 추가 - bot.ts: 시작 시 Puppeteer 초기화 (백그라운드) 주요 변경: - ensureChromium(): Chromium 없으면 자동 다운로드 - initializePuppeteer(): 사전 초기화 API - isPuppeteerReady(): 준비 상태 확인 Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 8 -- package.json | 1 - src/bot.ts | 11 +- src/utils/diffRenderer.ts | 4 +- src/utils/puppeteerSetup.ts | 250 ++++++++++++++++++++++++++++++++---- 5 files changed, 234 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index 55231e9..56f072f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "puppeteer": "^24.37.2" }, "devDependencies": { - "@types/diff2html": "^0.0.5", "@types/node": "^22.10.0", "esbuild": "^0.27.3", "pkg": "^5.8.1", @@ -1569,13 +1568,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/diff2html": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@types/diff2html/-/diff2html-0.0.5.tgz", - "integrity": "sha512-ZNANtIL5cZ/V9eDnH0P7qERxPchSSM1X6wUuOSioC+aWQE6uie/LVMO3KClOmgxLCu1fD77jy8sanYPP8wH7Yw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index f6777f9..d6ac34a 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ }, "license": "MIT", "devDependencies": { - "@types/diff2html": "^0.0.5", "@types/node": "^22.10.0", "esbuild": "^0.27.3", "pkg": "^5.8.1", diff --git a/src/bot.ts b/src/bot.ts index 1a8aced..85a0f94 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -25,7 +25,7 @@ import { getMultiSessionManager, } from "./sessions/multiSession.js"; import { sanitizeOutput } from "./utils/sanitizeOutput.js"; -import { closeBrowser } from "./utils/diffRenderer.js"; +import { closeBrowser, initializePuppeteer } from "./utils/diffRenderer.js"; import { initAuditLogger, getAuditLogger, audit, AuditEvent } from "./utils/auditLog.js"; import { initRateLimiter, getRateLimiter } from "./utils/rateLimiter.js"; import { isAllowedUser } from "./utils/security.js"; @@ -479,6 +479,15 @@ async function main(): Promise { process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); + // Pre-initialize Puppeteer (Chromium download) — non-blocking + try { + await initializePuppeteer(); + console.log(" [puppeteer] Chromium ready"); + } catch (err: any) { + console.warn(` [puppeteer] Chromium init failed: ${err.message}`); + console.warn(" [puppeteer] !diff will fall back to text mode"); + } + await client.login(DISCORD_BOT_TOKEN); } diff --git a/src/utils/diffRenderer.ts b/src/utils/diffRenderer.ts index c035f11..5e5dfe2 100644 --- a/src/utils/diffRenderer.ts +++ b/src/utils/diffRenderer.ts @@ -1,6 +1,4 @@ -// eslint-disable-next-line @typescript-eslint/no-require-imports -const diff2htmlModule = require("diff2html") as { html: (diff: string, config?: object) => string }; -const { html: diff2html } = diff2htmlModule; +import { html as diff2html } from "diff2html"; import puppeteer, { type Browser } from "puppeteer"; import { ensureChromium, isChromiumInstalled } from "./puppeteerSetup.js"; diff --git a/src/utils/puppeteerSetup.ts b/src/utils/puppeteerSetup.ts index 40a3779..9a426af 100644 --- a/src/utils/puppeteerSetup.ts +++ b/src/utils/puppeteerSetup.ts @@ -1,10 +1,14 @@ import path from "node:path"; import fs from "node:fs"; +import https from "node:https"; import { execSync, spawn } from "node:child_process"; // pkg 환경인지 확인 declare const process: NodeJS.Process & { pkg?: unknown }; +const CHROME_FOR_TESTING_JSON = + "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"; + // Chromium 설치 경로 (exe와 같은 폴더의 .chromium) function getChromiumPath(): string { // pkg로 빌드된 exe인 경우 process.execPath가 exe 경로 @@ -59,6 +63,23 @@ export function isChromiumInstalled(): boolean { return executable !== null; } +// 시스템에 설치된 Chrome 찾기 +function findSystemChrome(): string | null { + const candidates = [ + path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google", "Chrome", "Application", "chrome.exe"), + path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google", "Chrome", "Application", "chrome.exe"), + path.join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"), + ]; + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + // npx를 사용하여 Chromium 다운로드 async function downloadWithNpx(chromiumDir: string): Promise { return new Promise((resolve, reject) => { @@ -89,6 +110,170 @@ async function downloadWithNpx(chromiumDir: string): Promise { }); } +// HTTPS JSON 가져오기 +function fetchJson(url: string): Promise { + return new Promise((resolve, reject) => { + const request = (targetUrl: string, redirectCount = 0) => { + if (redirectCount > 5) { + reject(new Error("Too many redirects")); + return; + } + + https.get(targetUrl, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + request(res.headers.location, redirectCount + 1); + return; + } + + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`HTTP ${res.statusCode}`)); + return; + } + + let data = ""; + res.on("data", (chunk: Buffer) => { data += chunk.toString(); }); + res.on("end", () => { + try { + resolve(JSON.parse(data)); + } catch (err) { + reject(err); + } + }); + }).on("error", reject); + }; + + request(url); + }); +} + +// HTTPS 파일 다운로드 (진행률 표시) +function downloadFile(url: string, dest: string): Promise { + return new Promise((resolve, reject) => { + const tmpDest = dest + ".tmp"; + + const request = (targetUrl: string, redirectCount = 0) => { + if (redirectCount > 5) { + reject(new Error("Too many redirects")); + return; + } + + https.get(targetUrl, (res) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + res.resume(); + request(res.headers.location, redirectCount + 1); + return; + } + + if (res.statusCode !== 200) { + res.resume(); + reject(new Error(`Download failed: HTTP ${res.statusCode}`)); + return; + } + + const totalBytes = parseInt(res.headers["content-length"] ?? "0", 10); + let downloaded = 0; + let lastPercent = -1; + + const file = fs.createWriteStream(tmpDest); + + res.on("data", (chunk: Buffer) => { + downloaded += chunk.length; + if (totalBytes > 0) { + const percent = Math.floor((downloaded / totalBytes) * 100); + if (percent !== lastPercent && percent % 10 === 0) { + lastPercent = percent; + const mb = (downloaded / 1024 / 1024).toFixed(1); + const totalMb = (totalBytes / 1024 / 1024).toFixed(1); + process.stdout.write(`\r[Puppeteer] Downloading... ${mb}MB / ${totalMb}MB (${percent}%)`); + } + } + }); + + res.pipe(file); + + file.on("finish", () => { + file.close(() => { + process.stdout.write("\n"); + try { + if (fs.existsSync(dest)) fs.unlinkSync(dest); + fs.renameSync(tmpDest, dest); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + + file.on("error", (err) => { + fs.unlink(tmpDest, () => {}); + reject(err); + }); + }).on("error", (err) => { + fs.unlink(tmpDest, () => {}); + reject(err); + }); + }; + + request(url); + }); +} + +// Chrome for Testing API에서 chrome-headless-shell 직접 다운로드 +async function downloadDirect(chromiumDir: string): Promise { + console.log("[Puppeteer] Fetching Chrome for Testing download URL..."); + + const json = await fetchJson(CHROME_FOR_TESTING_JSON) as { + channels: { + Stable: { + version: string; + downloads: { + "chrome-headless-shell"?: Array<{ platform: string; url: string }>; + }; + }; + }; + }; + + const downloads = json.channels.Stable.downloads["chrome-headless-shell"]; + if (!downloads) { + throw new Error("chrome-headless-shell downloads not found in API response"); + } + + const win64 = downloads.find((d) => d.platform === "win64"); + if (!win64) { + throw new Error("win64 platform not found in chrome-headless-shell downloads"); + } + + const version = json.channels.Stable.version; + console.log(`[Puppeteer] Downloading chrome-headless-shell v${version} (win64)...`); + + const zipPath = path.join(chromiumDir, "chrome-headless-shell.zip"); + + // chromium 폴더 생성 + if (!fs.existsSync(chromiumDir)) { + fs.mkdirSync(chromiumDir, { recursive: true }); + } + + await downloadFile(win64.url, zipPath); + console.log("[Puppeteer] Download complete. Extracting..."); + + // PowerShell Expand-Archive로 ZIP 해제 + execSync( + `powershell -NoProfile -Command "Expand-Archive -Path '${zipPath}' -DestinationPath '${chromiumDir}' -Force"`, + { stdio: "inherit" }, + ); + + // ZIP 파일 정리 + try { + fs.unlinkSync(zipPath); + } catch { + // ignore + } + + console.log("[Puppeteer] Extraction complete!"); +} + // Puppeteer 내장 브라우저 사용 시도 function tryPuppeteerCache(): string | null { // Puppeteer가 설치한 기본 경로 확인 @@ -131,62 +316,73 @@ function tryPuppeteerCache(): string | null { // Chromium 다운로드 및 설치 export async function ensureChromium(): Promise { + // 1. CHROMIUM_PATH 환경변수 + const envPath = process.env.CHROMIUM_PATH; + if (envPath && fs.existsSync(envPath)) { + console.log(`[Puppeteer] Using CHROMIUM_PATH: ${envPath}`); + return envPath; + } + const chromiumDir = getChromiumPath(); - // 1. 이미 우리 폴더에 설치되어 있으면 사용 + // 2. 이미 .chromium 폴더에 설치되어 있으면 사용 let executable = findChromiumExecutable(chromiumDir); if (executable) { console.log(`[Puppeteer] Chromium found: ${executable}`); return executable; } - // 2. Puppeteer 캐시에서 찾기 + // 3. Puppeteer 캐시에서 찾기 const cachedChrome = tryPuppeteerCache(); if (cachedChrome) { console.log(`[Puppeteer] Using cached Chromium: ${cachedChrome}`); return cachedChrome; } - // 3. 다운로드 필요 + // 4. 시스템 Chrome 설치 경로 탐색 + const systemChrome = findSystemChrome(); + if (systemChrome) { + console.log(`[Puppeteer] Using system Chrome: ${systemChrome}`); + return systemChrome; + } + + // 5. HTTPS 직접 다운로드 (npx 불필요) console.log(`[Puppeteer] Chromium not found. Downloading to ${chromiumDir}...`); console.log("[Puppeteer] This may take a few minutes on first run."); - // chromium 폴더 생성 + try { + await downloadDirect(chromiumDir); + console.log("[Puppeteer] Direct download complete!"); + + executable = findChromiumExecutable(chromiumDir); + if (executable) { + return executable; + } + + throw new Error("Direct download completed but executable not found"); + } catch (directError) { + console.warn("[Puppeteer] Direct download failed:", directError); + console.log("[Puppeteer] Falling back to npx..."); + } + + // 6. npx fallback (최후 수단) if (!fs.existsSync(chromiumDir)) { fs.mkdirSync(chromiumDir, { recursive: true }); } try { await downloadWithNpx(chromiumDir); - console.log("[Puppeteer] Download complete!"); + console.log("[Puppeteer] npx download complete!"); - // 다운로드 후 다시 찾기 executable = findChromiumExecutable(chromiumDir); if (executable) { return executable; } - throw new Error("Chromium download completed but executable not found"); - } catch (error) { - console.error("[Puppeteer] Failed to download Chromium:", error); - console.log("[Puppeteer] Trying fallback: manual puppeteer install..."); - - // Fallback: puppeteer browsers 직접 설치 시도 - try { - execSync("npx puppeteer browsers install chrome-headless-shell", { - stdio: "inherit", - cwd: path.dirname(chromiumDir), - }); - - const fallbackExe = tryPuppeteerCache(); - if (fallbackExe) { - return fallbackExe; - } - } catch (fallbackError) { - console.error("[Puppeteer] Fallback also failed:", fallbackError); - } - - throw error; + throw new Error("npx download completed but executable not found"); + } catch (npxError) { + console.error("[Puppeteer] npx fallback also failed:", npxError); + throw npxError; } }