← back to Paul Conrad Cartoons Rebrand

scripts/import-p24.mjs

287 lines

#!/usr/bin/env node
// import-p24.mjs — copy every P24 photo + cartoon into the Inkwell app.
//
// Source (READ-ONLY): the P24 repo (default ~/Projects/crazy-news-channel).
//   - images/*.jpg                         -> type "photo"
//   - cartoons/2026*.html strip pages      -> type "cartoon"  (Playwright renders each page,
//       advances every panel, saves each artwork <svg> as a standalone .svg with computed
//       styles inlined, plus a PNG of the whole strip; embedded data: images are decoded)
//   - daily-cartoons/queue/<date>/<slug>/  -> type "cartoon"  (poster.jpg + meta.json)
// Output: public/p24/** + data/p24.json.
//
// NAMING RULE (TK-12230): no item id, path, title, or caption may contain the name of the
// real cartoonist these strips were styled after. Slugs and text are scrubbed on import and
// the script FAILS (exit 1) if anything slips through.
//
// Usage: node scripts/import-p24.mjs [--src /path/to/p24]   ($0, local only)

import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const argIdx = process.argv.indexOf('--src');
const SRC = argIdx > -1 ? process.argv[argIdx + 1] : path.join(process.env.HOME, 'Projects/crazy-news-channel');
const OUT = path.join(ROOT, 'public/p24');
const PW_PATH = process.env.PLAYWRIGHT_PATH || path.join(process.env.HOME, 'Projects/jevrun-runner/node_modules/playwright');
const require = createRequire(import.meta.url);
const { chromium } = require(PW_PATH);

