← back to Artmura Internal
scripts/build-products-json.js
171 lines
#!/usr/bin/env node
/**
* Internal Artmura viewer data — build data/products.json from the LIVE DW
* Shopify storefront feed (designerwallcoverings.com/products.json), filtered
* to vendor Artmura. The local shopify_products mirror is sparse for Artmura
* (no SKUs/prices), so the live feed is the authoritative source — same source
* the public artmura microsite (dw-vendor-landing template) builds from.
* INTERNAL ONLY. Retail = live variant price (samples excluded). $0 (public feed).
*/
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const OUT = path.join(__dirname, '..', 'data', 'products.json');
const FEED = 'https://www.designerwallcoverings.com/products.json';
const VENDOR = 'Artmura';
const PW = (() => {
try {
const env = fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8');
const m = env.match(/^DW_ADMIN_DB_PASSWORD=(.*)$/m);
return m ? m[1].replace(/^["']|["']$/g, '').trim() : '';
} catch { return ''; }
})();
const BUCKET = {
white: 'white', ivory: 'white', cream: 'white', beige: 'white',
grey: 'grey', gray: 'grey', silver: 'grey', neutral: 'grey', taupe: 'grey',
black: 'black', charcoal: 'black',
red: 'red', burgundy: 'red',
orange: 'orange', rust: 'orange', copper: 'orange', terracotta: 'orange',
brown: 'brown', tan: 'brown', bronze: 'brown', chocolate: 'brown',
gold: 'gold', yellow: 'gold', mustard: 'gold',
green: 'green', olive: 'green', sage: 'green', emerald: 'green',
teal: 'teal', turquoise: 'teal', aqua: 'teal',
blue: 'blue', navy: 'blue', indigo: 'blue',
purple: 'purple', violet: 'purple', lavender: 'purple', plum: 'purple',
pink: 'pink', rose: 'pink', blush: 'pink', magenta: 'pink',
};
const BUCKET_ORDER = ['white','grey','black','pink','red','orange','brown','gold','green','teal','blue','purple'];
const BUCKET_HUE = { red:0, orange:30, gold:50, green:120, teal:175, blue:215, purple:280, pink:330, brown:25, white:null, grey:null, black:null };
const clean = s => (s == null ? s : String(s)
.replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering'));
function bucketFor(tags) {
for (const t of (tags || [])) {
const key = String(t || '').toLowerCase().trim();
if (BUCKET[key]) return BUCKET[key];
}
return null;
}
async function fetchFeed() {
const all = [];
for (let page = 1; page <= 40; page++) {
const r = await fetch(`${FEED}?limit=250&page=${page}`, { headers: { 'User-Agent': 'DW-internal-viewer' } });
if (!r.ok) throw new Error(`feed page ${page}: HTTP ${r.status}`);
const j = await r.json();
if (!j.products || j.products.length === 0) break;
all.push(...j.products);
}
return all.filter(p => (p.vendor || '').trim() === VENDOR);
}
// Vendor ops meta — Artmura is CARRIED BY NEWWALL (Steve 2026-07-02): purchasing
// goes through the NewWall distributor account, so contact comes from fmpro's
// New Wall rows. buy_from makes the request emails greet "NewWall team" while
// the page still shows Artmura as the line.
// DISCOUNT: NewWall's 20% is LINE-SPECIFIC (Steve 2026-07-02 "only certain
// lines we get 20% off" — fmpro confirms only Coordonné + Escher by JV at 20%).
// Artmura's discount is UNCONFIRMED → left null; Get Price asks the vendor to
// confirm net cost + applicable discount. Set ARTMURA_DISCOUNT_PCT when confirmed.
const BUY_FROM = 'NewWall';
const ARTMURA_DISCOUNT_PCT = null; // unconfirmed — do NOT inherit newwall's blanket 20%
async function fetchVendorMeta() {
const out = { name: VENDOR, buy_from: BUY_FROM, phone: null, email: null, account_number: null,
discount_pct: null, pricing_unit: null, pricing_model: null, pricing_notes: null, sample_price: null };
if (!PW) return out;
const pool = new Pool({ host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PW, max: 1 });
try {
const vr = (await pool.query(`SELECT pricing_unit, pricing_model, pricing_notes, sample_price FROM vendor_registry WHERE vendor_code = 'newwall' LIMIT 1`)).rows[0] || {};
// fmpro New Wall rows are messy: the main row has the phone, a sub-line row has the real email
const fmEmail = (await pool.query(`SELECT email_1 FROM fmpro WHERE vendor_name ILIKE '%new%wall%' AND email_1 LIKE '%@%' LIMIT 1`)).rows[0] || {};
const fmPhone = (await pool.query(`SELECT phone, account_num FROM fmpro WHERE vendor_name ILIKE 'new wall' AND phone IS NOT NULL LIMIT 1`)).rows[0] || {};
const acct = (fmPhone.account_num || '').trim();
const acctOk = /^[A-Za-z0-9][A-Za-z0-9\-\/. ]{1,19}$/.test(acct) && !/discount|resale|on file|%/i.test(acct);
Object.assign(out, {
phone: fmPhone.phone || null,
email: fmEmail.email_1 || null,
account_number: acctOk ? acct : null,
discount_pct: ARTMURA_DISCOUNT_PCT,
pricing_unit: vr.pricing_unit || null,
pricing_model: vr.pricing_model || null,
pricing_notes: 'NewWall discount is line-specific; Artmura % unconfirmed — use Get Price',
sample_price: vr.sample_price != null ? Number(vr.sample_price) : null,
});
} catch (e) { console.error('vendor meta lookup failed (using defaults):', e.message); }
await pool.end().catch(() => {});
return out;
}
async function main() {
const [feed, vendorMeta] = await Promise.all([fetchFeed(), fetchVendorMeta()]);
const products = [];
let dropped = 0;
for (const p of feed) {
const imgs = (p.images || []).map(i => i.src).filter(Boolean);
if (imgs.length === 0) { dropped++; continue; }
// retail = the sellable (non-sample) variant's price; samples are $4.25 noise
const sellable = (p.variants || []).filter(v => !/sample/i.test(v.title || '')).sort((a, b) => Number(b.price) - Number(a.price));
const v0 = sellable[0] || (p.variants || [])[0] || {};
const price = v0.price != null && Number(v0.price) > 0 ? Number(v0.price) : null;
const sku = v0.sku || null;
const dwSku = (sku && /^DW[A-Z]{2}-\d+/.test(sku)) ? sku : null;
const title = clean((p.title || '').replace(/\s*\|\s*Artmura\s*$/i, ''));
const bucket = bucketFor(p.tags);
products.push({
handle: p.handle,
dw_sku: dwSku,
sku: dwSku ? null : sku, // vendor/model # when it isn't our DW sku
title: `${title} | ${VENDOR}`,
display_eyebrow: VENDOR,
display_name: title,
series: title.replace(/\s+(Wallcovering|Mural).*$/i, '') || title,
color: null,
book: p.product_type || VENDOR,
color_bucket: bucket,
hue: bucket ? BUCKET_HUE[bucket] : null,
hex: null, sat: null,
val: bucket === 'white' ? 1 : bucket === 'black' ? 0 : 0.5,
width: null, length: null, repeat: null, material: null, match: null,
price, cost: null, price_code: null,
body_html: clean((p.body_html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()),
swatch: imgs[0] || null,
room: imgs[1] || imgs[0] || null,
images: imgs.slice(0, 12),
published_at: p.published_at || p.created_at,
inquiry_sku: dwSku || sku || p.handle,
});
}
const facets = { books: {}, series: {}, colors: {} };
for (const p of products) {
if (p.book) facets.books[p.book] = (facets.books[p.book] || 0) + 1;
if (p.series) facets.series[p.series] = (facets.series[p.series] || 0) + 1;
if (p.color_bucket) facets.colors[p.color_bucket] = (facets.colors[p.color_bucket] || 0) + 1;
}
const orderedColors = BUCKET_ORDER.filter(b => facets.colors[b]).map(b => [b, facets.colors[b]]);
const snapshot = {
built_at: new Date().toISOString(),
source: 'designerwallcoverings.com live Shopify feed, vendor=Artmura (INTERNAL viewer)',
count: products.length,
dropped_no_image: dropped,
vendor: vendorMeta,
facets: {
total: products.length,
books: Object.entries(facets.books).sort((a, b) => b[1] - a[1]),
series: Object.entries(facets.series).sort((a, b) => b[1] - a[1]).slice(0, 300),
colors: orderedColors,
},
products,
};
fs.writeFileSync(OUT, JSON.stringify(snapshot));
console.log(`products.json -> ${OUT}`);
console.log(` products: ${products.length} | dropped (no image): ${dropped} | with price: ${products.filter(x => x.price).length}`);
}
main().catch(e => { console.error(e); process.exit(1); });