← back to Dw Yolo Loop
artmura-site/build-line.js
160 lines
#!/usr/bin/env node
/**
* DW vendor-landing generator — build a branded editorial landing for ANY live DW vendor
* straight from the Shopify store. The "fascinating for other lines" factory.
*
* SHOPIFY_ADMIN_TOKEN=… node build-line.js "Schumacher"
*
* Produces:
* lines/<slug>.json catalog snapshot (artmura.json shape, CDN image urls)
* lines/<slug>-handles.json {sku: live shopify handle}
* prints a site.config.js block to paste in.
* Then: VENDOR=<slug> PORT=99xx node server.js (set dataFile/colorsFile/imagePrefix per the printed block)
*/
const fs = require('fs');
const path = require('path');
const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const API = '2024-10';
const VENDOR = process.argv[2];
if (!TOKEN || !VENDOR) { console.error('usage: SHOPIFY_ADMIN_TOKEN=… node build-line.js "<Vendor>"'); process.exit(1); }
// TK-11200 — showroom-only guard. A showroom line (Phillip Jeffries) must stay
// "addressable but not discoverable"; a vendor microsite is a discovery surface, so the
// factory refuses to snapshot one at all. Override: ALLOW_SHOWROOM=1 (logs loudly).
const showroom = require('./showroom.js');
if (!showroom.assertNotShowroom(VENDOR, 'building a vendor-landing snapshot')) process.exit(2);
const slug = VENDOR.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const OUT = path.join(__dirname, 'lines');
fs.mkdirSync(OUT, { recursive: true });
const sleep = ms => new Promise(r => setTimeout(r, ms));
// rate-limited fetch with 429/5xx retry — survives large lines (Thibaut = 1900+ products)
async function api(p, tries = 5) {
for (let i = 0; i < tries; i++) {
const res = await fetch(`https://${STORE}/admin/api/${API}${p}`, { headers: { 'X-Shopify-Access-Token': TOKEN } });
if (res.status === 429 || res.status >= 500) {
const wait = Number(res.headers.get('Retry-After') || 2) * 1000 || 2000;
await sleep(wait * (i + 1));
continue;
}
await sleep(110); // ~8/s, under the 2-bucket leak rate
return res;
}
throw new Error(`api ${p} failed after ${tries} tries`);
}
async function getAll() {
let url = `/products.json?vendor=${encodeURIComponent(VENDOR)}&status=active&limit=250`;
const out = [];
while (url) {
const res = await api(url);
const link = res.headers.get('Link') || '';
const d = await res.json();
out.push(...(d.products || []));
const m = link.split(',').find(s => s.includes('rel="next"'));
url = m ? m.slice(m.indexOf('<') + 1, m.indexOf('>')).replace(/^https:\/\/[^/]+\/admin\/api\/[^/]+/, '') : null;
}
return out;
}
async function metafields(pid) {
try { const d = await (await api(`/products/${pid}/metafields.json`)).json();
return Object.fromEntries((d.metafields || []).filter(m => m.namespace === 'custom').map(m => [m.key, m.value])); }
catch { return {}; }
}
(async () => {
console.log(`building line "${VENDOR}" from ${STORE} ...`);
const prods = await getAll();
console.log(` ${prods.length} active products`);
const products = [], handles = {};
let i = 0, skippedShowroom = 0;
for (const p of prods) {
if (++i % 100 === 0) console.log(` …${i}/${prods.length} products processed`);
const mf = await metafields(p.id);
const isSample = v => (v.option1 || '').toLowerCase() === 'sample' || /-sample$/i.test(v.sku || '');
const sample = p.variants.find(isSample);
// main = highest-priced non-sample variant (never read the $4.25 sample as the price — see $4.25 policy)
const nonSample = p.variants.filter(v => !isSample(v));
const yard = nonSample.sort((a, b) => parseFloat(b.price) - parseFloat(a.price))[0] || p.variants[0];
const sku = (yard.sku || '').trim().replace(/-sample$/i, ''); // sample-only products: drop the -SAMPLE suffix
const tags = (p.tags || '').split(',').map(s => s.trim()).filter(Boolean);
// strip the trailing " | Vendor" and any redundant trailing "Wallcovering(s)" (incl. doubled)
const baseTitle = p.title.replace(/\s*\|\s*[^|]+$/, '').replace(/(\s+Wallcoverings?){1,2}\s*$/i, '').trim();
// skip placeholder/junk products: title is just the vendor name (e.g. "Schumacher | Schumacher
// Wallcovering") or no image — these have no real pattern and pollute the lookbook.
const cleanBase = baseTitle.replace(/\s+/g, ' ').trim().toLowerCase();
const isJunk = !cleanBase || cleanBase === VENDOR.toLowerCase()
|| cleanBase === `${VENDOR} ${VENDOR}`.toLowerCase()
|| !(p.images && p.images.length);
if (isJunk) continue;
// defense in depth: drop individually showroom-TAGGED products even on a sellable
// vendor (shared-label lines like MDC under "Phillipe Romano").
if (showroom.isShowroomProduct({ vendor: p.vendor, tags })) { skippedShowroom++; continue; }
if (sku) handles[sku.toUpperCase()] = p.handle;
products.push({
mfr_sku: mf.mfr_sku || sku,
pattern_series: mf.design_name || tags[0] || baseTitle.split(' ')[0],
color: mf.colorway_name || baseTitle.split(' ').slice(1).join(' ') || null,
title: baseTitle,
collection_book: mf.collection || null,
tags,
product_type: p.product_type || 'Wallcoverings',
price_newwall_retail: parseFloat(yard.price) || 0,
sample_price: sample ? parseFloat(sample.price) : null,
sold_by: yard.option1 || 'Yard',
substrate: mf.material || null,
grade: mf.grade || null,
dimensions: mf.width || null,
wall_coverage: mf.wall_coverage || null,
lead_time: mf.lead_time || null,
origin: mf.origin || null,
body_html: p.body_html,
product_url: `https://www.designerwallcoverings.com/products/${p.handle}`,
handle: p.handle,
shopify_source_id: p.id,
images: (p.images || []).map(im => im.src.split('?')[0]),
image_count: (p.images || []).length,
published_at: p.published_at,
});
}
// never write an EMPTY snapshot over a good one — a bad/expired token or a vendor-name
// typo returns 0 products, and rsync'ing that to prod blanks a live microsite's grid.
if (!products.length) {
console.error(`\n[build-line] REFUSED: 0 products captured for "${VENDOR}" — not writing lines/${slug}.json.\n Check the token and that the vendor name matches Shopify exactly. Override: ALLOW_EMPTY=1\n`);
if (process.env.ALLOW_EMPTY !== '1') process.exit(3);
}
fs.writeFileSync(path.join(OUT, `${slug}.json`), JSON.stringify({ vendor: VENDOR, captured_count: products.length, products }, null, 2));
fs.writeFileSync(path.join(OUT, `${slug}-handles.json`), JSON.stringify(handles, null, 1));
if (skippedShowroom) console.log(` [showroom] excluded ${skippedShowroom} showroom-only product(s) from the snapshot`);
console.log(` → lines/${slug}.json (${products.length}) lines/${slug}-handles.json`);
console.log(`\nPaste into site.config.js:\n`);
console.log(` ${slug}: {
// HOUSE brand — always Designer Wallcoverings (the property identity)
house: 'Designer Wallcoverings',
houseUrl: 'https://www.designerwallcoverings.com',
houseTagline: 'Designer Wallcoverings & Fabrics · To the Trade',
nav: [
{ label: 'All Wallcoverings', href: 'https://www.designerwallcoverings.com/collections/all' },
{ label: 'The Collection', href: '#catalog' },
{ label: 'Trade', href: 'https://www.designerwallcoverings.com/pages/trade' },
{ label: 'Shop the Collection ↗', href: 'https://www.designerwallcoverings.com/collections/${slug}' },
],
// NOTE: create a Shopify smart collection (vendor=${VENDOR}, handle=${slug}) so /collections/${slug} resolves
collectionUrl: 'https://www.designerwallcoverings.com/collections/${slug}',
// LINE being featured (a collection DW carries — NOT the property brand)
vendor: '${VENDOR}', line: '${VENDOR}',
title: '${VENDOR} Wallcoverings | Designer Wallcoverings',
wordmark: 'Designer Wallcoverings',
eyebrow: 'A Designer Wallcoverings Collection',
kicker: 'Designer Wallcoverings', tagline: 'The ${VENDOR} collection — curated by Designer Wallcoverings.',
booksHeading: '${VENDOR}', metaDescription: 'The ${VENDOR} collection at Designer Wallcoverings.',
dataFile: 'artmura-site/lines/${slug}.json',
colorsFile: 'artmura-site/lines/${slug}-colors.json', // optional: run extract_colors variant
handlesFile: 'artmura-site/lines/${slug}-handles.json',
imagePrefix: '', localImages: false, // uses Shopify CDN urls directly
storeBase: 'https://www.designerwallcoverings.com',
palette: { bg: '#f6f2ec', ink: '#211d18', accent: '#8c7a5f', gold: '#a98c54' },
},`);
console.log(`\nThen: VENDOR=${slug} PORT=99xx node server.js`);
})().catch(e => { console.error(e); process.exit(1); });