const BANNED = /conrad/i;
const scrub = (s) => String(s ?? '')
  .replace(/\bin the style of paul conrad\b/gi, 'in a classic newspaper ink style')
  .replace(/paul\s+(francis\s+)?conrad(['’]s)?/gi, 'classic ink')
  .replace(/conrad(['’]s)?/gi, 'ink')
  .replace(/\s{2,}/g, ' ')
  .trim();
// Slugs: drop the name segment entirely rather than substituting a word.
const neutralSlug = (s) => String(s).toLowerCase()
  .replace(/(^|-)conrad(-|$)/g, (m, a, b) => (a && b ? '-' : ''))
  .replace(/conrad/g, '')
  .replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');

function gitDate(rel) {
  try {
    const out = execFileSync('git', ['-C', SRC, 'log', '--diff-filter=A', '--follow', '--format=%cI', '--', rel], { encoding: 'utf8' }).trim().split('\n').filter(Boolean);
    if (out.length) return new Date(out[out.length - 1]).toISOString();
  } catch { /* fall through */ }
  return fs.statSync(path.join(SRC, rel)).mtime.toISOString();
}

function loadWindowScript(rel) {
  const window = {};
  const code = fs.readFileSync(path.join(SRC, rel), 'utf8');
  new Function('window', code)(window);
  return window;
}

function loadDefaultStories() {
  const html = fs.readFileSync(path.join(SRC, 'index.html'), 'utf8');
  const start = html.indexOf('const DEFAULT_STORIES = [');
  if (start < 0) return [];
  let i = html.indexOf('[', start), depth = 0, end = -1;
  for (let j = i; j < html.length; j++) {
    const c = html[j];
    if (c === '[') depth++;
    else if (c === ']') { depth--; if (depth === 0) { end = j; break; } }
  }
  try { return new Function(`return ${html.slice(i, end + 1)};`)(); } catch { return []; }
}

// Paint over the bottom-right signature corner (x 80-100%, y 84-100%) with its median colour.
function maskSignature(file) {
  const py = [
    'import sys, statistics',
    'from PIL import Image, ImageDraw',
    'p = sys.argv[1]; im = Image.open(p).convert("RGB"); w, h = im.size',
    'box = (int(w * 0.80), int(h * 0.84), w, h)',
    'px = list(im.crop(box).get_flattened_data() if hasattr(Image.Image, "get_flattened_data") else im.crop(box).getdata())',
    'med = tuple(int(statistics.median(c[i] for c in px)) for i in range(3))',
    'ImageDraw.Draw(im).rectangle(box, fill=med)',
    'im.save(p, quality=92)',
  ].join('\n');
  execFileSync('python3', ['-c', py, file]);
}

const items = [];
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(path.join(OUT, 'photos'), { recursive: true });
fs.mkdirSync(path.join(OUT, 'cartoons'), { recursive: true });

// ---------- photos ----------
const stories = [
  ...(loadWindowScript('stories-data.js').P24_EXTRA_STORIES || []),
  ...(loadWindowScript('real-news-data.js').P24_REAL_STORIES || []),
  ...loadDefaultStories(),
];
const cartoonManifest = loadWindowScript('cartoons/manifest.js').P24_CARTOONS || [];
for (const file of fs.readdirSync(path.join(SRC, 'images')).filter(f => /\.(jpe?g|png|webp)$/i.test(f)).sort()) {
  const base = file.replace(/\.[^.]+$/, '');
  const ext = path.extname(file).toLowerCase();
  const slug = neutralSlug(base) + (BANNED.test(base) ? '-ink' : '');
  const story = stories.find(s => String(s.image || '').endsWith('/' + file) || s.id === base);
  const cartoonRef = cartoonManifest.find(c => String(c.thumb || '').endsWith('/' + file));
  const title = story?.stages?.[0]?.headline || story?.headline || cartoonRef?.title
    || base.replace(/^[a-z]+-/, '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
  const caption = story?.article?.photoCaption || story?.article?.dek || cartoonRef?.blurb || '';
  fs.copyFileSync(path.join(SRC, 'images', file), path.join(OUT, 'photos', slug + ext));
  items.push({
    id: `photo-${slug}`,
    type: 'photo',
    title: scrub(title),
    caption: scrub(caption),
    date: gitDate(`images/${file}`),
    src: `/p24/photos/${slug}${ext}`,
    panels: [],
    category: story?.categoryLabel || story?.category || (cartoonRef ? 'Editorial Cartoon' : null),
    source_page: story ? `index.html#${neutralSlug(story.id)}` : `images/${slug}${ext}`,
  });
}

// ---------- cartoon strip pages ----------
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1100, height: 1400 }, deviceScaleFactor: 1, reducedMotion: 'reduce' });
const pages = fs.readdirSync(path.join(SRC, 'cartoons')).filter(f => /^2026.*\.html$/.test(f)).sort();
for (const file of pages) {
  const origSlug = file.replace(/\.html$/, '');
  const slug = neutralSlug(origSlug);
  const man = cartoonManifest.find(c => c.file === file);
  const dir = path.join(OUT, 'cartoons', slug);
  fs.mkdirSync(dir, { recursive: true });
  const page = await ctx.newPage();
  await page.goto('file://' + path.join(SRC, 'cartoons', file), { waitUntil: 'load' });
  await page.waitForTimeout(400);
  // Advance every panel: click covers / next buttons, press N, a bounded number of times.
  for (let k = 0; k < 8; k++) {
    await page.evaluate(() => {
      const btn = document.querySelector('#nextBtn:not([disabled])') || document.querySelector('button.cover:not([disabled])');
      if (btn) btn.click();
    });
    await page.keyboard.press('n').catch(() => {});
    await page.waitForTimeout(350);
  }
  await page.waitForTimeout(600);
  const meta = await page.evaluate(() => {
    const txt = (sel) => document.querySelector(sel)?.textContent?.trim() || '';
    return {
      h1: txt('h1'),
      dek: txt('.dek') || txt('.panel-caption') || document.querySelector('meta[name=description]')?.content || '',
      caps: [...document.querySelectorAll('figcaption')].map(f => f.textContent.trim()).filter(Boolean),
    };
  });
  // Serialize every sizeable artwork svg with computed styles inlined -> standalone file.
  const svgs = await page.evaluate(() => {
    const PROPS = ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-linecap', 'stroke-linejoin',
      'stroke-dasharray', 'opacity', 'font-family', 'font-size', 'font-weight', 'font-style', 'text-anchor',
      'dominant-baseline', 'visibility', 'display', 'color'];
    const out = [];
    const list = [...document.querySelectorAll('svg')].filter(s => {
      const r = s.getBoundingClientRect();
      return r.width >= 120 && r.height >= 90 && !s.closest('button');
    });
    for (const svg of list) {
      const r = svg.getBoundingClientRect();
      const clone = svg.cloneNode(true);
      const src = [svg, ...svg.querySelectorAll('*')];
      const dst = [clone, ...clone.querySelectorAll('*')];
      src.forEach((node, i) => {
        const cs = getComputedStyle(node);
        const decl = PROPS.map(p => `${p}:${cs.getPropertyValue(p)}`).join(';');
        dst[i].setAttribute('style', decl);
        dst[i].removeAttribute('class');
      });
      clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
      clone.setAttribute('width', String(Math.round(r.width)));
      clone.setAttribute('height', String(Math.round(r.height)));
      if (!clone.getAttribute('viewBox')) clone.setAttribute('viewBox', `0 0 ${Math.round(r.width)} ${Math.round(r.height)}`);
      const panelBg = getComputedStyle(svg.closest('figure') || document.body).backgroundColor;
      clone.setAttribute('style', clone.getAttribute('style') + `;background:${panelBg}`);
      out.push(new XMLSerializer().serializeToString(clone));
    }
    return out;
  });
  const panels = [];
  svgs.forEach((svgText, i) => {
    const name = `panel-${i + 1}.svg`;
    fs.writeFileSync(path.join(dir, name), '<?xml version="1.0" encoding="UTF-8"?>\n' + scrub(svgText));
    panels.push(`/p24/cartoons/${slug}/${name}`);
  });
  // Embedded raster artwork (data: URIs) -> decode to files.
  const dataImgs = await page.evaluate(() => [...document.querySelectorAll('img')].map(i => i.src).filter(s => s.startsWith('data:image')));
  let mainSrc = null;
  dataImgs.forEach((d, i) => {
    const m = d.match(/^data:image\/(\w+);base64,(.*)$/);
    if (!m) return;
    const ext = m[1] === 'jpeg' ? 'jpg' : m[1];
    const name = `art-${i + 1}.${ext}`;
    fs.writeFileSync(path.join(dir, name), Buffer.from(m[2], 'base64'));
    if (!mainSrc) mainSrc = `/p24/cartoons/${slug}/${name}`;
  });
  // Whole-strip PNG (always) — the card thumbnail when there is no embedded raster.
  // Scrub the name out of any rendered text first (the PNG bakes pixels, so do it in the DOM).
  await page.evaluate(() => {
    const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
    for (let n = w.nextNode(); n; n = w.nextNode()) {
      if (/conrad/i.test(n.nodeValue)) n.nodeValue = n.nodeValue.replace(/paul\s+(francis\s+)?conrad(['’]s)?/gi, 'classic ink').replace(/conrad(['’]s)?/gi, 'ink');
    }
    // Tag the artwork: the multi-panel .strip if present, else the largest svg/img/canvas.
    let best = document.querySelector('.strip'), area = 0;
    if (!best) for (const el of document.querySelectorAll('svg, img, canvas')) {
      const r = el.getBoundingClientRect();
      if (r.width * r.height > area && !el.closest('button')) { area = r.width * r.height; best = el; }
    }
    if (best) best.setAttribute('data-inkwell-art', '1');
  });
  const target = (await page.$('[data-inkwell-art]')) || (await page.$('main')) || (await page.$('body'));
  await target.screenshot({ path: path.join(dir, 'strip.png') });
  if (!mainSrc) mainSrc = `/p24/cartoons/${slug}/strip.png`;
  await page.close();
  const title = man?.title || meta.h1 || slug;
  const caption = man?.blurb || meta.dek || meta.caps.join(' ');
  items.push({
    id: `cartoon-${slug}`,
    type: 'cartoon',
    title: scrub(title),
    caption: scrub(caption),
    date: man?.created_at ? new Date(man.created_at).toISOString() : gitDate(`cartoons/${file}`),
    src: mainSrc,
    panels,
    category: 'Editorial Cartoon',
    source_page: `cartoons/${slug}.html`,
  });
}
await browser.close();

// ---------- daily-cartoon queue (poster.jpg + meta.json) ----------
const qRoot = path.join(SRC, 'daily-cartoons/queue');
for (const day of fs.existsSync(qRoot) ? fs.readdirSync(qRoot).sort() : []) {
  const dayDir = path.join(qRoot, day);
  if (!fs.statSync(dayDir).isDirectory()) continue;
  for (const s of fs.readdirSync(dayDir).sort()) {
    const d = path.join(dayDir, s);
    if (!fs.existsSync(path.join(d, 'poster.jpg'))) continue;
    let meta = {};
    try { meta = JSON.parse(fs.readFileSync(path.join(d, 'meta.json'), 'utf8')); } catch { /* poster only */ }
    const slug = `daily-${day}-${neutralSlug(s)}`;
    const dest = path.join(OUT, 'cartoons', slug);
    fs.mkdirSync(dest, { recursive: true });
    fs.copyFileSync(path.join(d, 'poster.jpg'), path.join(dest, 'poster.jpg'));
    // The image model paints a forged artist signature (bottom-right) into these posters.
    // Pixels can't be text-scrubbed, so mask that corner with the region's median colour.
    maskSignature(path.join(dest, 'poster.jpg'));
    items.push({
      id: `cartoon-${slug}`,
      type: 'cartoon',
      title: scrub(meta.title || s),
      caption: scrub(meta.caption || ''),
      date: meta.created_at ? new Date(meta.created_at).toISOString() : fs.statSync(path.join(d, 'poster.jpg')).mtime.toISOString(),
      src: `/p24/cartoons/${slug}/poster.jpg`,
      panels: [],
      category: 'Daily Cartoon',
      signature_masked: true,
      source_page: `daily-cartoons/queue/${day}/${neutralSlug(s)}/`,
    });
  }
}

// ---------- write + guard ----------
const doc = {
  generated_at: new Date().toISOString(),
  note: 'Original P24 (PANDEMONIUM-24) invented-satire photos and cartoons, imported by scripts/import-p24.mjs.',
  counts: { photo: items.filter(i => i.type === 'photo').length, cartoon: items.filter(i => i.type === 'cartoon').length, total: items.length },
  items,
};
const json = JSON.stringify(doc, null, 2);
const leaks = [];
if (BANNED.test(json)) leaks.push('data/p24.json');
for (const f of fs.readdirSync(OUT, { recursive: true })) {
  if (BANNED.test(f)) leaks.push('path:' + f);
  if (/\.svg$/.test(f) && BANNED.test(fs.readFileSync(path.join(OUT, f), 'utf8'))) leaks.push('svg:' + f);
}
if (leaks.length) { console.error('NAME LEAK — refusing to write:', leaks); process.exit(1); }
fs.writeFileSync(path.join(ROOT, 'data/p24.json'), json + '\n');
console.log(`imported ${doc.counts.photo} photos + ${doc.counts.cartoon} cartoons (${doc.counts.total} items); ` +
  `${items.reduce((n, i) => n + i.panels.length, 0)} panel svgs`);