← back to Paul Conrad Cartoons
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`);