scripts/moodboard.mjs
scripts/moodboard.mjsBrowse 27 files
3,290 tokens
11,913 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env node2/**3 * moodboard.mjs — visual reference search → downloaded images → one contact sheet you can actually look at.4 *5 * Usage:6 * node scripts/moodboard.mjs "<query>" ["<query2>" ...] [options]7 *8 * Options:9 * --source bing,pinterest,arena which sources (default all three)10 * --limit N images kept in total (default 24, cap 60)11 * --out DIR output dir (default design/moodboard)12 * --cols N contact-sheet columns (default 5)13 * --min-kb N skip images smaller than this (default 15)14 *15 * Writes DIR/img/NN.<ext>, DIR/contact-sheet*.png (LOOK AT THESE), DIR/MOODBOARD.md.16 * Exit 0 if any image landed.17 *18 * Source notes (probed live, 2026-08):19 * · bing — most reliable. Indexes pinimg / dribbble / behance CDNs and serves20 * hotlinkable originals through a browser context. This is the workhorse.21 * · pinterest — logged-out search renders a partial grid behind a login wall: sometimes22 * ~15 pins, sometimes zero. Best-effort, never load-bearing. Thumbnails are23 * rewritten 236x → 736x (originals/ is 403 for hotlinks).24 * · arena — api.are.na public search, no auth. Curated designer taste, lower volume.25 */26 27import { mkdir, writeFile } from 'fs/promises';28import { resolve, join } from 'path';29 30const args = process.argv.slice(2);31if (!args.length || args.includes('--help')) {32 console.log(`moodboard.mjs — image search → contact sheet33 34 node scripts/moodboard.mjs "<query>" ["<query2>" ...] [--source bing,pinterest,arena]35 [--limit 24] [--out design/moodboard] [--cols 5] [--min-kb 15]`);36 process.exit(0);37}38const get = (f, d) => { const i = args.indexOf(f); return i !== -1 ? args[i + 1] : d; };39const flagIdx = args.findIndex(a => a.startsWith('--'));40const queries = (flagIdx === -1 ? args : args.slice(0, flagIdx)).filter(Boolean);41 42const sources = get('--source', 'bing,pinterest,arena').split(',').map(s => s.trim());43const limit = Math.min(parseInt(get('--limit', '24'), 10) || 24, 60);44const outDir = resolve(get('--out', 'design/moodboard'));45const cols = parseInt(get('--cols', '5'), 10) || 5;46const minKb = parseInt(get('--min-kb', '15'), 10) || 15;47 48if (!queries.length) { console.error('[moodboard] no query given.'); process.exit(1); }49 50let chromium;51try { ({ chromium } = await import('playwright')); }52catch { console.error('playwright not found. Install: npm i -D playwright && npx playwright install chromium'); process.exit(2); }53 54const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';55const browser = await chromium.launch({ headless: true });56const ctx = await browser.newContext({ userAgent: UA, viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });57 58async function open(page, url, settle = 4000) {59 await page.goto(url, { waitUntil: 'commit', timeout: 30_000 });60 const dcl = await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).then(() => true).catch(() => false);61 await page.waitForTimeout(settle);62 if (!dcl) await page.evaluate(() => window.stop()).catch(() => {}); // stream that never closes63}64 65// --- sources ------------------------------------------------------------------66async function fromBing(q, want) {67 const page = await ctx.newPage();68 const out = [];69 try {70 await open(page, 'https://www.bing.com/images/search?q=' + encodeURIComponent(q) + '&form=HDRSC2', 3500);71 for (let pass = 0; pass < 3 && out.length < want; pass++) {72 const items = await page.evaluate(() =>73 [...document.querySelectorAll('a.iusc')].map(a => { try { return JSON.parse(a.getAttribute('m')); } catch { return null; } }).filter(Boolean));74 for (const it of items) {75 if (out.length >= want) break;76 if (it.murl && !out.some(o => o.img === it.murl)) out.push({ img: it.murl, thumb: it.turl, page: it.purl, source: 'bing' });77 }78 await page.evaluate(() => window.scrollBy(0, innerHeight * 2));79 await page.waitForTimeout(1800);80 }81 } catch (e) { console.error(`[moodboard] bing "${q}": ${e.message.split('\n')[0]}`); }82 finally { await page.close(); }83 return out;84}85 86async function fromPinterest(q, want) {87 const page = await ctx.newPage();88 const out = [];89 try {90 await open(page, 'https://www.pinterest.com/search/pins/?q=' + encodeURIComponent(q) + '&rs=typed', 6000);91 for (let pass = 0; pass < 4 && out.length < want; pass++) {92 const items = await page.evaluate(() =>93 [...document.querySelectorAll('img')]94 .filter(i => /i\.pinimg\.com\/\d+x\//.test(i.src))95 .map(i => ({ src: i.src, pin: i.closest('a[href^="/pin/"]')?.getAttribute('href') || null })));96 for (const it of items) {97 // 736x is the largest size that stays hotlinkable; /originals/ returns 403.98 const img = it.src.replace(/\/\d+x\//, '/736x/');99 if (out.length >= want) break;100 if (!out.some(o => o.img === img)) out.push({ img, thumb: it.src, page: it.pin ? 'https://www.pinterest.com' + it.pin : 'https://www.pinterest.com/search/pins/?q=' + encodeURIComponent(q), source: 'pinterest' });101 }102 await page.evaluate(() => window.scrollBy(0, innerHeight * 1.5));103 await page.waitForTimeout(2000);104 }105 if (!out.length) console.error('[moodboard] pinterest returned 0 (login wall) — bing covers it.');106 } catch (e) { console.error(`[moodboard] pinterest "${q}": ${e.message.split('\n')[0]}`); }107 finally { await page.close(); }108 return out;109}110 111async function fromArena(q, want) {112 const out = [];113 try {114 const res = await ctx.request.get(`https://api.are.na/v2/search?q=${encodeURIComponent(q)}&per=${Math.min(want * 2, 40)}`, { timeout: 20_000 });115 const json = await res.json();116 for (const b of json.blocks || []) {117 const img = b.image?.large?.url || b.image?.display?.url || b.image?.original?.url;118 if (!img || out.length >= want) continue;119 out.push({ img, thumb: b.image?.thumb?.url, page: `https://www.are.na/block/${b.id}`, source: 'arena', title: b.title });120 }121 } catch (e) { console.error(`[moodboard] are.na "${q}": ${e.message.split('\n')[0]}`); }122 return out;123}124 125// --- collect ------------------------------------------------------------------126const perSourcePerQuery = Math.ceil(limit / (sources.length * queries.length)) + 4;127let candidates = [];128for (const q of queries) {129 for (const s of sources) {130 const fn = s === 'bing' ? fromBing : s === 'pinterest' ? fromPinterest : s === 'arena' ? fromArena : null;131 if (!fn) { console.error(`[moodboard] unknown source "${s}"`); continue; }132 const got = await fn(q, perSourcePerQuery);133 process.stderr.write(`[moodboard] ${s} · "${q}" → ${got.length}\n`);134 candidates.push(...got.map(g => ({ ...g, query: q })));135 }136}137// dedupe by url, then interleave sources so one source can't own the whole sheet138const seen = new Set();139candidates = candidates.filter(c => !seen.has(c.img) && seen.add(c.img));140const bySource = sources.map(s => candidates.filter(c => c.source === s));141const ordered = [];142for (let i = 0; ordered.length < candidates.length; i++) {143 let added = false;144 for (const bucket of bySource) if (bucket[i]) { ordered.push(bucket[i]); added = true; }145 if (!added) break;146}147 148// --- download -----------------------------------------------------------------149await mkdir(join(outDir, 'img'), { recursive: true });150const kept = [];151const sizes = new Set();152for (const c of ordered) {153 if (kept.length >= limit) break;154 let buf = null, ct = '';155 for (const url of [c.img, c.thumb].filter(Boolean)) {156 try {157 const r = await ctx.request.get(url, { timeout: 20_000, headers: { Referer: c.page || '' } });158 if (!r.ok()) continue;159 ct = r.headers()['content-type'] || '';160 if (!/^image\//.test(ct)) continue;161 const b = await r.body();162 if (b.length < minKb * 1024) continue;163 buf = b; c.used = url; break;164 } catch {}165 }166 if (!buf) continue;167 if (sizes.has(buf.length)) continue; // cheap byte-identical dedupe across sources168 sizes.add(buf.length);169 const ext = /png/.test(ct) ? 'png' : /webp/.test(ct) ? 'webp' : /gif/.test(ct) ? 'gif' : 'jpg';170 const n = String(kept.length + 1).padStart(2, '0');171 const file = `img/${n}.${ext}`;172 await writeFile(join(outDir, file), buf);173 kept.push({ ...c, n, file, kb: Math.round(buf.length / 1024) });174}175 176// --- contact sheet ------------------------------------------------------------177// Rendering an HTML grid and screenshotting it needs no extra dependency and copes178// with mixed aspect ratios — one image the model reads instead of 24 separate ones.179const PER_SHEET = 20;180// A remainder of one or two tiles becoming its own 95%-empty page costs a second look for nothing,181// and the doctrine promises reading a moodboard costs one look. Balance the sheets instead.182const sheetCount = Math.max(1, Math.round(kept.length / PER_SHEET));183const perSheet = Math.ceil(kept.length / sheetCount);184const sheets = [];185for (let s = 0; s * perSheet < kept.length; s++) {186 const slice = kept.slice(s * perSheet, (s + 1) * perSheet);187 const html = `<!doctype html><meta charset="utf-8"><style>188 body{margin:0;background:#111;font:13px/1.2 ui-monospace,monospace;color:#eee}189 .g{display:grid;grid-template-columns:repeat(${cols},1fr);gap:10px;padding:10px}190 figure{margin:0;background:#1c1c1c;border-radius:4px;overflow:hidden}191 img{display:block;width:100%;height:210px;object-fit:contain;background:#0a0a0a}192 figcaption{padding:4px 6px;color:#9a9a9a}193 b{color:#fff}194 </style><div class=g>${slice.map(k =>195 `<figure><img src="img/${k.file.split('/')[1]}"><figcaption><b>${k.n}</b> ${k.source}</figcaption></figure>`).join('')}</div>`;196 const f = join(outDir, `_sheet${s}.html`);197 await writeFile(f, html, 'utf8');198 const page = await ctx.newPage();199 await page.setViewportSize({ width: cols * 320, height: 1000 });200 await page.goto('file:///' + f.replace(/\\/g, '/'), { waitUntil: 'load', timeout: 20_000 }).catch(() => {});201 await page.waitForTimeout(1200);202 const name = sheetCount === 1 ? 'contact-sheet.png' : `contact-sheet-${s + 1}.png`;203 await page.screenshot({ path: join(outDir, name), fullPage: true });204 await page.close();205 sheets.push(name);206}207 208await browser.close();209 210// --- index --------------------------------------------------------------------211const md = [212 `# MOODBOARD — ${queries.map(q => `"${q}"`).join(' · ')}`,213 '',214 sheets.length215 ? `**Look at ${sheets.map(s => `\`${s}\``).join(', ')} first** — every tile is numbered; refer to tiles by number below.`216 : '_no contact sheet: nothing downloaded._',217 '',218 'Reference images are for *direction* — palette, light, composition, texture, type energy.',219 'They are not assets: never ship a downloaded image, and never reproduce one composition.',220 'Name what you take from each tile you use, then throw the rest away.',221 '',222 '| # | source | kb | origin page |',223 '|---|---|---|---|',224 ...kept.map(k => `| ${k.n} | ${k.source} | ${k.kb} | ${k.page ? `[link](${k.page})` : '—'} |`),225 '',226 '## Read (fill in)',227 '- dominant palette across the tiles you like: ',228 '- light / contrast character: ',229 '- composition move worth stealing: ',230 '- what to explicitly avoid from these: ',231 '',232].join('\n');233await writeFile(join(outDir, 'MOODBOARD.md'), md, 'utf8');234 235console.log(`\n=== moodboard ===`);236console.log(`queries : ${queries.join(' | ')}`);237console.log(`kept : ${kept.length} images (${sources.map(s => `${s}:${kept.filter(k => k.source === s).length}`).join(' ')})`);238console.log(`sheets : ${sheets.map(s => join(outDir, s)).join('\n ') || '—'}`);239console.log(`index : ${join(outDir, 'MOODBOARD.md')}`);240process.exit(kept.length ? 0 : 1);241