// 한남자리·철판클럽 전용 캡처. // // 이 스크립트가 덮어쓰는 파일은 아래 여섯 개뿐이다. // src/assets/shots/work-hannam-jari.png // src/assets/shots/work-hannam-jari-2.png // src/assets/shots/work-griddle-club.png // src/assets/shots/work-griddle-club-2.png // public/work/hannam-jari/og.png // public/work/griddle-club/og.png // // 공개 자산과 같은 HTTP 경로로 상대 링크를 검증하고, 폰트·이미지가 준비되지 않으면 // 불완전한 화면을 저장하지 않고 실패한다. import fs from "node:fs"; import http from "node:http"; import path from "node:path"; import url from "node:url"; import puppeteer from "puppeteer-core"; const here = path.dirname(url.fileURLToPath(import.meta.url)); const root = path.resolve(here, ".."); const publicRoot = path.join(root, "public"); const shotsRoot = path.join(root, "src", "assets", "shots"); const chrome = [ process.env.CHROME_PATH, "C:/Program Files/Google/Chrome/Application/chrome.exe", "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", "/usr/bin/google-chrome", "/usr/bin/chromium", ].find((candidate) => candidate && fs.existsSync(candidate)); if (!chrome) { throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH or install Chrome/Chromium."); } const MIME = new Map([ [".avif", "image/avif"], [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], [".ico", "image/x-icon"], [".jpeg", "image/jpeg"], [".jpg", "image/jpeg"], [".js", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"], [".png", "image/png"], [".svg", "image/svg+xml"], [".webmanifest", "application/manifest+json; charset=utf-8"], [".webp", "image/webp"], [".woff", "font/woff"], [".woff2", "font/woff2"], ]); const jobs = [ { label: "한남자리 공개", route: "/work/hannam-jari/", output: path.join(shotsRoot, "work-hannam-jari.png"), }, { label: "한남자리 관리자", route: "/work/hannam-jari/admin/", output: path.join(shotsRoot, "work-hannam-jari-2.png"), }, { label: "철판클럽 공개", route: "/work/griddle-club/", output: path.join(shotsRoot, "work-griddle-club.png"), }, { label: "철판클럽 가맹", route: "/work/griddle-club/franchise/", output: path.join(shotsRoot, "work-griddle-club-2.png"), }, ]; const ogJobs = [ { label: "한남자리 OG", route: "/work/hannam-jari/", output: path.join(publicRoot, "work", "hannam-jari", "og.png"), }, { label: "철판클럽 OG", route: "/work/griddle-club/", output: path.join(publicRoot, "work", "griddle-club", "og.png"), }, ]; function inside(rootDirectory, candidate) { const relative = path.relative(rootDirectory, candidate); return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative)); } const server = http.createServer((request, response) => { try { const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname); const candidate = path.resolve(publicRoot, `.${pathname}`); if (!inside(publicRoot, candidate)) { response.writeHead(403).end("forbidden"); return; } const file = fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() ? path.join(candidate, "index.html") : candidate; if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404).end("not found"); return; } response.setHeader("Content-Type", MIME.get(path.extname(file).toLowerCase()) || "application/octet-stream"); response.setHeader("Cache-Control", "no-store"); fs.createReadStream(file).pipe(response); } catch { response.writeHead(400).end("bad request"); } }); await fs.promises.mkdir(shotsRoot, { recursive: true }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const address = server.address(); const origin = `http://127.0.0.1:${address.port}`; let browser; async function waitForReady(page) { await Promise.race([ page.evaluate(() => document.fonts?.ready || Promise.resolve()), new Promise((_, reject) => setTimeout(() => reject(new Error("Font readiness timed out")), 10000)), ]); await page.evaluate(async () => { const images = [...document.images]; for (const image of images) image.loading = "eager"; await Promise.allSettled(images.map((image) => image.decode())); }); await new Promise((resolve) => setTimeout(resolve, 900)); const broken = await page.evaluate(() => [...document.images] .filter((image) => image.complete && image.naturalWidth === 0) .map((image) => image.currentSrc || image.src)); if (broken.length) throw new Error(`Broken images: ${broken.join(", ")}`); await page.addStyleTag({ content: "*{animation:none!important;transition:none!important;caret-color:transparent!important}html{scroll-behavior:auto!important}::-webkit-scrollbar{display:none!important}", }); await page.evaluate(() => window.scrollTo(0, 0)); } async function capture(job, viewport) { const page = await browser.newPage(); const failures = []; page.on("pageerror", (error) => failures.push(`pageerror ${error.message}`)); page.on("response", (response) => { if (response.url().startsWith(origin) && response.status() >= 400) { failures.push(`${response.status()} ${response.url()}`); } }); try { await page.setCacheEnabled(false); await page.setViewport(viewport); const response = await page.goto(origin + job.route, { waitUntil: "networkidle0", timeout: 30000 }); if (!response?.ok()) throw new Error(`${job.route} returned ${response?.status() ?? "no response"}`); await waitForReady(page); if (failures.length) throw new Error(failures.join(" · ")); await fs.promises.mkdir(path.dirname(job.output), { recursive: true }); await page.screenshot({ path: job.output, type: "png", fullPage: false }); const size = Math.round((await fs.promises.stat(job.output)).size / 1024); console.log(`CAPTURE ${job.label} → ${path.relative(root, job.output)} (${viewport.width}×${viewport.height}, ${size}KB)`); } finally { await page.close(); } } try { console.log("OVERWRITE SCOPE"); for (const job of [...jobs, ...ogJobs]) console.log(`- ${path.relative(root, job.output)}`); browser = await puppeteer.launch({ executablePath: chrome, headless: "new", args: [ "--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--force-device-scale-factor=1", ], }); for (const job of jobs) await capture(job, { width: 1440, height: 900, deviceScaleFactor: 1 }); for (const job of ogJobs) await capture(job, { width: 1200, height: 630, deviceScaleFactor: 1 }); } finally { if (browser) await browser.close(); await new Promise((resolve) => server.close(resolve)); } console.log("쇼케이스 듀오 카드 4장과 OG 2장 캡처 완료");