← back to Paul Conrad Cartoons
scripts/extract-arena-ideas.cjs
98 lines
#!/usr/bin/env node
// extract-arena-ideas.cjs — pull every cartoon idea out of a finished Model Arena battle (TK-12241). $0, local.
// Usage: node scripts/extract-arena-ideas.cjs [battleDir] (default ~/Projects/model-arena/data/artifacts/7b062a7d42e5)
// Each artifact is a model-written HTML page; some build their cards from an inline <script> array
// (const ideas/cards = [...]), others hard-code .card DOM. Every page is rendered in headless Chromium
// and read BOTH ways: the JS array when present (authoritative), else the rendered .card DOM.
// Output: data/arena-ideas.json — deduped, with dropped ideas listed + reasons. Existing curation
// status (approved/deleted) is carried over by id on re-extract.
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { chromium } = require(process.env.PLAYWRIGHT_PATH || path.join(process.env.HOME, 'Projects/jevrun-runner/node_modules/playwright'));
const DIR = process.argv[2] || path.join(process.env.HOME, 'Projects/model-arena/data/artifacts/7b062a7d42e5');
const BATTLE = path.basename(DIR);
const OUT = path.join(__dirname, '..', 'data', 'arena-ideas.json');
const NAME = new RegExp(['con', 'rad'].join(''), 'i');
// Drop rules: real people, brands / franchises, named artists, style-mimicry, and caricature risk.
const DROP = [
[/in the style of|style of [A-Z]/i, 'asks for an artist style'],
[NAME, 'names the research subject'],
[/\b(trump|biden|obama|clinton|bush|nixon|reagan|kennedy|carter|putin|musk|bezos|zuckerberg|pelosi|mcconnell|harris|desantis|newsom|schwarzenegger)\b/i, 'names a real person'],
[/\b(uber|lyft|instagram|twitter|tiktok|facebook|meta|google|amazon|apple|tesla|netflix|disney|starbucks|mcdonald'?s?|coca-?cola|pepsi|walmart|star wars|middle[- ]earth|tolkien|marvel|pixar|lego|iphone|youtube)\b/i, 'names a brand / franchise'],
[/\b(picasso|warhol|dali|van gogh|banksy|rockwell|seuss|schulz)\b/i, 'names an artist'],
[/\b(black|white|asian|latino|jewish|muslim) (man|woman|person|people)'?s? face\b|face on the pendulum/i, 'racial caricature risk'],
];
const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
const unquote = (s) => clean(s).replace(/^["“'‘]+|["”'’]+$/g, '');
const modelOf = (f) => path.basename(f, '.html').replace(/^(mac2|mlx)__/, '');
(async () => {
const files = fs.readdirSync(DIR).filter(f => f.endsWith('.html')).sort();
const browser = await chromium.launch();
const raw = [];
const perModel = {};
for (const f of files) {
const page = await browser.newPage();
await page.goto('file://' + path.join(DIR, f), { waitUntil: 'load' });
const got = await page.evaluate(() => {
// eslint-disable-next-line no-undef
const arr = (typeof ideas !== 'undefined' && Array.isArray(ideas)) ? ideas : (typeof cards !== 'undefined' && Array.isArray(cards) && cards.length && !(cards[0] instanceof Element)) ? cards : null;
if (arr) return { via: 'script-array', rows: arr.map(o => ({ theme: o.theme, title: o.title, caption: o.caption, scene: o.scene, why: o.why || o.whyFunny || o.reason, drawability: o.drawability })) };
const q = (el, sel) => { const n = el.querySelector(sel); return n ? n.textContent : ''; };
return { via: 'dom', rows: [...document.querySelectorAll('.card')].map(c => ({
theme: q(c, '.theme-chip, .theme'), title: q(c, '.card-title, h2, h3'), caption: q(c, '.caption'), scene: q(c, '.scene'),
why: q(c, '.funny-reason, .whys, .why'), drawability: c.getAttribute('data-drawability') || q(c, '.drawability, .score'),
})) };
});
const mtime = fs.statSync(path.join(DIR, f)).mtime.toISOString();
perModel[modelOf(f)] = { file: f, via: got.via, extracted: got.rows.length };
for (const r of got.rows) raw.push({ ...r, model: modelOf(f), created_at: mtime });
await page.close();
}
await browser.close();
const prev = fs.existsSync(OUT) ? JSON.parse(fs.readFileSync(OUT, 'utf8')) : { items: [] };
const prevById = Object.fromEntries(prev.items.map(i => [i.id, i]));
const seen = new Map();
const items = [];
const dropped = [];
for (const r of raw) {
let caption = unquote(r.caption);
const notes = [];
if (/[ -鿿]/.test(caption + r.scene)) { caption = caption.replace(/授权/g, 'authorized ').replace(/[ -鿿]+/g, '').replace(/\s+/g, ' ').trim(); notes.push('garbled CJK token cleaned'); }
const it = {
model: r.model, theme: clean(r.theme).replace(/&/g, '&'), title: clean(r.title), caption,
scene: clean(r.scene).replace(/[ -鿿]+/g, ''), why: clean(r.why), drawability: Number(clean(r.drawability)) || null,
created_at: r.created_at,
};
const blob = [it.title, it.caption, it.scene, it.why].join(' ');
const hit = DROP.find(([re]) => re.test(blob));
if (!it.title || !it.scene) { dropped.push({ model: it.model, title: it.title, reason: 'incomplete (no title/scene)' }); continue; }
if (hit) { dropped.push({ model: it.model, title: it.title, reason: hit[1] }); continue; }
const key = it.title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
const ckey = it.caption.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
if (seen.has(key) || seen.has(ckey)) { dropped.push({ model: it.model, title: it.title, reason: `duplicate of ${seen.get(key) || seen.get(ckey)}` }); continue; }
seen.set(key, `${it.model}:${it.title}`); seen.set(ckey, `${it.model}:${it.title}`);
it.id = 'arena-' + crypto.createHash('sha1').update(`${BATTLE}|${it.model}|${it.title}`).digest('hex').slice(0, 10);
const p = prevById[it.id];
it.status = (p && p.status) || 'pending';
it.status_at = (p && p.status_at) || null;
if (notes.length) it.note = notes.join('; ');
items.push(it);
}
for (const m of Object.keys(perModel)) {
perModel[m].kept = items.filter(i => i.model === m).length;
perModel[m].dropped = dropped.filter(d => d.model === m).length;
}
const doc = { generated_at: new Date().toISOString(), source: `model-arena battle ${BATTLE}`, per_model: perModel, counts: { extracted: raw.length, kept: items.length, dropped: dropped.length }, dropped, items };
const body = JSON.stringify(doc, null, 2);
if (NAME.test(body)) { console.error('FAIL: naming rule hit in arena output'); process.exit(1); }
fs.writeFileSync(OUT, body + '\n');
console.log(`extracted ${raw.length}, kept ${items.length}, dropped ${dropped.length} -> data/arena-ideas.json`);
for (const [m, v] of Object.entries(perModel)) console.log(` ${m}: ${v.extracted} extracted via ${v.via}, ${v.kept} kept, ${v.dropped} dropped`);
for (const d of dropped) console.log(` dropped [${d.model}] ${d.title}: ${d.reason}`);
})().catch(e => { console.error(e); process.exit(2); });