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