Files
OneOS-V2/scripts/capture-theme-homepage.mjs
2026-07-29 16:04:39 +08:00

1026 lines
31 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
const DEFAULT_CAPTURE_OPTIONS = {
viewport: { width: 1440, height: 1200 },
deviceScaleFactor: 1,
format: 'webp',
quality: 88,
timeoutMs: 60000,
settleTimeoutMs: 10000,
waitAfterLoadMs: 0,
waitUntil: 'load',
hideScrollbar: true,
scrollWarmup: true,
maxScrollSteps: 80,
maxScreenshotHeight: 16383,
headless: true,
};
const PLAYWRIGHT_CACHE_DIR = path.join(os.homedir(), '.cache', 'axure-extractor');
const CHROMIUM_ARGS = [
'--allow-file-access-from-files',
'--disable-web-security',
'--disable-blink-features=AutomationControlled',
'--ignore-certificate-errors',
'--no-sandbox',
];
function isObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function resolveMaybePath(value, baseDir) {
if (!value) return value;
return path.isAbsolute(value) ? value : path.resolve(baseDir, value);
}
function parseNumber(value, name) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`Invalid ${name}: ${value}`);
}
return parsed;
}
function parseInteger(value, name) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`Invalid ${name}: ${value}`);
}
return parsed;
}
export function normalizeViewport(value) {
if (isObject(value)) {
const width = parseInteger(value.width, 'viewport width');
const height = parseInteger(value.height, 'viewport height');
if (width <= 0 || height <= 0) {
throw new Error(`Invalid viewport: ${JSON.stringify(value)}`);
}
return { width, height };
}
if (typeof value !== 'string') {
throw new Error(`Invalid viewport: ${String(value)}`);
}
const match = value.trim().match(/^(\d+)x(\d+)$/i);
if (!match) {
throw new Error(`Invalid viewport: ${value}`);
}
return {
width: parseInteger(match[1], 'viewport width'),
height: parseInteger(match[2], 'viewport height'),
};
}
function parseHeader(value) {
const separatorIndex = value.includes('=') ? value.indexOf('=') : value.indexOf(':');
if (separatorIndex <= 0) {
throw new Error(`Invalid header, expected Name=Value: ${value}`);
}
const name = value.slice(0, separatorIndex).trim();
const headerValue = value.slice(separatorIndex + 1).trim();
if (!name) {
throw new Error(`Invalid header, expected Name=Value: ${value}`);
}
return [name, headerValue];
}
function readRequiredValue(argv, index, flag) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${flag} requires a value`);
}
return value;
}
export function parseCaptureArgs(argv = process.argv) {
const args = {
all: false,
help: false,
dryRun: false,
waitForSelectors: [],
dismissSelectors: [],
removeSelectors: [],
unstickSelectors: [],
headers: {},
};
const rawArgs = argv.slice(2);
for (let i = 0; i < rawArgs.length; i += 1) {
const flag = rawArgs[i];
if (flag === '--') {
continue;
} else if (flag === '--help' || flag === '-h') {
args.help = true;
} else if (flag === '--all') {
args.all = true;
} else if (flag === '--dry-run') {
args.dryRun = true;
} else if (flag === '--theme') {
args.theme = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--url') {
args.url = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--config') {
args.config = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--output') {
args.output = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--capture-root') {
args.captureRoot = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--viewport') {
args.viewport = normalizeViewport(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--device-scale-factor') {
args.deviceScaleFactor = parseNumber(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--format') {
args.format = readRequiredValue(rawArgs, i, flag).toLowerCase();
i += 1;
} else if (flag === '--quality') {
args.quality = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--timeout') {
args.timeoutMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--settle-timeout') {
args.settleTimeoutMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--wait') {
args.waitAfterLoadMs = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--wait-until') {
args.waitUntil = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--max-scroll-steps') {
args.maxScrollSteps = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--max-screenshot-height') {
args.maxScreenshotHeight = parseInteger(readRequiredValue(rawArgs, i, flag), flag);
i += 1;
} else if (flag === '--hide-scrollbar') {
args.hideScrollbar = true;
} else if (flag === '--show-scrollbar') {
args.hideScrollbar = false;
} else if (flag === '--scroll-warmup') {
args.scrollWarmup = true;
} else if (flag === '--no-scroll-warmup') {
args.scrollWarmup = false;
} else if (flag === '--headless') {
args.headless = true;
} else if (flag === '--no-headless') {
args.headless = false;
} else if (flag === '--connect-cdp') {
args.connectCdp = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--browser-executable') {
args.browserExecutable = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--storage-state') {
args.storageState = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--cookie-file') {
args.cookieFile = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--http-user') {
args.httpUser = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--http-pass') {
args.httpPass = readRequiredValue(rawArgs, i, flag);
i += 1;
} else if (flag === '--header') {
const [name, value] = parseHeader(readRequiredValue(rawArgs, i, flag));
args.headers[name] = value;
i += 1;
} else if (flag === '--wait-for-selector') {
args.waitForSelectors.push(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--dismiss-selector') {
args.dismissSelectors.push(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--remove-selector') {
args.removeSelectors.push(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag === '--unstick-selector') {
args.unstickSelectors.push(readRequiredValue(rawArgs, i, flag));
i += 1;
} else if (flag.startsWith('--')) {
throw new Error(`Unknown option: ${flag}`);
} else if (!args.theme) {
args.theme = flag;
} else if (!args.url) {
args.url = flag;
} else {
throw new Error(`Unexpected argument: ${flag}`);
}
}
return args;
}
export function findWorkspaceRoot(startDir = process.cwd()) {
let current = path.resolve(startDir);
while (true) {
if (fs.existsSync(path.join(current, 'pnpm-workspace.yaml'))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return path.resolve(startDir);
}
current = parent;
}
}
function normalizeSelectorList(value) {
if (!value) return [];
if (Array.isArray(value)) return value.filter(Boolean).map(String);
return [String(value)];
}
function normalizeHeaders(value) {
if (!value) return {};
if (Array.isArray(value)) {
return Object.fromEntries(value.map((item) => parseHeader(String(item))));
}
if (isObject(value)) {
return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, String(val)]));
}
throw new Error('headers must be an object or array');
}
function normalizeOptionBag(value = {}) {
const bag = { ...value };
if (bag.viewport) bag.viewport = normalizeViewport(bag.viewport);
if (bag.headers) bag.headers = normalizeHeaders(bag.headers);
if (bag.waitForSelectors) bag.waitForSelectors = normalizeSelectorList(bag.waitForSelectors);
if (bag.dismissSelectors) bag.dismissSelectors = normalizeSelectorList(bag.dismissSelectors);
if (bag.removeSelectors) bag.removeSelectors = normalizeSelectorList(bag.removeSelectors);
if (bag.unstickSelectors) bag.unstickSelectors = normalizeSelectorList(bag.unstickSelectors);
if (bag.format) bag.format = String(bag.format).toLowerCase();
return bag;
}
function normalizeConfigThemes(themes) {
if (!themes) return [];
if (Array.isArray(themes)) {
return themes.map((entry) => {
if (typeof entry === 'string') {
return { theme: entry };
}
return { ...entry };
});
}
if (isObject(themes)) {
return Object.entries(themes).map(([theme, entry]) => {
if (typeof entry === 'string') {
return { theme, url: entry };
}
return { theme, ...entry };
});
}
throw new Error('config.themes must be an object or array');
}
function readThemeSource(theme, appRoot) {
const themeJsonPath = path.join(appRoot, 'src', 'themes', theme, 'theme.json');
if (!fs.existsSync(themeJsonPath)) {
return {};
}
const themeJson = readJson(themeJsonPath);
return themeJson.source || {};
}
function inferThemeFromUrl(url) {
try {
return new URL(url).hostname.replace(/^www\./, '').split('.')[0] || 'homepage';
} catch {
return 'homepage';
}
}
function firstNonEmpty(...values) {
return values.find((value) => typeof value === 'string' && value.length > 0);
}
function pickCaptureOptions(args) {
const optionKeys = [
'output',
'captureRoot',
'viewport',
'deviceScaleFactor',
'format',
'quality',
'timeoutMs',
'settleTimeoutMs',
'waitAfterLoadMs',
'waitUntil',
'hideScrollbar',
'scrollWarmup',
'maxScrollSteps',
'maxScreenshotHeight',
'headless',
'connectCdp',
'browserExecutable',
'storageState',
'cookieFile',
'httpUser',
'httpPass',
'headers',
'waitForSelectors',
'dismissSelectors',
'removeSelectors',
'unstickSelectors',
];
return Object.fromEntries(
optionKeys
.filter((key) => args[key] !== undefined)
.map((key) => [key, args[key]]),
);
}
function mergeJobOptions(...bags) {
const job = {};
for (const rawBag of bags) {
const bag = normalizeOptionBag(rawBag);
for (const [key, value] of Object.entries(bag)) {
if (value === undefined) continue;
if (
key === 'waitForSelectors'
|| key === 'dismissSelectors'
|| key === 'removeSelectors'
|| key === 'unstickSelectors'
) {
job[key] = [...(job[key] || []), ...normalizeSelectorList(value)];
} else if (key === 'headers') {
job.headers = { ...(job.headers || {}), ...normalizeHeaders(value) };
} else {
job[key] = value;
}
}
}
return job;
}
function resolveOutputPath({ output, theme, format, appRoot, multiple }) {
if (!output) {
return path.join(appRoot, 'src', 'themes', theme, 'assets', `official-homepage.${format}`);
}
const resolved = resolveMaybePath(output, appRoot);
const extension = path.extname(resolved);
if (extension && !multiple) {
return resolved;
}
return path.join(resolved, theme, `official-homepage.${format}`);
}
function resolveConfigPath(configPath, appRoot) {
return resolveMaybePath(configPath, appRoot);
}
export function resolveCaptureJobs(args, options = {}) {
const appRoot = path.resolve(options.appRoot || process.cwd());
const workspaceRoot = path.resolve(options.workspaceRoot || findWorkspaceRoot(appRoot));
const config = args.config ? readJson(resolveConfigPath(args.config, appRoot)) : {};
const configThemes = normalizeConfigThemes(config.themes);
let entries = [];
if (args.all) {
if (configThemes.length === 0) {
throw new Error('--all requires a config file with themes');
}
entries = configThemes;
} else {
const theme = args.theme || config.theme || (args.url ? inferThemeFromUrl(args.url) : null);
if (!theme) {
throw new Error('A theme is required. Pass --theme <name> or --all with a config file.');
}
const configEntry = configThemes.find((entry) => entry.theme === theme) || {};
entries = [{ ...configEntry, theme }];
}
return entries.map((entry) => {
const theme = entry.theme || inferThemeFromUrl(entry.url || args.url || '');
const source = readThemeSource(theme, appRoot);
const baseOptions = mergeJobOptions(
DEFAULT_CAPTURE_OPTIONS,
config.defaults || {},
entry,
pickCaptureOptions(args),
);
const format = baseOptions.format || DEFAULT_CAPTURE_OPTIONS.format;
if (!['png', 'jpeg', 'webp'].includes(format)) {
throw new Error(`Unsupported screenshot format: ${format}`);
}
const url = firstNonEmpty(args.url, entry.url, source.websiteUrl, source.originalDetailUrl);
if (!url) {
throw new Error(`No URL found for theme "${theme}". Pass --url or add source.websiteUrl to theme.json.`);
}
const captureRoot = resolveMaybePath(
baseOptions.captureRoot || path.join(workspaceRoot, '.local', 'theme-captures'),
appRoot,
);
const reportDir = path.join(captureRoot, theme);
const outputPath = resolveOutputPath({
output: baseOptions.output,
theme,
format,
appRoot,
multiple: entries.length > 1,
});
return {
...baseOptions,
theme,
url,
format,
appRoot,
workspaceRoot,
captureRoot,
reportDir,
reportPath: path.join(reportDir, 'meta.json'),
outputPath,
storageState: resolveMaybePath(baseOptions.storageState, appRoot),
cookieFile: resolveMaybePath(baseOptions.cookieFile, appRoot),
};
});
}
async function loadPlaywright() {
try {
return await import('playwright');
} catch {
const cachedPath = path.join(PLAYWRIGHT_CACHE_DIR, 'node_modules', 'playwright', 'index.mjs');
if (fs.existsSync(cachedPath)) {
return import(pathToFileURL(cachedPath).href);
}
}
throw new Error(
'Playwright is not available. Install it with npm, or run the existing clone-page tool once to populate the shared cache.',
);
}
function chromiumCandidatePaths(platform = process.platform) {
if (platform === 'darwin') {
return [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
];
}
if (platform === 'win32') {
return [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
];
}
return [
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
];
}
export function resolveChromiumLaunchOptions(job, options = {}) {
const exists = options.exists || fs.existsSync;
const candidatePaths = options.candidatePaths || chromiumCandidatePaths();
const executablePath = job.browserExecutable
|| candidatePaths.find((candidate) => exists(candidate));
return {
headless: job.headless,
args: CHROMIUM_ARGS,
...(executablePath ? { executablePath } : {}),
};
}
export function resolveScreenshotPlan(job) {
if (job.format === 'webp') {
return {
playwrightType: 'png',
screenshotPath: path.join(job.reportDir, 'official-homepage-source.png'),
outputPath: job.outputPath,
needsConversion: true,
};
}
return {
playwrightType: job.format,
screenshotPath: job.outputPath,
outputPath: job.outputPath,
needsConversion: false,
};
}
export function resolveScreenshotBox(job, visualState) {
const sourceHeight = Math.max(
visualState?.scrollHeight || 0,
visualState?.bodyHeight || 0,
job.viewport.height,
);
const maxHeight = job.maxScreenshotHeight;
const shouldClip = job.format === 'webp'
&& Number.isFinite(maxHeight)
&& maxHeight > 0
&& sourceHeight > maxHeight;
if (shouldClip) {
return {
fullPage: false,
viewport: { width: job.viewport.width, height: maxHeight },
clipped: true,
sourceHeight,
outputHeight: maxHeight,
};
}
return {
fullPage: true,
clipped: false,
sourceHeight,
outputHeight: sourceHeight,
};
}
function findExecutable(command, envPath = process.env.PATH || '') {
const names = process.platform === 'win32' && !command.endsWith('.exe')
? [command, `${command}.exe`]
: [command];
for (const dir of envPath.split(path.delimiter)) {
for (const name of names) {
const candidate = path.join(dir, name);
if (fs.existsSync(candidate)) return candidate;
}
}
return null;
}
function convertToWebp(inputPath, outputPath, quality) {
const cwebp = findExecutable('cwebp');
if (cwebp) {
execFileSync(cwebp, ['-quiet', '-q', String(quality), inputPath, '-o', outputPath]);
return 'cwebp';
}
const magick = findExecutable('magick');
if (magick) {
execFileSync(magick, [inputPath, '-quality', String(quality), outputPath]);
return 'magick';
}
const convert = findExecutable('convert');
if (convert) {
execFileSync(convert, [inputPath, '-quality', String(quality), outputPath]);
return 'convert';
}
throw new Error('WebP output requires cwebp, ImageMagick magick, or ImageMagick convert in PATH.');
}
function identifyImage(imagePath) {
const magick = findExecutable('magick');
const identify = magick ? [magick, 'identify'] : [findExecutable('identify') || 'identify'];
const output = execFileSync(identify[0], [...identify.slice(1), '-format', '%w %h', imagePath], {
encoding: 'utf8',
}).trim();
const [width, height] = output.split(/\s+/).map((value) => Number.parseInt(value, 10));
return { width, height };
}
export function resolveWidthNormalization(job, image) {
const width = Math.min(job.viewport.width, image.width);
return {
needed: image.width !== job.viewport.width,
width,
height: image.height,
x: 0,
y: 0,
};
}
function normalizeImageWidth(imagePath, job) {
const image = identifyImage(imagePath);
const normalize = resolveWidthNormalization(job, image);
if (!normalize.needed) {
return { ...normalize, originalWidth: image.width, originalHeight: image.height, tool: null };
}
const magick = findExecutable('magick');
if (magick) {
execFileSync(magick, [
imagePath,
'-crop',
`${normalize.width}x${normalize.height}+${normalize.x}+${normalize.y}`,
'+repage',
imagePath,
]);
return { ...normalize, originalWidth: image.width, originalHeight: image.height, tool: 'magick' };
}
const convert = findExecutable('convert');
if (convert) {
execFileSync(convert, [
imagePath,
'-crop',
`${normalize.width}x${normalize.height}+${normalize.x}+${normalize.y}`,
'+repage',
imagePath,
]);
return { ...normalize, originalWidth: image.width, originalHeight: image.height, tool: 'convert' };
}
throw new Error('Width normalization requires ImageMagick magick or convert in PATH.');
}
async function openPage(playwright, job) {
let browser;
let context;
let page;
let ownsContext = true;
if (job.connectCdp) {
browser = await playwright.chromium.connectOverCDP(job.connectCdp);
context = browser.contexts()[0] || await browser.newContext();
ownsContext = browser.contexts()[0] !== context;
page = await context.newPage();
await page.setViewportSize(job.viewport);
await context.setExtraHTTPHeaders(job.headers || {});
} else {
browser = await playwright.chromium.launch(resolveChromiumLaunchOptions(job));
context = await browser.newContext({
viewport: job.viewport,
deviceScaleFactor: job.deviceScaleFactor,
storageState: job.storageState && fs.existsSync(job.storageState) ? job.storageState : undefined,
extraHTTPHeaders: job.headers,
httpCredentials: job.httpUser && job.httpPass
? { username: job.httpUser, password: job.httpPass }
: undefined,
ignoreHTTPSErrors: true,
});
page = await context.newPage();
}
if (job.cookieFile) {
const cookies = readJson(job.cookieFile);
await context.addCookies(Array.isArray(cookies) ? cookies : cookies.cookies || []);
}
return { browser, context, page, ownsContext };
}
async function injectCaptureCss(page, job) {
const scrollbarCss = job.hideScrollbar
? `
html, body {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
*::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
}
`
: '';
const removeCss = (job.removeSelectors || [])
.map((selector) => `${selector} { display: none !important; visibility: hidden !important; }`)
.join('\n');
const unstickCss = (job.unstickSelectors || [])
.map((selector) => `${selector} { position: static !important; top: auto !important; }`)
.join('\n');
await page.addStyleTag({
content: `
${scrollbarCss}
* {
caret-color: transparent !important;
}
${removeCss}
${unstickCss}
`,
});
}
async function dismissOverlays(page, selectors) {
for (const selector of selectors || []) {
try {
const locator = page.locator(selector).first();
await locator.click({ timeout: 1500 });
await page.waitForTimeout(300);
} catch {
// Optional dismiss selectors should never fail a capture.
}
}
}
async function waitForSelectors(page, selectors, timeoutMs) {
for (const selector of selectors || []) {
await page.waitForSelector(selector, { timeout: timeoutMs, state: 'attached' });
}
}
async function waitForFontsAndImages(page, timeoutMs) {
await page.evaluate(async (timeout) => {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const withTimeout = (promise) => Promise.race([promise, delay(timeout)]);
if (document.fonts?.ready) {
await withTimeout(document.fonts.ready);
}
const pendingImages = Array.from(document.images).filter((image) => !image.complete);
await withTimeout(Promise.all(pendingImages.map((image) => new Promise((resolve) => {
image.addEventListener('load', resolve, { once: true });
image.addEventListener('error', resolve, { once: true });
}))));
}, Math.min(timeoutMs, 8000));
}
async function scrollWarmup(page, job) {
const result = await page.evaluate(async ({ maxScrollSteps }) => {
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const scrollingElement = document.scrollingElement || document.documentElement;
const step = Math.max(600, Math.floor(window.innerHeight * 0.85));
let previousHeight = 0;
let stableHeightPasses = 0;
let steps = 0;
while (steps < maxScrollSteps) {
const scrollHeight = scrollingElement.scrollHeight;
if (scrollHeight === previousHeight) stableHeightPasses += 1;
else stableHeightPasses = 0;
previousHeight = scrollHeight;
const nextY = Math.min(window.scrollY + step, scrollHeight - window.innerHeight);
window.scrollTo(0, nextY);
steps += 1;
await delay(220);
if (nextY >= scrollHeight - window.innerHeight && stableHeightPasses >= 2) {
break;
}
}
window.scrollTo(0, 0);
await delay(500);
return { steps, scrollHeight: scrollingElement.scrollHeight };
}, { maxScrollSteps: job.maxScrollSteps });
return result;
}
async function getVisualState(page) {
return page.evaluate(() => {
const scrollingElement = document.scrollingElement || document.documentElement;
const images = Array.from(document.images);
return {
scrollHeight: scrollingElement.scrollHeight,
clientHeight: scrollingElement.clientHeight,
bodyHeight: document.body?.scrollHeight || 0,
imageTotal: images.length,
imageComplete: images.filter((image) => image.complete).length,
fontStatus: document.fonts?.status || 'unsupported',
nodeCount: document.querySelectorAll('*').length,
};
});
}
function visualStateKey(state) {
return [
state.scrollHeight,
state.bodyHeight,
state.imageTotal,
state.imageComplete,
state.fontStatus,
state.nodeCount,
].join('|');
}
async function waitForVisualStable(page, timeoutMs) {
const startedAt = Date.now();
let lastKey = '';
let lastState = null;
let stablePasses = 0;
while (Date.now() - startedAt < timeoutMs) {
const state = await getVisualState(page);
const key = visualStateKey(state);
const assetsReady = state.imageTotal === state.imageComplete
&& (state.fontStatus === 'loaded' || state.fontStatus === 'unsupported');
if (key === lastKey && assetsReady) {
stablePasses += 1;
} else {
stablePasses = 0;
}
lastKey = key;
lastState = state;
if (stablePasses >= 2) {
return { stable: true, elapsedMs: Date.now() - startedAt, state };
}
await page.waitForTimeout(400);
}
return { stable: false, elapsedMs: Date.now() - startedAt, state: lastState };
}
async function captureJob(playwright, job) {
fs.mkdirSync(path.dirname(job.outputPath), { recursive: true });
fs.mkdirSync(job.reportDir, { recursive: true });
const { browser, context, page, ownsContext } = await openPage(playwright, job);
const startedAt = Date.now();
let warmup = null;
let stability = null;
try {
await page.goto(job.url, { waitUntil: job.waitUntil, timeout: job.timeoutMs });
await injectCaptureCss(page, job);
await waitForSelectors(page, job.waitForSelectors, job.timeoutMs);
await dismissOverlays(page, job.dismissSelectors);
if (job.waitAfterLoadMs > 0) {
await page.waitForTimeout(job.waitAfterLoadMs);
}
await waitForFontsAndImages(page, job.settleTimeoutMs);
if (job.scrollWarmup) {
warmup = await scrollWarmup(page, job);
await waitForFontsAndImages(page, job.settleTimeoutMs);
}
stability = await waitForVisualStable(page, job.settleTimeoutMs);
await injectCaptureCss(page, job);
const preScreenshotState = await getVisualState(page);
const screenshotPlan = resolveScreenshotPlan(job);
const screenshotBox = resolveScreenshotBox(job, preScreenshotState);
fs.mkdirSync(path.dirname(screenshotPlan.screenshotPath), { recursive: true });
const screenshotOptions = {
path: screenshotPlan.screenshotPath,
fullPage: screenshotBox.fullPage,
type: screenshotPlan.playwrightType,
animations: 'disabled',
caret: 'hide',
timeout: job.timeoutMs,
};
if (screenshotBox.viewport) {
await page.setViewportSize(screenshotBox.viewport);
await page.waitForTimeout(250);
}
if (screenshotPlan.playwrightType === 'jpeg') {
screenshotOptions.quality = job.quality;
}
await page.screenshot(screenshotOptions);
const converter = screenshotPlan.needsConversion
? convertToWebp(screenshotPlan.screenshotPath, screenshotPlan.outputPath, job.quality)
: null;
const widthNormalization = normalizeImageWidth(screenshotPlan.outputPath, job);
const finalState = await getVisualState(page);
const metadata = {
theme: job.theme,
requestedUrl: job.url,
finalUrl: page.url(),
title: await page.title(),
outputPath: job.outputPath,
viewport: job.viewport,
format: job.format,
quality: job.format === 'png' ? undefined : job.quality,
hideScrollbar: job.hideScrollbar,
scrollWarmup: job.scrollWarmup,
screenshotPlan,
screenshotBox,
converter,
widthNormalization,
warmup,
stability,
finalState,
durationMs: Date.now() - startedAt,
fileSize: fs.statSync(job.outputPath).size,
capturedAt: new Date().toISOString(),
auth: {
connectCdp: Boolean(job.connectCdp),
storageState: Boolean(job.storageState),
cookieFile: Boolean(job.cookieFile),
httpCredentials: Boolean(job.httpUser && job.httpPass),
headers: Object.keys(job.headers || {}),
},
};
fs.writeFileSync(job.reportPath, JSON.stringify(metadata, null, 2));
return metadata;
} finally {
await page.close().catch(() => {});
if (!job.connectCdp && ownsContext) {
await context.close().catch(() => {});
}
if (!job.connectCdp) {
await browser.close().catch(() => {});
}
}
}
function showHelp() {
console.log(`
Capture a long full-page official homepage screenshot for a theme.
Usage:
npm run capture:theme -- --theme linear
npm run capture:theme -- --theme linear --url https://linear.app
npm run capture:theme -- --config theme-capture.json --all
Options:
--theme NAME Theme directory name under src/themes
--url URL Homepage URL. Defaults to theme.json source.websiteUrl
--config FILE JSON config with defaults and themes
--all Capture every theme from config
--output PATH Output file for one theme, or output directory for batch
--viewport WxH Viewport before full-page capture. Default: 1440x1200
--hide-scrollbar Hide scrollbars before screenshot. Default
--show-scrollbar Keep scrollbars visible
--scroll-warmup Scroll down and back up to trigger lazy loading. Default
--no-scroll-warmup Disable lazy-load warmup
--max-screenshot-height N Cap WebP output height. Default: 16383
--wait-for-selector SEL Wait for selector before capture. Repeatable
--dismiss-selector SEL Click optional overlay close/accept selector. Repeatable
--remove-selector SEL Hide noisy selector before capture. Repeatable
--unstick-selector SEL Make sticky selector static before capture. Repeatable
--connect-cdp URL Reuse a logged-in Chrome, for example http://localhost:9222
--browser-executable PATH Use a specific Chrome/Chromium executable
--storage-state FILE Playwright storageState JSON
--cookie-file FILE Playwright cookies JSON or { "cookies": [] }
--header Name=Value Extra HTTP header. Repeatable
--http-user USER Basic auth username
--http-pass PASS Basic auth password
--dry-run Print resolved jobs without opening a browser
`);
}
async function runCli() {
const args = parseCaptureArgs(process.argv);
if (args.help) {
showHelp();
return;
}
const jobs = resolveCaptureJobs(args);
if (args.dryRun) {
console.log(JSON.stringify(jobs, null, 2));
return;
}
const playwright = await loadPlaywright();
for (const job of jobs) {
console.log(`[theme-capture] ${job.theme} ${job.url}`);
const metadata = await captureJob(playwright, job);
console.log(`[theme-capture] wrote ${metadata.outputPath}`);
console.log(`[theme-capture] report ${job.reportPath}`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
runCli().catch((error) => {
console.error(`[theme-capture] ${error.stack || error.message || error}`);
process.exitCode = 1;
});
}