master
Drew Owens Publish 1.0.0 c95d26a 2mo ago
#!/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(' | ')}`);
}