feat: xavis ai_platform 기능 이전 및 ncue 환경 전환
xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영. @xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
293
scripts/capture-menu-screenshots.mjs
Normal file
293
scripts/capture-menu-screenshots.mjs
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* AI Platform 메뉴 화면 캡처 (Playwright)
|
||||
* - 로그인: 운영 URL (PROD 전용 화면)
|
||||
* - 나머지: 로컬 SUPER 모드 서버 (인증 없이 캡처)
|
||||
* - 대시보드: OPS 세션 쿠키(허용 이메일) 주입 후 캡처
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs/promises";
|
||||
import fsSync from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_DIR =
|
||||
process.env.CAPTURE_OUT_DIR ||
|
||||
path.join(__dirname, "..", "docs", "ppt-screenshots");
|
||||
const LOCAL_BASE = process.env.CAPTURE_BASE_URL || "http://127.0.0.1:8030";
|
||||
const PROD_BASE = (process.env.CAPTURE_PROD_BASE || "https://ai.xavis.co.kr").replace(/\/$/, "");
|
||||
const PROD_LOGIN = `${PROD_BASE}/login`;
|
||||
/** 목록(학습·사례)은 운영 데이터·전체 로드 후 캡처 */
|
||||
const CAPTURE_LISTS_FROM_PROD = process.env.CAPTURE_LISTS_FROM_PROD !== "0";
|
||||
/** 일반 사용자 OPS 세션 (관리자·특수 메뉴 쿠키 없음) */
|
||||
const CAPTURE_USER_EMAIL = (
|
||||
process.env.CAPTURE_USER_EMAIL || "employee@ncue.net"
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
/** general: 가이드봇·WM·대시보드·체크리스트 제외 캡처 */
|
||||
const CAPTURE_PROFILE = (process.env.CAPTURE_PROFILE || "general").trim().toLowerCase();
|
||||
|
||||
const LOCAL_PAGES_ALL = [
|
||||
{ id: "ai-explore", path: "/ai-explore", waitMs: 800 },
|
||||
{ id: "meeting-minutes", path: "/ai-explore/meeting-minutes", waitMs: 1200 },
|
||||
{ id: "task-checklist", path: "/ai-explore/task-checklist", waitMs: 1200 },
|
||||
{ id: "chat", path: "/ai-explore/chat", waitMs: 800 },
|
||||
{ id: "fscan", path: "/ai-explore/fscan", waitMs: 2000 },
|
||||
{ id: "prompts", path: "/ai-explore/prompts", waitMs: 1500 },
|
||||
{ id: "ax-apply", path: "/ax-apply", waitMs: 1000 },
|
||||
];
|
||||
|
||||
/** 목록 패널 전체(element) 캡처 — viewport 잘림 방지 */
|
||||
const LIST_PANEL_PAGES = [
|
||||
{
|
||||
id: "learning",
|
||||
path: "/learning",
|
||||
selector: "section.panel:has(#lecture-results-root)",
|
||||
useProd: true,
|
||||
preload: "learning",
|
||||
},
|
||||
{
|
||||
id: "ai-cases",
|
||||
path: "/ai-cases",
|
||||
selector: "section.panel:has(.success-story-grid)",
|
||||
useProd: true,
|
||||
preload: "none",
|
||||
},
|
||||
{
|
||||
id: "ai-cases-compose",
|
||||
path: "/ai-cases/compose",
|
||||
selector: "main.use-case-compose",
|
||||
useProd: true,
|
||||
preload: "none",
|
||||
},
|
||||
];
|
||||
|
||||
const LOCAL_PAGES =
|
||||
CAPTURE_PROFILE === "general"
|
||||
? LOCAL_PAGES_ALL.filter(
|
||||
(p) => !["task-checklist", "dashboard", "dashboard-business-performance"].includes(p.id)
|
||||
)
|
||||
: LOCAL_PAGES_ALL;
|
||||
|
||||
/** 일반 사용자 좌측 메뉴(가이드봇·WM·대시보드 없음) */
|
||||
const MENU_OVERVIEW = { id: "menu-overview", path: "/ai-explore", waitMs: 1000 };
|
||||
|
||||
function readEnvValue(key) {
|
||||
const envPath = path.join(__dirname, "..", ".env");
|
||||
try {
|
||||
const raw = fsSync.readFileSync(envPath, "utf8");
|
||||
const re = new RegExp(`^${key}=(.+)$`, "m");
|
||||
const m = raw.match(re);
|
||||
if (!m) return "";
|
||||
return m[1].split("#")[0].trim().replace(/^["']|["']$/g, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** ops-auth.js 와 동일한 세션 쿠키 서명 */
|
||||
function buildOpsSessionCookie(email) {
|
||||
const secret = readEnvValue("AUTH_SECRET") || readEnvValue("ADMIN_TOKEN") || "ncue-admin";
|
||||
const exp = Number.MAX_SAFE_INTEGER;
|
||||
const iat = Date.now();
|
||||
const payload = `${email}|${exp}|${iat}`;
|
||||
const sig = crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
||||
return Buffer.from(JSON.stringify({ email, exp, iat, sig })).toString("base64url");
|
||||
}
|
||||
|
||||
function captureUserEmail() {
|
||||
return CAPTURE_USER_EMAIL;
|
||||
}
|
||||
|
||||
async function capturePage(page, url, outPath, waitMs = 1000, fullPage = false) {
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 90000 });
|
||||
await page.waitForTimeout(waitMs);
|
||||
await page.screenshot({ path: outPath, fullPage });
|
||||
console.log(" saved:", outPath);
|
||||
}
|
||||
|
||||
/** 학습센터 무한 스크롤 — 모든 페이지 로드 */
|
||||
async function loadAllLearningPages(page) {
|
||||
await page.waitForSelector("#lecture-grid", { timeout: 60000 });
|
||||
let guard = 0;
|
||||
while (guard < 80) {
|
||||
guard += 1;
|
||||
const state = await page.evaluate(() => {
|
||||
const sentinel = document.getElementById("infinite-scroll-sentinel");
|
||||
const btn = document.getElementById("lecture-load-more-btn");
|
||||
const countEl = document.getElementById("lecture-total-count");
|
||||
const cards = document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card");
|
||||
return {
|
||||
hasNext: !!(sentinel && sentinel.getAttribute("data-has-next") === "true"),
|
||||
hasBtn: !!btn,
|
||||
total: countEl ? countEl.textContent.trim() : "",
|
||||
loaded: cards.length,
|
||||
};
|
||||
});
|
||||
if (!state.hasNext) break;
|
||||
if (state.hasBtn) {
|
||||
await page.locator("#lecture-load-more-btn").click({ timeout: 5000 }).catch(() => {});
|
||||
} else {
|
||||
await page.evaluate(() => {
|
||||
const s = document.getElementById("infinite-scroll-sentinel");
|
||||
if (s) s.scrollIntoView({ block: "end", behavior: "instant" });
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
});
|
||||
}
|
||||
await page.waitForTimeout(900);
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const el = document.getElementById("infinite-scroll-loading");
|
||||
return !el || el.style.display === "none" || el.style.display === "";
|
||||
},
|
||||
{ timeout: 45000 }
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
const finalCount = await page.evaluate(() => {
|
||||
const countEl = document.getElementById("lecture-total-count");
|
||||
const cards = document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card");
|
||||
return { total: countEl ? countEl.textContent.trim() : "?", loaded: cards.length };
|
||||
});
|
||||
console.log(" learning loaded:", finalCount.loaded, "/ total", finalCount.total);
|
||||
}
|
||||
|
||||
/** 목록 섹션(element) 캡처 — 카드 전체가 포함되도록 */
|
||||
async function captureListPanel(page, baseUrl, item, outPath) {
|
||||
const url = `${baseUrl}${item.path}`;
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 90000 });
|
||||
if (item.preload === "learning") {
|
||||
await loadAllLearningPages(page);
|
||||
}
|
||||
await page.waitForSelector(item.selector, { timeout: 60000 });
|
||||
await page.waitForTimeout(600);
|
||||
const panel = page.locator(item.selector).first();
|
||||
await panel.scrollIntoViewIfNeeded();
|
||||
await panel.screenshot({ path: outPath });
|
||||
const meta = await page.evaluate((sel) => {
|
||||
const cards =
|
||||
sel.includes("lecture")
|
||||
? document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card").length
|
||||
: document.querySelectorAll(".success-story-grid .success-story-card, .success-story-grid a").length;
|
||||
const chip = document.querySelector(".count-chip");
|
||||
return { cards, chip: chip ? chip.textContent.trim() : "" };
|
||||
}, item.selector);
|
||||
console.log(" saved:", outPath, meta.chip || "", "visible cards:", meta.cards);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
locale: "ko-KR",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const userEmail = captureUserEmail();
|
||||
const opsCookie = buildOpsSessionCookie(userEmail);
|
||||
const cookieJar = [
|
||||
{
|
||||
name: "ops_user_session",
|
||||
value: opsCookie,
|
||||
url: LOCAL_BASE,
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
},
|
||||
];
|
||||
if (CAPTURE_LISTS_FROM_PROD) {
|
||||
cookieJar.push({
|
||||
name: "ops_user_session",
|
||||
value: opsCookie,
|
||||
domain: new URL(PROD_BASE).hostname,
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "Lax",
|
||||
});
|
||||
}
|
||||
await context.addCookies(cookieJar);
|
||||
console.log("Capture persona:", userEmail, "(no admin cookie)", "profile:", CAPTURE_PROFILE);
|
||||
console.log("Output dir:", OUT_DIR);
|
||||
|
||||
console.log("[1/4] Login (production)...");
|
||||
try {
|
||||
await capturePage(
|
||||
page,
|
||||
PROD_LOGIN,
|
||||
path.join(OUT_DIR, "login.png"),
|
||||
1500
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(" login capture failed:", err.message);
|
||||
}
|
||||
|
||||
console.log("[2/4] Local menus (PROD + OPS cookie, no admin)...");
|
||||
for (const item of LOCAL_PAGES) {
|
||||
const outPath = path.join(OUT_DIR, `${item.id}.png`);
|
||||
try {
|
||||
await capturePage(page, `${LOCAL_BASE}${item.path}`, outPath, item.waitMs);
|
||||
} catch (err) {
|
||||
console.warn(` ${item.id} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[2b/4] List panels (full load + element capture)...");
|
||||
for (const item of LIST_PANEL_PAGES) {
|
||||
const outPath = path.join(OUT_DIR, `${item.id}.png`);
|
||||
const base = item.useProd && CAPTURE_LISTS_FROM_PROD ? PROD_BASE : LOCAL_BASE;
|
||||
try {
|
||||
await captureListPanel(page, base, item, outPath);
|
||||
} catch (err) {
|
||||
console.warn(` ${item.id} list panel failed (${base}):`, err.message);
|
||||
if (base !== LOCAL_BASE) {
|
||||
try {
|
||||
await captureListPanel(page, LOCAL_BASE, item, outPath);
|
||||
} catch (err2) {
|
||||
console.warn(` ${item.id} local fallback failed:`, err2.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[3/4] Menu overview...");
|
||||
try {
|
||||
await capturePage(
|
||||
page,
|
||||
`${LOCAL_BASE}${MENU_OVERVIEW.path}`,
|
||||
path.join(OUT_DIR, `${MENU_OVERVIEW.id}.png`),
|
||||
MENU_OVERVIEW.waitMs
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(" menu-overview failed:", err.message);
|
||||
}
|
||||
|
||||
if (CAPTURE_PROFILE !== "general") {
|
||||
console.log("[4/4] Dashboard (허용 계정:", userEmail, ")...");
|
||||
for (const item of [
|
||||
{ id: "dashboard", path: "/dashboard", waitMs: 1000 },
|
||||
{ id: "dashboard-business-performance", path: "/dashboard/business-performance", waitMs: 2500 },
|
||||
]) {
|
||||
const outPath = path.join(OUT_DIR, `${item.id}.png`);
|
||||
try {
|
||||
await capturePage(page, `${LOCAL_BASE}${item.path}`, outPath, item.waitMs);
|
||||
} catch (err) {
|
||||
console.warn(` ${item.id} failed:`, err.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("[4/4] Dashboard skip (general profile)");
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log("Done. Output:", OUT_DIR);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user