designpaca/apps/site/tools/capture-showcase-duo.mjs
Yun Chan 6930906583
All checks were successful
ci / build (push) Successful in 42s
release / release (push) Successful in 1m5s
release: v0.10.0
2026-08-29 21:19:26 +09:00

194 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 한남자리·철판클럽 전용 캡처.
//
// 이 스크립트가 덮어쓰는 파일은 아래 여섯 개뿐이다.
// 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장 캡처 완료");