/** * Storage의 reward-products/ 이미지 전체를 리사이즈 + WebP로 일괄 최적화한다. * 같은 오브젝트 경로에 덮어쓰고 다운로드 토큰을 보존하므로 Firestore에 저장된 URL은 그대로 유효하다. * 실행: npm run optimize:reward-images */ import { execFileSync, execSync } from "node:child_process"; import { OPTIMIZED_FLAG, optimizeRewardImage, targetForPath } from "../src/services/imageOptimizeService"; const storageBucket = "mmday-panit.firebasestorage.app"; const accessToken = (process.platform === "win32" ? execSync("gcloud auth print-access-token", { encoding: "utf8", windowsHide: true }) : execFileSync("gcloud", ["auth", "print-access-token"], { encoding: "utf8", windowsHide: true })).trim(); const apiBase = `https://storage.googleapis.com/storage/v1/b/${storageBucket}/o`; const uploadBase = `https://storage.googleapis.com/upload/storage/v1/b/${storageBucket}/o`; const authHeader = { Authorization: `Bearer ${accessToken}` }; interface StorageObject { name: string; size: string; contentType?: string; metadata?: Record; } async function listObjects(): Promise { const items: StorageObject[] = []; let pageToken: string | undefined; do { const params = new URLSearchParams({ prefix: "reward-products/", fields: "items(name,size,contentType,metadata),nextPageToken", }); if (pageToken) params.set("pageToken", pageToken); const res = await fetch(`${apiBase}?${params}`, { headers: authHeader }); if (!res.ok) throw new Error(`list failed: ${res.status} ${await res.text()}`); const body = await res.json() as { items?: StorageObject[]; nextPageToken?: string }; items.push(...(body.items ?? [])); pageToken = body.nextPageToken; } while (pageToken); return items; } async function download(name: string): Promise { const res = await fetch(`${apiBase}/${encodeURIComponent(name)}?alt=media`, { headers: authHeader }); if (!res.ok) throw new Error(`download failed for ${name}: ${res.status}`); return Buffer.from(await res.arrayBuffer()); } async function markOptimized(name: string): Promise { const res = await fetch(`${apiBase}/${encodeURIComponent(name)}`, { method: "PATCH", headers: { ...authHeader, "Content-Type": "application/json" }, body: JSON.stringify({ metadata: { [OPTIMIZED_FLAG]: "true" } }), }); if (!res.ok) throw new Error(`metadata patch failed for ${name}: ${res.status} ${await res.text()}`); } /** multipart 업로드로 본문과 메타데이터(토큰 보존 + 최적화 플래그)를 한 번에 교체한다. */ async function overwrite(obj: StorageObject, data: Buffer): Promise { const boundary = "reward-image-optimize-boundary"; const metadata = { name: obj.name, contentType: "image/webp", metadata: { ...(obj.metadata ?? {}), [OPTIMIZED_FLAG]: "true" }, }; const body = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n` + `${JSON.stringify(metadata)}\r\n` + `--${boundary}\r\nContent-Type: image/webp\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--`), ]); const res = await fetch(`${uploadBase}?uploadType=multipart`, { method: "POST", headers: { ...authHeader, "Content-Type": `multipart/related; boundary=${boundary}` }, body, }); if (!res.ok) throw new Error(`upload failed for ${obj.name}: ${res.status} ${await res.text()}`); } async function main() { const objects = await listObjects(); let before = 0; let after = 0; for (const obj of objects) { const size = Number(obj.size); const target = targetForPath(obj.name); if (!target) { console.log(`skip (path) ${obj.name}`); continue; } if (obj.metadata?.[OPTIMIZED_FLAG] === "true") { console.log(`skip (done) ${obj.name}`); before += size; after += size; continue; } const input = await download(obj.name); const output = await optimizeRewardImage(input, target); before += size; if (output.data.length >= input.length) { await markOptimized(obj.name); after += size; console.log(`skip (small) ${obj.name} ${(size / 1024).toFixed(0)}KB`); continue; } await overwrite(obj, output.data); after += output.data.length; console.log( `optimized ${obj.name} ` + `${(size / 1048576).toFixed(1)}MB -> ${(output.data.length / 1024).toFixed(0)}KB ` + `(${output.width}x${output.height})`); } console.log(`\nTOTAL ${objects.length} files: ${(before / 1048576).toFixed(1)}MB -> ${(after / 1048576).toFixed(1)}MB`); } main().catch((err) => { console.error(err); process.exit(1); });