← back to Patterndesignlab
scripts/build-catalog.js
231 lines
// build-catalog.js — assemble the AUTHORITATIVE seed catalog with CORRECT 1:1 design→image pairs.
// OWNED content only. Two owned sources:
// 1. the 38 curated designs in data/source-catalog.json (trusted, settlement-passed)
// 2. an expansion drawn from wallco-ai/data/designs.json — published, not-removed, seamless,
// file-present-locally, and passing a conservative local settlement keyword screen.
// The patternbank-archive scrape is compliance-LOCKED and is NEVER read here.
// Every design's OWN local image is copied into public/assets/patterns/ named pdl-<id>.<ext>.
// $0 (local) — no paid API, no AI. Deterministic.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'public', 'assets', 'patterns');
const CATALOG_OUT = path.join(ROOT, 'data', 'catalog.json');
const CURATED = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'source-catalog.json'), 'utf8'));
const PILOT_DIR = '/Users/macstudio3/Projects/pattern-vault/public/assets/pilot';
const WALLCO = '/Users/macstudio3/Projects/wallco-ai';
const WALLCO_DESIGNS = path.join(WALLCO, 'data', 'designs.json');
const GEN_DIR = path.join(WALLCO, 'data', 'generated');
const IMG_DIR = path.join(WALLCO, 'data', 'images');
// Supplementary OWNED local image source — the WallpapersBack fork's generated/ dir
// (settlement-passed, same wallpapersback.com provenance). Recovers masters/designs
// whose master file isn't in the wallco-ai dirs but IS on local disk here.
const FORK_GEN_DIR = '/Users/macstudio3/Projects/WallpapersBack/wallpapersback-fork/data/generated';
// OWNED masters pulled READ-ONLY from prod (Kamatera WallpapersBack fork
// /root/public-projects/wallpapersback/data/generated) into this repo's gitignored
// data/prod-masters/ dir — deepens the credible chinoiserie/stripe/other lanes whose
// masters existed only on prod. Same wallpapersback.com provenance, settlement-screened.
const PROD_MASTERS_DIR = path.join(ROOT, 'data', 'prod-masters');
// ---- canonical license terms (feeds the PDP "what you're buying" copy) ----
const TERMS = {
digital_single_use: 'Use this pattern in ONE digital product or ONE marketing campaign — on-screen, web, app or social. Non-exclusive. No physical goods for resale, no redistribution of the source file.',
commercial_print: 'Print this pattern on physical goods for sale (wallpaper, fabric, stationery, packaging) — unlimited print runs. Non-exclusive, worldwide. Attribution optional. No sublicensing of the raw file.',
exclusive: 'Category-exclusive buyout — the design is retired from every other channel and licensed to you alone within your product category. Includes source files. Non-transferable.',
};
const LABELS = { digital_single_use: 'Digital single-use license', commercial_print: 'Commercial print license', exclusive: 'Exclusive license' };
const TILE_PRICES = { digital_single_use: 149, commercial_print: 495, exclusive: 2500 };
const MURAL_PRICES = { digital_single_use: 249, commercial_print: 795, exclusive: 4500 };
function standardTiers(kind) {
const p = (kind === 'mural' || kind === 'mural_panel') ? MURAL_PRICES : TILE_PRICES;
return ['digital_single_use', 'commercial_print', 'exclusive'].map(t => ({
tier: t, label: LABELS[t], priceUsd: p[t], terms: TERMS[t],
}));
}
// Ensure every curated tier also carries usage terms (some PV-INHOUSE tiers lacked them).
function withTerms(tiers, kind) {
const fallback = standardTiers(kind);
if (!Array.isArray(tiers) || !tiers.length) return fallback;
return tiers.map(t => ({
...t,
label: t.label || LABELS[t.tier] || t.tier,
terms: t.terms || TERMS[t.tier] || 'Contact us for full usage terms.',
}));
}
// ---- image resolution + copy ----
const gen = new Set(fs.existsSync(GEN_DIR) ? fs.readdirSync(GEN_DIR) : []);
const imgset = new Set(fs.existsSync(IMG_DIR) ? fs.readdirSync(IMG_DIR) : []);
const forkGen = new Set(fs.existsSync(FORK_GEN_DIR) ? fs.readdirSync(FORK_GEN_DIR) : []);
const prodMasters = new Set(fs.existsSync(PROD_MASTERS_DIR) ? fs.readdirSync(PROD_MASTERS_DIR) : []);
function resolveWallco(filename) {
if (!filename) return null;
if (gen.has(filename)) return path.join(GEN_DIR, filename);
if (imgset.has(filename)) return path.join(IMG_DIR, filename);
if (forkGen.has(filename)) return path.join(FORK_GEN_DIR, filename); // fork fallback (recovers #138 etc.)
if (prodMasters.has(filename)) return path.join(PROD_MASTERS_DIR, filename); // prod read-only pull (deep credible lanes)
return null;
}
function copyImage(srcAbs, id) {
if (!srcAbs || !fs.existsSync(srcAbs)) return null;
const ext = path.extname(srcAbs).toLowerCase() || '.png';
const dest = `pdl-${String(id).replace(/[^a-zA-Z0-9_-]/g, '')}${ext}`;
fs.copyFileSync(srcAbs, path.join(OUT_DIR, dest));
return '/assets/patterns/' + dest;
}
// ---- HARDENED settlement keyword screen -------------------------------------
// Excludes settlement-blocked motifs — birds, butterflies, bananas / banana pods,
// grapes, palm / palm-fronds, and directional-tropical-leaf patterns — across
// title AND tags AND motifs AND category, case-insensitive, word-boundary aware.
// (Word boundaries stop "napalm"/"grapefruit"-style false positives while still
// catching the real motifs.) NO paid AI this pass; this is the deterministic
// local gate. If a design trips it, it is EXCLUDED — better to skip than serve.
const BLOCK_RE = new RegExp(
'\\b(' + [
'banana', 'bananas', 'banana\\s*pod', 'banana\\s*pods',
'grape', 'grapes',
'bird', 'birds', 'birdie',
'butterfly', 'butterflies',
'parrot', 'parrots', 'toucan', 'toucans', 'macaw', 'macaws',
'flamingo', 'flamingos', 'peacock', 'peacocks',
'hummingbird', 'hummingbirds', 'cockatoo', 'cockatoos',
'palm', 'palms', 'palm\\s*frond', 'palm\\s*fronds', 'frond', 'fronds',
'tropical', 'jungle', 'rainforest',
'monstera', 'banana\\s*leaf', 'banana\\s*leaves',
'bird\\s*of\\s*paradise',
].join('|') + ')\\b', 'i');
function settlementBlocked(r) {
const blob = [
r.title, r.category, r.styleLine,
Array.isArray(r.tags) ? r.tags.join(' ') : '',
Array.isArray(r.motifs) ? r.motifs.join(' ') : '',
].filter(Boolean).join(' ');
const m = BLOCK_RE.exec(blob);
return m ? m[0] : null; // returns the matched term (truthy) or null
}
// ---- credible vs novelty classifier (drives credible-first ingestion) -------
const NOVELTY_RE = /drunk|stoned|muybridge|zoo|\bdogs?\b|designer-bugs|monkey|circus|skull/i;
const isNovelty = cat => NOVELTY_RE.test(cat || '');
// ---- wallco-ai lookup for curated numeric-id designs + expansion ----
const wallco = JSON.parse(fs.readFileSync(WALLCO_DESIGNS, 'utf8'));
const byId = new Map(wallco.map(r => [String(r.id), r]));
const out = [];
let resolved = 0, fellBack = 0, blocked = 0;
const usedIds = new Set();
const blockedLog = [];
// ---- 1) curated 38 — resolve each design's OWN image ----
for (const d of CURATED) {
usedIds.add(String(d.id));
// Defense-in-depth: even the "trusted" curated set is run through the hardened
// settlement screen. If a curated design trips it, EXCLUDE it (skip, don't serve).
const hit = settlementBlocked(d);
if (hit) { blocked++; blockedLog.push({ id: d.id, term: hit, where: 'curated' }); continue; }
let srcAbs = null;
if (typeof d.img === 'string' && d.img.startsWith('/assets/pilot/')) {
srcAbs = path.join(PILOT_DIR, path.basename(d.img)); // PV-INHOUSE pilot webp
} else {
const w = byId.get(String(d.id));
if (w) srcAbs = resolveWallco(w.filename); // WPB numeric id → local file
}
const localImg = copyImage(srcAbs, d.id);
if (!localImg) { // honest rule: never assign a plausible-but-wrong tile
fellBack++;
continue; // drop — its real master is gone locally; revisit in iter-3
}
resolved++;
out.push({
id: d.id, title: d.title, category: d.category, kind: d.kind || 'seamless_tile',
dominantHex: d.dominantHex, styleLine: d.styleLine, provenance: d.provenance,
createdAt: d.createdAt, tags: [], motifs: [],
img: localImg,
imgResolved: true,
licenseTiers: withTerms(d.licenseTiers, d.kind),
pricingStatus: d.pricingStatus || 'DRAFT',
});
}
const curatedResolved = out.length;
// ---- 2) expansion from wallco-ai — DEPTH via credible-first ingestion --------
// Goal (iter-3): meaningfully deepen CREDIBLE pattern-design content so the
// catalog reads like a real licensing marketplace — not padded with novelty.
// Strategy: two passes over the file-present, published, not-removed,
// settlement-passed, not-already-used pool:
// PASS A (credible) — ingest EVERY eligible credible design (no cap): the
// botanical / floral / damask / chinoiserie / stripe /
// toile / geometric / aztec-kilim / cactus lanes.
// PASS B (novelty) — keep a REPRESENTATIVE sample for character, hard-capped
// per category so drunk-animals & friends can't flood.
const NOV_PER_CAT = 6, DRUNK_CAP = 14, DOG_CAP = 3;
const eligible = [];
for (const r of wallco) {
if (r.is_published !== true || r.user_removed === true) continue;
if (r.kind !== 'seamless_tile') continue;
if (usedIds.has(String(r.id))) continue;
if (!resolveWallco(r.filename)) continue;
const hit = settlementBlocked(r);
if (hit) { blocked++; blockedLog.push({ id: r.id, term: hit, where: 'expansion', category: r.category }); continue; }
usedIds.add(String(r.id));
eligible.push(r);
}
function pushDesign(r, credible) {
const localImg = copyImage(resolveWallco(r.filename), r.id);
if (!localImg) return false;
resolved++;
out.push({
id: r.id, title: r.title, category: r.category, kind: 'seamless_tile',
dominantHex: r.dominant_hex || null, styleLine: null,
provenance: 'owned · wallpapersback published, not-removed · settlement-screened',
createdAt: r.created_at, tags: r.tags || [], motifs: r.motifs || [],
img: localImg, imgResolved: true, contentClass: credible ? 'credible' : 'novelty',
licenseTiers: standardTiers('seamless_tile'), pricingStatus: 'DRAFT',
});
return true;
}
// PASS A — credible, uncapped, newest-first for a fresh top-of-catalog
const credibleCands = eligible.filter(r => !isNovelty(r.category))
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
let addedCredible = 0;
for (const r of credibleCands) if (pushDesign(r, true)) addedCredible++;
// PASS B — novelty, per-category capped round-robin for representative variety
const novGroups = {};
for (const r of eligible) if (isNovelty(r.category)) (novGroups[r.category] = novGroups[r.category] || []).push(r);
for (const c of Object.keys(novGroups)) novGroups[c].sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const novNames = Object.keys(novGroups).sort();
const capFor = c => c === 'drunk-animals' ? DRUNK_CAP : (/^dogs\b/i.test(c) ? DOG_CAP : NOV_PER_CAT);
const taken = {}; novNames.forEach(c => taken[c] = 0);
let addedNovelty = 0, round = 0;
while (true) {
let progressed = false;
for (const c of novNames) {
if (taken[c] >= capFor(c)) continue;
const r = novGroups[c][taken[c]];
if (!r) continue;
taken[c]++; progressed = true;
if (pushDesign(r, false)) addedNovelty++;
}
if (!progressed || ++round > 200) break;
}
fs.writeFileSync(CATALOG_OUT, JSON.stringify(out, null, 1));
const correct = out.filter(d => d.imgResolved).length;
const credibleTotal = out.filter(d => d.contentClass !== 'novelty' && !isNovelty(d.category)).length;
const noveltyTotal = out.length - credibleTotal;
console.log(`Catalog built: ${out.length} designs (${curatedResolved} curated + ${addedCredible} credible expansion + ${addedNovelty} novelty expansion).`);
console.log(`Curated dropped (master gone locally, NOT mis-imaged): ${fellBack}.`);
console.log(`Settlement-blocked (excluded, not served): ${blocked}.`);
if (blockedLog.length) console.log(' blocked terms:', JSON.stringify(blockedLog.slice(0, 20)));
console.log(`Content balance: ${credibleTotal} credible / ${noveltyTotal} novelty (${(100*noveltyTotal/out.length).toFixed(1)}% novelty).`);
console.log(`Correct 1:1 design→image pairs: ${correct}/${out.length} (100% by construction).`);
console.log(`Images copied into ${path.relative(ROOT, OUT_DIR)}. $0 (local).`);