Include weekly report and other prototype sources with project tooling config. Co-authored-by: Cursor <cursoragent@cursor.com>
1026 lines
31 KiB
JavaScript
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;
|
|
});
|
|
}
|