← back to Dw Marketing Reels

scripts/fetch-new-arrivals.mjs

118 lines

#!/usr/bin/env node
// Pull the LIVE DW "New Arrivals" collection (maintained by the new-arrivals-rotator
// skill) and normalize it into data/new-arrivals.json for the reel builder + gallery.
// $0 — public storefront products.json, no auth, no paid API.
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { LEAK_DENY } from './leak-deny.mjs';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const STORE = process.env.DW_STORE || 'https://designerwallcoverings.com';
const COLLECTION = process.env.DW_COLLECTION || 'new-arrivals';
const LIMIT = Number(process.env.DW_LIMIT || 24);
const UA = 'Mozilla/5.0 (dw-marketing-reels)';

// Optional narrowing — all backwards-compatible (unset = generic collection dump).
// DW_VENDOR:         keep only products whose vendor matches (case-insensitive exact).
// DW_TITLE_MATCH:    keep only products whose title matches this regex (e.g. "cork").
// DW_DEDUPE_PATTERN: "1" = one product per distinct pattern (drops repeated colorways).
const VENDOR = (process.env.DW_VENDOR || '').trim();
const TITLE_MATCH = process.env.DW_TITLE_MATCH ? new RegExp(process.env.DW_TITLE_MATCH, 'i') : null;
const DEDUPE = process.env.DW_DEDUPE_PATTERN === '1';
// DW_SPREAD: sample N items evenly across the filtered list instead of taking the head.
// Alphabetical collections cluster colorways of one pattern together; spreading keeps a
// mixed-collection reel visually varied. Deterministic (stride pick, no randomness).
const SPREAD = Number(process.env.DW_SPREAD || 0);
// Swatch/sample books are never a reel frame.
const NON_PRODUCT = /collection book|swatch book|sample book|memo sample/i;
// Private-label / upstream leak filter (LEAK_DENY) imported from ./leak-deny.mjs — single
// source synced to canonical dw-leak-scanner. Slide cards render vendor names verbatim,
// so denied vendors must never enter the feed.

function cleanTitle(t = '') {
  // "Katamari, Pewter Wallcoverings | Thibaut" -> {pattern:"Katamari", color:"Pewter", vendor:"Thibaut"}
  const [main, vendor = ''] = t.split('|').map(s => s.trim());
  const noSuffix = main
    .replace(/\s*Repeat:.*$/i, '')  // spec junk some titles carry ("… Repeat: 34\" (86.4cm)V x …")
    .replace(/\s*(Wallcoverings?|Wallpaper|Fabric)s?\s*$/i, '').trim();
  const [pattern, ...rest] = noSuffix.split(',');
  return {
    pattern: (pattern || noSuffix).trim(),
    color: rest.join(',').trim(),
    vendor: vendor.trim(),
  };
}

// DW_PAGES: fetch up to N pages of the collection (Shopify caps limit at 250/page).
// Alphabetically-sorted collections need >1 page to reach past the early letters.
const PAGES = Number(process.env.DW_PAGES || 1);

async function main() {
  const products = [];
  for (let page = 1; page <= PAGES; page++) {
    const url = `${STORE}/collections/${COLLECTION}/products.json?limit=${LIMIT}&page=${page}`;
    const res = await fetch(url, { headers: { 'User-Agent': UA } });
    if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`);
    const batch = (await res.json()).products || [];
    products.push(...batch);
    if (batch.length < LIMIT) break; // last page
  }

  let items = products
    .map(p => {
      const img = (p.images || [])[0]?.src || '';
      const variant = (p.variants || [])[0] || {};
      const t = cleanTitle(p.title);
      return {
        id: p.id,
        handle: p.handle,
        title: p.title,
        pattern: t.pattern,
        color: t.color,
        vendor: p.vendor || t.vendor,
        product_type: p.product_type || '',
        image: img,
        // large render size for crisp video frames
        image_hi: img ? img.replace(/(\.[a-z]+)(\?.*)?$/i, '_1200x$1$2') : '',
        price: variant.price || null,
        url: `${STORE}/products/${p.handle}`,
        created_at: p.created_at,
        published_at: p.published_at,
      };
    })
    .filter(x => x.image)                                  // a reel frame needs an image
    .filter(x => !NON_PRODUCT.test(x.title))               // no swatch/sample books
    .filter(x => !LEAK_DENY.test(x.vendor) && !LEAK_DENY.test(x.title)); // no private-label upstream leaks

  if (VENDOR) items = items.filter(x => (x.vendor || '').toLowerCase() === VENDOR.toLowerCase());
  if (TITLE_MATCH) items = items.filter(x => TITLE_MATCH.test(x.title));
  if (DEDUPE) {
    const seen = new Set();
    items = items.filter(x => {
      const k = x.pattern.toLowerCase();
      if (seen.has(k)) return false;
      seen.add(k);
      return true;
    });
  }

  if (SPREAD > 0 && items.length > SPREAD) {
    const stride = items.length / SPREAD;
    items = Array.from({ length: SPREAD }, (_, i) => items[Math.floor(i * stride)]);
  }

  mkdirSync(join(ROOT, 'data'), { recursive: true });
  const out = {
    source: `${STORE}/collections/${COLLECTION}`,
    fetched_at: new Date().toISOString(),
    count: items.length,
    items,
  };
  writeFileSync(join(ROOT, 'data', 'new-arrivals.json'), JSON.stringify(out, null, 2));
  console.log(`✓ ${items.length} new arrivals -> data/new-arrivals.json`);
  items.slice(0, 5).forEach(i => console.log(`  • ${i.pattern} / ${i.color} (${i.vendor})`));
}

main().catch(e => { console.error('✗', e.message); process.exit(1); });