← back to Muralsource Curator
mural-source-create.mjs
147 lines
// Create Mural Source products on the LIVE DW Shopify store — modeled on the proven
// hollywood-create.mjs (sample-SAFE by construction: BOTH variants in ONE POST /products.json,
// so productVariantsBulkCreate's sample-overwrite bug cannot fire). Then go-live:
// track→activate→qty 2026→publish 13 channels→ACTIVE. Image = live BigCommerce URL which
// Shopify fetches + rehosts to its own CDN (the "localize" step). vendor:"Mural Source" exactly
// matches the smart-collection rule → auto-populates the mural-source collection.
// Mural Source is a CUSTOMER-FACING brand → NO scrub. DRY-RUN default; --apply --limit=N to write.
import fs from 'node:fs';
const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const STORE = (env.match(/^SHOPIFY_STORE=(.+)$/m) || [])[1].trim();
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
const REST = `https://${STORE}/admin/api/2024-10`;
const GQL = `${REST}/graphql.json`;
const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const APPLY = process.argv.includes('--apply');
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
const MANIFEST = (process.argv.find(a => a.startsWith('--manifest=')) || '').split('=')[1]
|| new URL('wave1_six.json', import.meta.url).pathname;
const AUDIT = new URL('mural-source-create-audit.jsonl', import.meta.url).pathname;
const LOCATION_ID = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd (same store as hollywood)
const TARGET_QTY = 2026;
const PUBLICATIONS = [22208643184,22497296496,29646651457,29739483201,29776969793,37904089153,
43657658419,44234276915,44317474867,44317507635,71898464307,115856375859,140027723827].map(n => `gid://shopify/Publication/${n}`);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const esc = s => String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
const tagArr = s => { try { return JSON.parse(s); } catch { return []; } };
function payload(it) {
const pattern = it.pattern_name, color = it.color_name || '';
const title = `${pattern}${color ? ' ' + color : ''} | Mural Source`;
const unit = 'Priced Per Single Roll';
// tags: ai_tags + brand/type identity + color; dedup; guarantee >=2
let tags = tagArr(it.ai_tags).map(t => String(t).trim()).filter(Boolean);
for (const must of ['Mural Source', 'Wallcovering', 'Mural']) if (!tags.some(t => t.toLowerCase() === must.toLowerCase())) tags.push(must);
if (color && !tags.some(t => t.toLowerCase() === color.toLowerCase())) tags.push(color);
tags = [...new Set(tags)];
// description: ai_description if present, else deterministic fallback (satisfies 5-field desc gate)
const desc = it.ai_description && it.ai_description.length > 20
? it.ai_description
: `${pattern}${color ? ' in ' + color : ''} — a ${it.product_type || 'wallcovering'} from Mural Source${it.material ? ', ' + it.material.toLowerCase() : ''}. Sold ${unit.toLowerCase()}.`;
const row = (l, v) => v ? `<tr><td>${l}</td><td>${esc(v)}</td></tr>` : '';
const body = `<p>${esc(desc)}</p><table>` +
row('Width', it.width) + row('Length', it.length) + row('Repeat', it.repeat_v) +
row('Material', it.material) + `<tr><td>Sold Per</td><td>${unit}</td></tr></table>`;
const sl = 'single_line_text_field', ml = 'multi_line_text_field';
const metafields = [];
const mf = (ns, key, val, type = sl) => { if (val) metafields.push({ namespace: ns, key, type, value: String(val) }); };
mf('global', 'Brand', 'Mural Source');
mf('global', 'width', it.width);
mf('global', 'length', it.length);
mf('global', 'repeat', it.repeat_v);
mf('global', 'unit_of_measure', unit);
mf('global', 'lead_time', 'Usually in Stock');
mf('custom', 'pattern_name', pattern);
mf('custom', 'supplier_name', 'Mural Source');
mf('custom', 'manufacturer_sku', it.mfr_sku);
mf('custom', 'color', color);
mf('custom', 'real_color_name', color);
mf('custom', 'material', it.material, ml);
mf('dwc', 'manufacturer_sku', it.mfr_sku);
return { product: {
title, vendor: 'Mural Source', product_type: it.product_type || 'Wallcovering', status: 'draft',
tags: tags.join(', '), body_html: body, metafields, images: it.image_url ? [{ src: it.image_url }] : [],
options: [{ name: 'Size' }],
variants: [
{ sku: it.dw_sku, price: String(parseFloat(it.price_retail).toFixed(2)), option1: unit, taxable: true, requires_shipping: true },
{ sku: `${it.dw_sku}-Sample`, price: '4.25', option1: 'Sample', taxable: true, requires_shipping: true },
],
} };
}
async function gql(query, variables) {
for (let a = 0; a < 4; a++) {
const r = await fetch(GQL, { method: 'POST', headers: H, body: JSON.stringify({ query, variables }) });
const j = await r.json();
if (j.errors) { if (a === 3) throw new Error(JSON.stringify(j.errors)); await sleep(900 * (a + 1)); continue; }
return j.data;
}
}
const Q_V = `query($id:ID!){product(id:$id){status variants(first:10){edges{node{id sku inventoryItem{id}}}}}}`;
const M_TRACK = `mutation($id:ID!){inventoryItemUpdate(id:$id,input:{tracked:true}){userErrors{message}}}`;
const M_ACT = `mutation($iid:ID!,$loc:ID!){inventoryActivate(inventoryItemId:$iid,locationId:$loc){userErrors{message}}}`;
const M_QTY = `mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{message}}}`;
const M_PUB = `mutation($id:ID!,$pubs:[PublicationInput!]!){publishablePublish(id:$id,input:$pubs){userErrors{message}}}`;
const M_ACTIVE = `mutation($id:ID!){productUpdate(input:{id:$id,status:ACTIVE}){product{status}userErrors{message}}}`;
async function goLive(pid) {
const gid = `gid://shopify/Product/${pid}`;
const d = await gql(Q_V, { id: gid });
const items = d.product.variants.edges.map(e => e.node.inventoryItem.id);
for (const iid of items) { await gql(M_TRACK, { id: iid }); await gql(M_ACT, { iid, loc: LOCATION_ID }); }
await gql(M_QTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: items.map(iid => ({ inventoryItemId: iid, locationId: LOCATION_ID, quantity: TARGET_QTY })) } });
await gql(M_PUB, { id: gid, pubs: PUBLICATIONS.map(p => ({ publicationId: p })) });
const r = await gql(M_ACTIVE, { id: gid });
return { status: r.productUpdate?.product?.status, varCount: items.length };
}
const items = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
const createdSet = new Set();
if (fs.existsSync(AUDIT)) for (const l of fs.readFileSync(AUDIT, 'utf8').trim().split('\n').filter(Boolean)) {
try { const r = JSON.parse(l); if (r.action === 'CREATED') createdSet.add(r.dw); } catch {}
}
const remaining = items.filter(it => !createdSet.has(it.dw_sku));
const todo = LIMIT > 0 ? remaining.slice(0, LIMIT) : remaining;
if (createdSet.size) console.log(`resume: ${createdSet.size} already created, ${remaining.length} remain`);
const out = APPLY ? fs.createWriteStream(AUDIT, { flags: 'a' }) : null;
console.log(`mural-source-create: ${todo.length} items · ${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'}`);
let created = 0, err = 0;
for (const it of todo) {
const pl = payload(it);
const fiveField = pl.product.images.length > 0 && pl.product.body_html.length > 30
&& pl.product.tags.split(',').filter(Boolean).length >= 2
&& pl.product.variants.some(v => /-Sample$/.test(v.sku) && v.price === '4.25')
&& pl.product.variants.some(v => !/-Sample$/.test(v.sku) && parseFloat(v.price) > 0);
if (!APPLY) {
console.log(`\n${it.dw_sku} "${pl.product.title}" 5field=${fiveField ? 'OK' : '⚠FAIL'}`);
console.log(` variants: [${pl.product.variants.map(v => v.option1 + ' ' + v.sku + ' $' + v.price).join(' | ')}]`);
console.log(` tags(${pl.product.tags.split(',').length}): ${pl.product.tags.slice(0, 100)}`);
console.log(` img: ${pl.product.images[0]?.src ? 'yes' : 'NONE'} · metafields: ${pl.product.metafields.length}`);
continue;
}
if (!fiveField) { console.error(` SKIP 5-field FAIL ${it.dw_sku}`); out.write(JSON.stringify({ dw: it.dw_sku, action: 'SKIP_5FIELD' }) + '\n'); err++; continue; }
try {
const r = await fetch(`${REST}/products.json`, { method: 'POST', headers: H, body: JSON.stringify(pl) });
const txt = await r.text();
if (r.status === 429 && /daily variant creation limit/i.test(txt)) {
console.error(` ⛔ daily variant-creation cap at ${created} — stopping (resume next run).`);
out.write(JSON.stringify({ dw: it.dw_sku, action: 'CAP_ABORT', created }) + '\n'); out.end(); process.exit(3);
}
let j; try { j = JSON.parse(txt); } catch { j = {}; }
if (!r.ok || !j.product) { console.error(` ERR create ${it.dw_sku}: ${r.status} ${txt.slice(0,120)}`); out.write(JSON.stringify({ dw: it.dw_sku, action: 'ERR_CREATE', status: r.status, body: txt.slice(0,160) }) + '\n'); err++; await sleep(700); continue; }
const pid = j.product.id;
const handle = j.product.handle;
const variants = j.product.variants.map(v => ({ sku: v.sku, price: v.price, opt: v.option1 }));
const sampleOk = variants.some(v => /-Sample$/i.test(v.sku) && v.price === '4.25');
const gl = await goLive(pid);
out.write(JSON.stringify({ dw: it.dw_sku, mfr: it.mfr_sku, pid, handle, action: 'CREATED', status: gl.status, variants, sampleOk, varCount: gl.varCount }) + '\n');
console.log(` ✓ ${it.dw_sku} → ${pid} ${gl.status} variants=${variants.length} sampleOk=${sampleOk}`);
created++; await sleep(500);
} catch (e) { console.error(` ERR ${it.dw_sku}: ${e.message.slice(0, 140)}`); err++; await sleep(700); }
}
if (out) out.end();
console.log(`\nDONE: created=${created} err=${err} of ${todo.length}` + (APPLY ? ` · audit ${AUDIT}` : ' · DRY-RUN, no writes'));