0951dbfb8a
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
201 lines
8.2 KiB
JavaScript
201 lines
8.2 KiB
JavaScript
/**
|
|
* Capture blog-post screenshots of the product's Models page (the free-model rows).
|
|
*
|
|
* A deliberately lean sibling of capture-shots.mjs: that script drives a full scripted
|
|
* LLM conversation for the landing hero shots, while the Models page needs no LLM at
|
|
* all — a fresh user's default Project already presets the entire built-in model
|
|
* catalog, so the free rows (Ling 3.0 Flash / Nemotron 3 Ultra / Laguna M.1 /
|
|
* openrouter/free) render their light-yellow "Free" badges straight from the $0
|
|
* pricing. Flow: boot the Web server against a temp data root serving the built web
|
|
* dist -> provision one demo user per UI language (password rotated once so the
|
|
* initial-password banner never shows) -> open /models, scroll the OpenRouter group
|
|
* to the top of the viewport (Claude Opus 5 sits on its first card row, the free
|
|
* rows a few rows below) -> screenshot, light theme, zh + en, as
|
|
* free-models-page-<lang>-light.webp (re-encoded to WebP inside Chromium, same as
|
|
* capture-shots.mjs).
|
|
*
|
|
* Output is a two-step flow, because blog images are not committed to this repo:
|
|
* 1. this script writes into the gitignored staging dir packages/landing/.blog-assets/;
|
|
* 2. upload the files it produced to the `blog-assets/` directory of the sibling
|
|
* `Prism-Shadow/penguin-harness-community` repo, which is what the posts load from
|
|
* (the renderer resolves /blog-assets/<name> there — see src/lib/links.ts).
|
|
*
|
|
* Prereqs: `pnpm --filter @prismshadow/penguin-{skills,core,server,web} build` and
|
|
* Playwright's chromium. Run: `node scripts/capture-blog-shots.mjs` (or
|
|
* `pnpm --filter @prismshadow/penguin-landing blog-shots`).
|
|
*/
|
|
import { spawn } from "node:child_process";
|
|
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { chromium } from "@playwright/test";
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(HERE, "../../..");
|
|
// Gitignored staging dir: these images are hosted in the community repo, not committed here.
|
|
const OUT_DIR = path.resolve(HERE, "../.blog-assets");
|
|
const SRV_PORT = 8944; // Distinct from capture-shots.mjs (8940/8941) so both can run.
|
|
// On loopback binds the App is canonically served on `localhost`; the 127.0.0.1
|
|
// counterpart is the Workspace-preview host, where /api deliberately answers 401
|
|
// (see server app.ts's canonical-host guard).
|
|
const BASE = `http://localhost:${SRV_PORT}`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server + API helpers (same shape as capture-shots.mjs).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function waitFor(url, tries = 60) {
|
|
for (let i = 0; i < tries; i++) {
|
|
try {
|
|
const res = await fetch(url);
|
|
if (res.ok) return;
|
|
} catch {}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
throw new Error(`server not ready: ${url}`);
|
|
}
|
|
|
|
async function api(cookie, method, url, body) {
|
|
const res = await fetch(`${BASE}${url}`, {
|
|
method,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(cookie ? { cookie } : {}),
|
|
},
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
if (!res.ok) throw new Error(`${method} ${url} -> ${res.status} ${await res.text()}`);
|
|
return { json: await res.json().catch(() => ({})), setCookie: res.headers.get("set-cookie") };
|
|
}
|
|
|
|
async function login(userId, password) {
|
|
const { setCookie } = await api(null, "POST", "/api/auth/login", { userId, password });
|
|
if (!setCookie) throw new Error("no session cookie from login");
|
|
return setCookie.split(";")[0];
|
|
}
|
|
|
|
/**
|
|
* Per-language demo user (same ids as capture-shots.mjs). Unlike that script it does NOT
|
|
* touch the model table: the fresh Project's preset catalog is exactly what the shot is
|
|
* about. The password is rotated once so the initial-password banner stays out of frame.
|
|
*/
|
|
async function provisionUser(adminCookie, lang) {
|
|
const userId = lang === "zh" ? "demo" : "alex";
|
|
const initial = `${userId}12345`;
|
|
await api(adminCookie, "POST", "/api/admin/users", { userId, password: initial }).catch((e) => {
|
|
if (!String(e).includes("409")) throw e;
|
|
});
|
|
let password = initial;
|
|
const cookie = await login(userId, initial);
|
|
try {
|
|
await api(cookie, "PUT", "/api/me/password", {
|
|
oldPassword: initial,
|
|
newPassword: `penguin-${userId}-2026`,
|
|
});
|
|
password = `penguin-${userId}-2026`;
|
|
} catch {}
|
|
return { userId, password };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const dataRoot = mkdtempSync(path.join(os.tmpdir(), "penguin-blog-shots-"));
|
|
mkdirSync(OUT_DIR, { recursive: true });
|
|
|
|
const srv = spawn("node", [path.join(ROOT, "packages/server/dist/index.js")], {
|
|
env: {
|
|
...process.env,
|
|
PENGUIN_HOME: path.join(dataRoot, "home"),
|
|
PENGUIN_WEB_DB: path.join(dataRoot, "web.db"),
|
|
PENGUIN_WEB_DIST: path.join(ROOT, "packages/web/dist"),
|
|
PORT: String(SRV_PORT),
|
|
HOST: "127.0.0.1",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
srv.stderr.on("data", (d) => process.stderr.write(`[srv!] ${d}`));
|
|
process.on("exit", () => {
|
|
try {
|
|
srv.kill();
|
|
} catch {}
|
|
});
|
|
|
|
try {
|
|
await waitFor(`${BASE}/`);
|
|
console.log(`[blog-shots] server ready on ${BASE}`);
|
|
|
|
const adminCookie = await login("admin", "penguin-2026");
|
|
const browser = await chromium.launch();
|
|
|
|
// WebP encoder: Chromium re-encodes the PNG screenshot buffer via canvas (same
|
|
// convention and quality as capture-shots.mjs, keeping repo assets small).
|
|
const encoderPage = await browser.newPage();
|
|
async function saveWebp(pngBuffer, fileName) {
|
|
const dataUrl = await encoderPage.evaluate(async (b64) => {
|
|
const img = new Image();
|
|
img.src = `data:image/png;base64,${b64}`;
|
|
await img.decode();
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
canvas.getContext("2d").drawImage(img, 0, 0);
|
|
return canvas.toDataURL("image/webp", 0.82);
|
|
}, pngBuffer.toString("base64"));
|
|
writeFileSync(path.join(OUT_DIR, fileName), Buffer.from(dataUrl.split(",")[1], "base64"));
|
|
console.log(`[blog-shots] ${fileName}`);
|
|
}
|
|
|
|
for (const lang of ["zh", "en"]) {
|
|
const user = await provisionUser(adminCookie, lang);
|
|
|
|
// 1280x900 @1.5x, light theme (the blog embeds a single light variant per language,
|
|
// like rag-app-<lang>-light.webp): tall enough that the OpenRouter group shows Claude
|
|
// Opus 5 (first card row) together with the free rows and their badges.
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1280, height: 900 },
|
|
deviceScaleFactor: 1.5,
|
|
locale: lang === "zh" ? "zh-CN" : "en-US",
|
|
});
|
|
await context.addInitScript(
|
|
([t, l]) => {
|
|
localStorage.setItem("penguin.theme", t);
|
|
localStorage.setItem("penguin.lang", l);
|
|
},
|
|
["light", lang],
|
|
);
|
|
const page = await context.newPage();
|
|
await page.goto(`${BASE}/login`);
|
|
const loginRes = await page.request.post(`${BASE}/api/auth/login`, {
|
|
data: { userId: user.userId, password: user.password },
|
|
});
|
|
if (!loginRes.ok()) throw new Error(`browser login failed: ${loginRes.status()}`);
|
|
|
|
await page.goto(`${BASE}/models`);
|
|
// The catalog is loaded once a free row's card is rendered (its "Free" badge with it).
|
|
const lingCard = page.getByText("Ling 3.0 Flash (free)").first();
|
|
await lingCard.waitFor({ timeout: 20000 });
|
|
// Scroll the OpenRouter group section to the top of the viewport so the group header,
|
|
// Claude Opus 5 (first card row) and the badged free rows all sit in frame.
|
|
await page
|
|
.getByRole("button", { name: /OpenRouter/ })
|
|
.first()
|
|
.evaluate((el) =>
|
|
el.closest("section").scrollIntoView({ block: "start", behavior: "instant" }),
|
|
);
|
|
await page.waitForTimeout(1200);
|
|
await saveWebp(await page.screenshot(), `free-models-page-${lang}-light.webp`);
|
|
|
|
await context.close();
|
|
}
|
|
|
|
await browser.close();
|
|
console.log(`[blog-shots] done -> ${OUT_DIR}`);
|
|
process.exit(0);
|
|
} catch (err) {
|
|
console.error("[blog-shots] FAILED:", err);
|
|
process.exit(1);
|
|
}
|