master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
#!/usr/bin/env node
// wiki-light-shot — render an Adom Wiki page in synthesized LIGHT MODE and
// screenshot it (top viewport + full page), optionally injecting the shots
// into adom-shotlog so the human actually sees them.
//
// The wiki ships dark-only (color-scheme: dark, no light palette). This tool
// logs a headless Chrome in via a single-use `adom-wiki auth login` magic link
// (so private pages work), applies an invert+hue-rotate overlay with raster
// media re-inverted (hero/screenshots keep true colors), verifies at the pixel
// level that the result is genuinely light, and saves/injects the shots.
//
// Contract: OK:/WARN:/ERROR: + Hint: lines. Exit 0 = shots saved, 1 = failure.
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import zlib from 'node:zlib';
const PKG_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const WIKI_HOST = 'wiki.adom.inc';
const NAV_TIMEOUT_MS = 45000;
const SETTLE_MS = 2500;
// ---------- output helpers ----------
const jsonMode = process.argv.includes('--json');
const lines = [];
function say(line) { if (jsonMode) lines.push(line); else console.log(line); }
const ok = (m) => say(`OK: ${m}`);
const warn = (m) => say(`WARN: ${m}`);
const hint = (m) => say(`Hint: ${m}`);
function die(msg, ...hints) {
if (jsonMode) {
console.log(JSON.stringify({ status: 'error', error: msg, hints, log: lines }, null, 2));
} else {
console.error(`ERROR: ${msg}`);
for (const h of hints) console.error(`Hint: ${h}`);
}
process.exit(1);
}
// ---------- args ----------
function usage() {
say('wiki-light-shot — screenshot an Adom Wiki page in synthesized light mode');
say('');
say('Usage: wiki-light-shot <owner/slug | https://wiki.adom.inc/...> [options]');
say('');
say('Options:');
say(' --out DIR output directory (default: ./wiki-light-shots)');
say(' --channel NAME shotlog channel (default: the page slug)');
say(' --no-inject save PNGs only, skip adom-shotlog');
say(' --no-auth skip login (public pages only; private pages 404)');
say(' --keep-dark capture as-is without the light-mode overlay');
say(' --viewport WxH viewport size (default 1920x1200)');
say(' --full-max PX cap full-page capture height (default 8000)');
say(' --json machine-readable { status, data, hints[] } envelope');
process.exit(process.argv.length <= 2 ? 1 : 0);
}
const VALUE_FLAGS = new Set(['--out', '--channel', '--viewport', '--full-max']);
const BOOL_FLAGS = new Set(['--no-inject', '--no-auth', '--keep-dark', '--json', '--help', '-h']);
const flags = {};
const positionals = [];
const rawArgs = process.argv.slice(2);
for (let i = 0; i < rawArgs.length; i++) {
const a = rawArgs[i];
if (VALUE_FLAGS.has(a)) {
const v = rawArgs[++i];
if (v === undefined || v.startsWith('--')) die(`${a} needs a value`);
flags[a] = v;
} else if (BOOL_FLAGS.has(a)) flags[a] = true;
else if (a.startsWith('--')) die(`unknown flag ${a}`, 'run with --help for usage');
else positionals.push(a);
}
if (rawArgs.length === 0 || flags['--help'] || flags['-h']) usage();
const flagVal = (name, dflt) => flags[name] ?? dflt;
const hasFlag = (name) => !!flags[name];
if (positionals.length !== 1) {
die(positionals.length === 0 ? 'no page reference given' : `expected one page reference, got: ${positionals.join(', ')}`,
'pass owner/slug (e.g. adom/puma-bridge) or a full wiki.adom.inc URL');
}
const ref = positionals[0];
const outDir = resolve(flagVal('--out', './wiki-light-shots'));
const noInject = hasFlag('--no-inject');
const noAuth = hasFlag('--no-auth');
const keepDark = hasFlag('--keep-dark');
const vpRaw = flagVal('--viewport', '1920x1200');
const vpMatch = /^(\d{3,4})x(\d{3,4})$/.exec(vpRaw);
if (!vpMatch) die(`bad --viewport '${vpRaw}'`, 'use WxH, e.g. 1920x1200');
const viewport = { width: +vpMatch[1], height: +vpMatch[2] };
const fullMax = Math.max(viewport.height, parseInt(flagVal('--full-max', '8000'), 10) || 8000);
// ---------- resolve + validate the target URL (never leak the session elsewhere) ----------
let pageUrl, slug;
if (/^https?:\/\//i.test(ref)) {
let u;
try { u = new URL(ref); } catch { die(`'${ref}' is not a valid URL`); }
if (u.hostname !== WIKI_HOST) {
die(`refusing to open ${u.hostname} — this tool logs the browser in as YOU and only navigates ${WIKI_HOST}`,
'pass an owner/slug or a https://wiki.adom.inc/... URL');
}
pageUrl = u.href;
slug = u.pathname.split('/').filter(Boolean).slice(0, 2).join('-') || 'wiki-page';
} else {
const m = /^([A-Za-z0-9][\w.-]*)\/([A-Za-z0-9][\w.-]*)$/.exec(ref);
if (!m) die(`'${ref}' is neither owner/slug nor a wiki URL`, 'examples: adom/puma-bridge, https://wiki.adom.inc/adom/core');
pageUrl = `https://${WIKI_HOST}/${m[1]}/${m[2]}`;
slug = m[2];
}
const channel = flagVal('--channel', slug);
// ---------- find Chrome ----------
function findChrome() {
const envPath = process.env.WIKI_LIGHT_CHROME;
if (envPath) {
if (existsSync(envPath)) return envPath;
die(`WIKI_LIGHT_CHROME points at '${envPath}' which does not exist`, 'unset it or fix the path');
}
for (const flavor of ['chrome', 'chrome-headless-shell']) {
const base = join(homedir(), '.cache', 'puppeteer', flavor);
if (!existsSync(base)) continue;
const versions = readdirSync(base).filter((d) => d.startsWith('linux-'))
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
for (const v of versions) {
for (const sub of [`chrome-linux64/chrome`, `chrome-headless-shell-linux64/chrome-headless-shell`]) {
const p = join(base, v, sub);
if (existsSync(p)) return p;
}
}
}
for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']) {
const r = spawnSync('which', [name], { encoding: 'utf8' });
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
}
return null;
}
// ---------- puppeteer-core, self-healing ----------
async function loadPuppeteer() {
const req = createRequire(join(PKG_DIR, 'package.json'));
try { return req('puppeteer-core'); } catch {}
try { return (await import('puppeteer-core')).default; } catch {}
warn('puppeteer-core not found — installing it into the skill package (one-time)');
// --no-save is load-bearing: this package.json's "dependencies" holds WIKI
// deps (owner/slug refs) — letting npm write into it would corrupt them.
const r = spawnSync('npm', ['install', 'puppeteer-core@^22', '--no-save', '--no-package-lock', '--no-audit', '--no-fund', '--silent'],
{ cwd: PKG_DIR, encoding: 'utf8', timeout: 180000 });
if (r.status !== 0) {
die('could not install puppeteer-core', `run manually: cd ${PKG_DIR} && npm i puppeteer-core@^22`,
r.stderr ? `npm said: ${String(r.stderr).trim().slice(0, 300)}` : 'is npm on PATH?');
}
try { return req('puppeteer-core'); }
catch (e) { die(`puppeteer-core installed but still not loadable: ${e.message}`); }
}
// ---------- auth ----------
function mintLoginUrl() {
try {
const out = execFileSync('adom-wiki', ['auth', 'login', '--json'], { encoding: 'utf8', timeout: 20000 });
const parsed = JSON.parse(out);
const url = parsed?.data?.logged_in_url;
if (url && new URL(url).hostname === WIKI_HOST) return url;
warn('adom-wiki auth login returned no usable link — continuing anonymously');
} catch (e) {
warn(`could not mint a wiki login link (${String(e.message).split('\n')[0]}) — continuing anonymously`);
hint("private pages will 404 without auth; check `adom-wiki auth token` / `adom-wiki whoami`");
}
return null;
}
// ---------- tiny PNG luminance check (proof the shot is really light) ----------
// Supports what Chrome emits: 8-bit RGB/RGBA, non-interlaced.
function pngMeanLuminance(buf) {
try {
if (buf.readUInt32BE(0) !== 0x89504e47) return null;
let pos = 8, width = 0, height = 0, colorType = -1, bitDepth = 0, interlace = 0;
const idat = [];
while (pos < buf.length) {
const len = buf.readUInt32BE(pos);
const type = buf.toString('ascii', pos + 4, pos + 8);
const data = buf.subarray(pos + 8, pos + 8 + len);
if (type === 'IHDR') {
width = data.readUInt32BE(0); height = data.readUInt32BE(4);
bitDepth = data[8]; colorType = data[9]; interlace = data[12];
} else if (type === 'IDAT') idat.push(data);
else if (type === 'IEND') break;
pos += 12 + len;
}
if (bitDepth !== 8 || interlace !== 0 || (colorType !== 2 && colorType !== 6)) return null;
const bpp = colorType === 6 ? 4 : 3;
const raw = zlib.inflateSync(Buffer.concat(idat));
const stride = width * bpp;
let sum = 0, n = 0;
const prev = Buffer.alloc(stride);
const cur = Buffer.alloc(stride);
const rowStep = Math.max(1, Math.floor(height / 240)); // sample ~240 rows
const pxStep = Math.max(1, Math.floor(width / 320)) * bpp;
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
raw.copy(cur, 0, y * (stride + 1) + 1, (y + 1) * (stride + 1));
for (let i = 0; i < stride; i++) {
const a = i >= bpp ? cur[i - bpp] : 0, b = prev[i], c = i >= bpp ? prev[i - bpp] : 0;
let v = cur[i];
if (filter === 1) v = (v + a) & 255;
else if (filter === 2) v = (v + b) & 255;
else if (filter === 3) v = (v + ((a + b) >> 1)) & 255;
else if (filter === 4) {
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
v = (v + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 255;
}
cur[i] = v;
}
if (y % rowStep === 0) {
for (let i = 0; i + 2 < stride; i += pxStep) {
sum += 0.2126 * cur[i] + 0.7152 * cur[i + 1] + 0.0722 * cur[i + 2];
n++;
}
}
cur.copy(prev);
}
return n ? sum / n : null;
} catch { return null; }
}
// ---------- shotlog ----------
function shotlogAvailable() {
const r = spawnSync('adom-shotlog', ['health'], { encoding: 'utf8', timeout: 8000 });
return r.status === 0;
}
function inject(file, desc) {
const r = spawnSync('adom-shotlog',
['inject', '-c', channel, '-d', desc, '-s', 'headless-chrome', '--thread', 'wiki light mode', file],
{ encoding: 'utf8', timeout: 30000 });
const out = (r.stdout || '') + (r.stderr || '');
if (r.status !== 0) { warn(`shotlog inject failed for ${file}: ${out.trim().slice(0, 200)}`); return false; }
const seen = out.split('\n').find((l) => l.includes('viewer(s)'));
if (seen) say(seen.trim());
return true;
}
// ---------- main ----------
const chromePath = findChrome();
if (!chromePath) {
die('no Chrome found', 'set WIKI_LIGHT_CHROME=/path/to/chrome, or install one: npx @puppeteer/browsers install chrome@stable');
}
const puppeteer = await loadPuppeteer();
mkdirSync(outDir, { recursive: true });
let browser;
const cleanup = async () => { try { await browser?.close(); } catch {} };
process.on('SIGINT', async () => { await cleanup(); process.exit(1); });
process.on('SIGTERM', async () => { await cleanup(); process.exit(1); });
try {
browser = await puppeteer.launch({
executablePath: chromePath,
headless: 'new',
args: ['--no-sandbox', '--disable-dev-shm-usage', '--hide-scrollbars'],
});
} catch (e) {
die(`Chrome failed to launch (${chromePath}): ${String(e.message).split('\n')[0]}`,
'try a different binary via WIKI_LIGHT_CHROME');
}
try {
const page = await browser.newPage();
await page.setViewport({ ...viewport, deviceScaleFactor: 1 });
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'light' }]);
// Belt-and-braces: block requests to any non-wiki host except same-page assets.
await page.setRequestInterception(true);
page.on('request', (r) => {
try {
const h = new URL(r.url()).hostname;
if (h === WIKI_HOST || h.endsWith('.r2.cloudflarestorage.com') || r.url().startsWith('data:')) r.continue();
else r.abort('blockedbyclient');
} catch { r.abort('blockedbyclient'); }
});
// Log in (single-use link, fresh every run). One retry with a fresh link.
let authed = false;
if (!noAuth) {
for (let attempt = 0; attempt < 2 && !authed; attempt++) {
const loginUrl = mintLoginUrl();
if (!loginUrl) break;
try {
const r = await page.goto(loginUrl, { waitUntil: 'networkidle2', timeout: NAV_TIMEOUT_MS });
// The session cookie is HttpOnly, so a clean 2xx/3xx landing is the signal.
authed = !!r && r.status() < 400;
if (!authed) warn(`login link landed with HTTP ${r?.status?.()} (link expired? links are single-use, 60s)`);
} catch (e) {
warn(`login navigation failed (attempt ${attempt + 1}): ${String(e.message).split('\n')[0]}`);
}
}
if (authed) ok('logged in via single-use adom-wiki magic link');
else warn('proceeding anonymously — private pages will 404');
}
// Navigate to the page, one retry (the session cookie survives retries).
let resp = null;
for (let attempt = 0; attempt < 2; attempt++) {
try {
resp = await page.goto(pageUrl, { waitUntil: 'networkidle2', timeout: NAV_TIMEOUT_MS });
break;
} catch (e) {
if (attempt === 1) die(`could not load ${pageUrl}: ${String(e.message).split('\n')[0]}`,
'is wiki.adom.inc reachable from this container?');
warn('page load timed out, retrying once');
}
}
const status = resp?.status?.() ?? 0;
if (status === 404) {
await cleanup();
die(`the wiki returned 404 for ${pageUrl}`,
authed ? 'the page does not exist (check the slug with `adom-wiki page stats <owner>/<slug>`) or your account lacks access'
: 'the page may be private — retry without --no-auth so the tool can log in');
}
if (status >= 400) { await cleanup(); die(`the wiki returned HTTP ${status} for ${pageUrl}`); }
await new Promise((r) => setTimeout(r, SETTLE_MS));
// Synthesized light mode: invert the page, re-invert raster media so the
// hero and screenshots keep their true colors.
if (!keepDark) {
await page.addStyleTag({ content: `
html { filter: invert(1) hue-rotate(180deg) !important; color-scheme: light !important; background: #ffffff !important; }
img, video, iframe, canvas, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; }
` });
await new Promise((r) => setTimeout(r, 600));
}
const meta = await page.evaluate(() => ({
title: document.title,
scrollHeight: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight || 0),
}));
const mode = keepDark ? 'dark' : 'light';
const topFile = join(outDir, `${slug}-${mode}-top.png`);
const fullFile = join(outDir, `${slug}-${mode}-full.png`);
await page.screenshot({ path: topFile });
const fullH = Math.min(meta.scrollHeight, fullMax);
if (meta.scrollHeight > fullMax) {
warn(`page is ${meta.scrollHeight}px tall — full capture clipped to ${fullMax}px (raise with --full-max)`);
}
await page.screenshot({ path: fullFile, clip: { x: 0, y: 0, width: viewport.width, height: fullH } });
await cleanup();
// Pixel-level proof: is the shot actually light?
const lum = pngMeanLuminance(readFileSync(topFile));
if (!keepDark) {
if (lum === null) warn('could not verify luminance (unexpected PNG format) — eyeball the shot');
else if (lum < 100) warn(`the capture still reads DARK (mean luminance ${lum.toFixed(0)}/255) — the wiki markup may have changed; inspect ${topFile}`);
else ok(`light mode verified: mean luminance ${lum.toFixed(0)}/255`);
} else if (lum !== null) {
ok(`captured as-is (mean luminance ${lum.toFixed(0)}/255)`);
}
ok(`saved ${topFile} (${viewport.width}x${viewport.height}) and ${fullFile} (${viewport.width}x${fullH})`);
// Show the human, via shotlog.
let injected = false;
if (!noInject) {
if (shotlogAvailable()) {
const label = keepDark ? 'as-is (dark)' : 'in light mode (wiki is dark-only; light synthesized via CSS invert)';
injected = inject(topFile, `wiki ${ref} ${label} - top of page`);
injected = inject(fullFile, `wiki ${ref} ${label} - full page`) || injected;
if (injected) ok(`injected into shotlog channel '${channel}' — the human sees it in the shotlog viewer`);
} else {
warn('adom-shotlog is not available — shots saved to disk only');
hint('install it (`adom-wiki pkg install adom/adom-shotlog`) so the human can actually SEE the shots; you cannot show images in a text thread');
}
}
if (jsonMode) {
console.log(JSON.stringify({
status: 'ok',
data: { page: pageUrl, title: meta.title, mode, top: topFile, full: fullFile,
meanLuminance: lum, channel: injected ? channel : null, authed },
hints: [], log: lines,
}, null, 2));
}
process.exit(0);
} catch (e) {
await cleanup();
die(`capture failed: ${String(e.stack || e.message).split('\n').slice(0, 3).join(' | ')}`);
}