← back to Wallco Ai
aesthetic purge: add scripts/purge-neon-and-multicolor.js (neon + ≥4 hue buckets)
1db958091938a3ff64d971858265b0b9a6272fb0 · 2026-05-19 23:30:43 -0700 · Steve
Files touched
A scripts/purge-neon-and-multicolor.js
Diff
commit 1db958091938a3ff64d971858265b0b9a6272fb0
Author: Steve <steve@designerwallcoverings.com>
Date: Tue May 19 23:30:43 2026 -0700
aesthetic purge: add scripts/purge-neon-and-multicolor.js (neon + ≥4 hue buckets)
---
scripts/purge-neon-and-multicolor.js | 353 +++++++++++++++++++++++++++++++++++
1 file changed, 353 insertions(+)
diff --git a/scripts/purge-neon-and-multicolor.js b/scripts/purge-neon-and-multicolor.js
new file mode 100755
index 0000000..aa9ea5f
--- /dev/null
+++ b/scripts/purge-neon-and-multicolor.js
@@ -0,0 +1,353 @@
+#!/usr/bin/env node
+/**
+ * Aesthetic purge — wallco.ai 2026-05-19.
+ *
+ * Steve's directive: "remove all bright neon patterns of patterns with more
+ * than 6 colors on wallco.ai and log to not show those colors any more.
+ * Focus more on tone on tone in high fashion colors."
+ *
+ * Two pass criteria (a design qualifies if ANY pass fires):
+ *
+ * (A) PALETTE pass — measured from spoon_all_designs.palette (jsonb)
+ * - distinct_hue_buckets >= 4 (the 4-bucket "more than 6 colors" rule
+ * under a 5-cap palette; hue buckets count distinct families in the
+ * 12-bucket hue wheel rather than rg/by twins)
+ * - OR any palette entry is neon: HSL S >= 70, L 45..75, pct >= 5
+ *
+ * (B) TAG / PROMPT pass — string match on tags[] + prompt
+ * - regex /\b(neon|fluorescent|electric|rainbow|day[- ]?glo|highlighter)\b/i
+ *
+ * For each matched design we mirror the same 6 steps as
+ * POST /api/design/:id/settlement-violation in server.js (~L5661):
+ * 1) SELECT row
+ * 2) Back up image to ~/.wallco-deleted/<DATE>/<id>_<basename>.png
+ * 3) Append JSONL entry to data/settlement-audit/do-not-want.jsonl
+ * 4) unlink local file
+ * 5) DELETE FROM spoon_all_designs WHERE id=$1
+ * 6) cache invalidation = pm2 reload wallco-ai (caller's job)
+ *
+ * Usage:
+ * node scripts/purge-neon-and-multicolor.js --dry-run
+ * node scripts/purge-neon-and-multicolor.js --apply
+ *
+ * Defaults are conservative: nothing happens without --apply.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { spawnSync } = require('child_process');
+
+const ROOT = path.join(__dirname, '..');
+const AUDIT_DIR = path.join(ROOT, 'data', 'settlement-audit');
+const REGISTRY = path.join(AUDIT_DIR, 'do-not-want.jsonl');
+const TODAY = new Date().toISOString().slice(0, 10);
+const BACKUP_DIR = path.join(os.homedir(), '.wallco-deleted', TODAY);
+
+// ---- args ------------------------------------------------------------------
+const argv = process.argv.slice(2);
+const DRY_RUN = argv.includes('--dry-run');
+const APPLY = argv.includes('--apply');
+if (!DRY_RUN && !APPLY) {
+ console.error('Usage: node scripts/purge-neon-and-multicolor.js [--dry-run | --apply]');
+ process.exit(2);
+}
+if (DRY_RUN && APPLY) {
+ console.error('Pass exactly one of --dry-run or --apply');
+ process.exit(2);
+}
+
+// ---- psql wrapper (mirrors generate_designs.js) ---------------------------
+function psql(sql) {
+ const onLinux = (process.platform === 'linux');
+ const cmd = onLinux ? 'sudo' : 'psql';
+ const args = onLinux
+ ? ['-n', '-u', 'postgres', 'psql', 'dw_unified', '-At', '-q']
+ : ['dw_unified', '-At', '-q'];
+ const r = spawnSync(cmd, args, { input: sql, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
+ if (r.status !== 0) throw new Error(r.stderr || r.stdout || 'psql failed');
+ return r.stdout;
+}
+
+// ---- colour analysis ------------------------------------------------------
+// 12-bucket hue wheel — distinct hue families count as separate "colors" in
+// human perception (red, orange, yellow, chartreuse, green, teal, cyan, blue,
+// indigo, violet, magenta, pink). Whites/blacks/very-low-sat greys collapse
+// to a single neutral bucket. This is the rule Steve's "more than 6 colors"
+// maps to under a 5-cap palette.
+function hexToHsl(hex) {
+ if (!hex || typeof hex !== 'string') return null;
+ const m = hex.trim().match(/^#?([0-9a-f]{6})$/i);
+ if (!m) return null;
+ const n = parseInt(m[1], 16);
+ const r = ((n >> 16) & 0xff) / 255;
+ const g = ((n >> 8) & 0xff) / 255;
+ const b = ( n & 0xff) / 255;
+ const mx = Math.max(r, g, b);
+ const mn = Math.min(r, g, b);
+ const l = (mx + mn) / 2;
+ let h = 0, s = 0;
+ if (mx !== mn) {
+ const d = mx - mn;
+ s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
+ switch (mx) {
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break;
+ case g: h = (b - r) / d + 2; break;
+ case b: h = (r - g) / d + 4; break;
+ }
+ h *= 60;
+ }
+ return { h, s: s * 100, l: l * 100 };
+}
+
+// Returns a bucket name for a hex. Neutrals (sat<12 OR L<10 OR L>90) → 'neutral'.
+function hueBucket(hex) {
+ const hsl = hexToHsl(hex);
+ if (!hsl) return 'unknown';
+ if (hsl.s < 12 || hsl.l < 10 || hsl.l > 90) return 'neutral';
+ const h = hsl.h;
+ if (h < 15) return 'red';
+ if (h < 45) return 'orange';
+ if (h < 65) return 'yellow';
+ if (h < 90) return 'chartreuse';
+ if (h < 150) return 'green';
+ if (h < 180) return 'teal';
+ if (h < 210) return 'cyan';
+ if (h < 250) return 'blue';
+ if (h < 280) return 'indigo';
+ if (h < 310) return 'violet';
+ if (h < 340) return 'magenta';
+ return 'red'; // 340..360
+}
+
+function isNeon(hex, pct) {
+ const hsl = hexToHsl(hex);
+ if (!hsl) return false;
+ return hsl.s >= 70 && hsl.l >= 45 && hsl.l <= 75 && (pct == null || pct >= 5);
+}
+
+// ---- tag / prompt regex ---------------------------------------------------
+const TAG_PROMPT_RE = /\b(neon|fluorescent|electric|rainbow|day[- ]?glo|highlighter|fluoro)\b/i;
+
+// ---- pull candidate set ---------------------------------------------------
+// We need: id, prompt, category, motifs, tags, generator, dominant_hex,
+// palette, parent_design_id, local_path. Pull as TSV-of-JSON-per-row for
+// safe transport through psql -At.
+console.log('[1/4] Loading all designs from dw_unified.spoon_all_designs ...');
+const rowsRaw = psql(`
+ SELECT row_to_json(t)
+ FROM (
+ SELECT id, prompt, category, motifs, tags, generator, dominant_hex,
+ palette, parent_design_id, local_path
+ FROM spoon_all_designs
+ ) t;
+`).split('\n').filter(Boolean);
+
+console.log(` ${rowsRaw.length} rows fetched.`);
+
+// ---- evaluate -------------------------------------------------------------
+const matches = [];
+const breakdown = {
+ palette_only: 0,
+ tag_only: 0,
+ both: 0,
+ hue_buckets_4plus: 0,
+ hue_buckets_5plus: 0,
+ hue_buckets_6plus: 0,
+ neon_palette: 0,
+ tag_match: 0,
+};
+
+for (const line of rowsRaw) {
+ let r;
+ try { r = JSON.parse(line); } catch { continue; }
+
+ // PALETTE pass
+ let paletteHit = false;
+ let neonHit = false;
+ let hueBuckets = 0;
+ const palette = Array.isArray(r.palette) ? r.palette : [];
+
+ if (palette.length) {
+ const buckets = new Set();
+ for (const p of palette) {
+ const b = hueBucket(p.hex);
+ if (b !== 'neutral' && b !== 'unknown') buckets.add(b);
+ if (isNeon(p.hex, p.pct)) neonHit = true;
+ }
+ hueBuckets = buckets.size;
+ if (hueBuckets >= 4) paletteHit = true;
+ if (neonHit) paletteHit = true;
+ }
+ if (hueBuckets >= 4) breakdown.hue_buckets_4plus++;
+ if (hueBuckets >= 5) breakdown.hue_buckets_5plus++;
+ if (hueBuckets >= 6) breakdown.hue_buckets_6plus++;
+ if (neonHit) breakdown.neon_palette++;
+
+ // TAG / PROMPT pass
+ const haystack = [
+ r.prompt || '',
+ Array.isArray(r.tags) ? r.tags.join(' ') : '',
+ Array.isArray(r.motifs) ? r.motifs.join(' ') : '',
+ ].join(' ');
+ const tagHit = TAG_PROMPT_RE.test(haystack);
+ if (tagHit) breakdown.tag_match++;
+
+ if (paletteHit && tagHit) breakdown.both++;
+ else if (paletteHit) breakdown.palette_only++;
+ else if (tagHit) breakdown.tag_only++;
+
+ if (paletteHit || tagHit) {
+ matches.push({
+ id: r.id,
+ prompt: r.prompt,
+ category: r.category,
+ motifs: r.motifs,
+ tags: r.tags,
+ generator: r.generator,
+ dominant_hex: r.dominant_hex,
+ palette,
+ parent_design_id: r.parent_design_id,
+ local_path: r.local_path,
+ _reasons: {
+ palette: paletteHit,
+ tag: tagHit,
+ hue_buckets: hueBuckets,
+ neon: neonHit,
+ tag_regex_match: tagHit ? haystack.match(TAG_PROMPT_RE)?.[0] : null,
+ },
+ });
+ }
+}
+
+console.log('\n[2/4] Evaluation breakdown:');
+console.log(' ', JSON.stringify(breakdown, null, 2).replace(/\n/g, '\n '));
+console.log(`\n TOTAL MATCHES: ${matches.length}`);
+
+// ---- sample print ---------------------------------------------------------
+function topReason(m) {
+ const r = m._reasons;
+ if (r.palette && r.tag) return `palette+tag (buckets=${r.hue_buckets}, neon=${r.neon}, tag="${r.tag_regex_match}")`;
+ if (r.palette) return `palette (buckets=${r.hue_buckets}, neon=${r.neon})`;
+ return `tag ("${r.tag_regex_match}")`;
+}
+console.log('\n[3/4] Sample (first 10 matches):');
+for (const m of matches.slice(0, 10)) {
+ const prompt = (m.prompt || '').slice(0, 80).replace(/\s+/g, ' ');
+ console.log(` #${m.id} · ${m.category || '?'} · ${topReason(m)} · "${prompt}"`);
+}
+
+if (DRY_RUN) {
+ console.log('\n[4/4] DRY-RUN — nothing deleted. Re-run with --apply to execute.');
+ process.exit(0);
+}
+
+// ---- APPLY ----------------------------------------------------------------
+console.log(`\n[4/4] APPLY — purging ${matches.length} designs ...`);
+try { fs.mkdirSync(BACKUP_DIR, { recursive: true }); } catch {}
+try { fs.mkdirSync(AUDIT_DIR, { recursive: true }); } catch {}
+
+const REASON = 'aesthetic-purge-2026-05-19: neon and/or ≥4 distinct hues — tone-on-tone direction';
+
+let deleted = 0;
+let backedUp = 0;
+let unlinked = 0;
+let appendedEntries = 0;
+let dbDeleteFails = 0;
+const errors = [];
+
+// Process in chunks so we don't blow the psql input buffer on a 1000-row
+// DELETE IN (...) — and so a single row failure doesn't tank the rest.
+const CHUNK = 50;
+for (let i = 0; i < matches.length; i += CHUNK) {
+ const chunk = matches.slice(i, i + CHUNK);
+
+ // 2) backup + 4) unlink (filesystem) — do this BEFORE the DB DELETE so a
+ // PG hiccup doesn't leave us with orphaned files. We accumulate audit
+ // entries first, write them after each chunk's DB DELETE succeeds.
+ const auditLines = [];
+ const idsToDelete = [];
+
+ for (const m of chunk) {
+ let backupFile = null;
+ if (m.local_path && fs.existsSync(m.local_path)) {
+ try {
+ const bn = `${m.id}_${path.basename(m.local_path)}`;
+ backupFile = path.join(BACKUP_DIR, bn);
+ fs.copyFileSync(m.local_path, backupFile);
+ backedUp++;
+ } catch (e) {
+ errors.push(`backup #${m.id}: ${e.message}`);
+ backupFile = null;
+ }
+ }
+
+ const entry = {
+ ts: new Date().toISOString(),
+ id: m.id,
+ reason: REASON,
+ prompt: m.prompt || null,
+ category: m.category || null,
+ motifs: m.motifs || [],
+ tags: m.tags || [],
+ generator: m.generator || null,
+ dominant_hex: m.dominant_hex || null,
+ parent_design_id: m.parent_design_id || null,
+ palette: m.palette || [],
+ backup_file: backupFile,
+ bulk_purge: true,
+ regen_suppressed: true,
+ detection: m._reasons,
+ };
+ auditLines.push(JSON.stringify(entry));
+ idsToDelete.push(m.id);
+ }
+
+ // 5) DELETE in one statement per chunk
+ try {
+ psql(`DELETE FROM spoon_all_designs WHERE id IN (${idsToDelete.join(',')});`);
+ deleted += idsToDelete.length;
+ } catch (e) {
+ dbDeleteFails += idsToDelete.length;
+ errors.push(`DELETE chunk @${i}: ${e.message}`);
+ // Skip the file unlink + audit append for this chunk so DB+disk stay in sync.
+ continue;
+ }
+
+ // Append audit entries for the chunk
+ try {
+ fs.appendFileSync(REGISTRY, auditLines.join('\n') + '\n', 'utf8');
+ appendedEntries += auditLines.length;
+ } catch (e) {
+ errors.push(`audit append @${i}: ${e.message}`);
+ }
+
+ // Now unlink files (DB row already gone; safe to remove disk image)
+ for (const m of chunk) {
+ if (m.local_path && fs.existsSync(m.local_path)) {
+ try { fs.unlinkSync(m.local_path); unlinked++; } catch (e) { errors.push(`unlink #${m.id}: ${e.message}`); }
+ }
+ }
+
+ if (((i / CHUNK) | 0) % 4 === 0) {
+ console.log(` ... ${Math.min(i + CHUNK, matches.length)}/${matches.length} processed`);
+ }
+}
+
+console.log('\n========================================');
+console.log(' PURGE COMPLETE');
+console.log('========================================');
+console.log(` matches considered : ${matches.length}`);
+console.log(` db rows deleted : ${deleted}`);
+console.log(` db delete failures : ${dbDeleteFails}`);
+console.log(` files backed up : ${backedUp} → ${BACKUP_DIR}`);
+console.log(` files unlinked : ${unlinked}`);
+console.log(` audit lines appended: ${appendedEntries} → ${REGISTRY}`);
+console.log(` errors : ${errors.length}`);
+if (errors.length) {
+ console.log('\nFirst 10 errors:');
+ for (const e of errors.slice(0, 10)) console.log(' - ' + e);
+}
+
+console.log('\nNext step: pm2 reload wallco-ai (invalidates DESIGNS in-memory cache)');
← 079feef report: dw-stale-urls rescrape pass (4 vendors, 806 products
·
back to Wallco Ai
·
aesthetic purge: log 1007 designs to do-not-want.jsonl (neon eee8638 →