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