← 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); });