← back to Fromental Internal

scripts/collection-material.js

194 lines

/* collection-material.js — feed-first ($0) resolver that maps each Fromental
 * product handle to its design COLLECTION ("book"/pattern/designer) and its
 * MATERIAL, using the storefront's own collection membership + body_html.
 *
 * Strategy (all $0 plain-fetch, no LLM by default):
 *   1. GET /collections.json (paginated) → classify each collection as a
 *      MATERIAL bucket, a named DESIGN collection, or an ignorable
 *      color/theme/aggregate bucket.
 *   2. GET /collections/<handle>/products.json for the MATERIAL + DESIGN
 *      collections → build handle→collections membership.
 *   3. Per product: COLLECTION = most-specific named design collection it
 *      belongs to, else a product_type-derived default; MATERIAL = best
 *      material-collection membership (ranked), else body_html keywords, else
 *      a handle/product_type default.
 *
 * Exported: buildResolver() → { collectionFor(handle, product), materialFor(handle, product) }
 * Used by scrape-fromental.js so collection+material are populated at scrape
 * time and never regress on a refresh.
 */
const BASE = 'https://www.fromental.co.uk';
const UA = { 'User-Agent': 'Mozilla/5.0 (Macintosh) DW-catalog-sync' };

// Material-technique collection handle → normalized material label.
const MATERIAL = {
  'silk-wallcoverings': 'Silk', 'silk-artworks': 'Silk',
  'embroidered-wallcoverings': 'Embroidered Silk', 'embroidered-accessories': 'Embroidered',
  'hand-embroidered-wallpapers': 'Embroidered Silk', 'hand-embroidered-accessories': 'Embroidered', 'hand-embroidered-artworks': 'Embroidered',
  'hand-painted-wallcoverings': 'Hand-Painted Silk', 'hand-painted-accessories': 'Hand-Painted', 'hand-painted-artworks': 'Hand-Painted',
  'paper-wallcoverings': 'Paper', 'paper-wallpapers': 'Paper', 'paper-maquettes-wallcoverings': 'Paper',
  'metal-paper-wallcoverings': 'Metallic Paper',
  'metallic-wallcoverings': 'Metallic', 'metallic-accessories': 'Metallic', 'metallic-artworks': 'Metallic', 'metallic-wallpapers': 'Metallic',
  'linen-wallcoverings': 'Linen',
  'velvet-wallcoverings': 'Velvet', 'velvet-accessories': 'Velvet',
  'printed-wallcoverings': 'Printed', 'printed-wallpapers': 'Printed',
  'vinyl-accessories': 'Vinyl', 'rexine-wallcoverings': 'Rexine',
  'slub-wallcoverings': 'Slub Silk', 'gilded-wallpapers': 'Gilded',
  'pearlescent-wallcoverings': 'Pearlescent', 'pearlescent-wallpapers': 'Pearlescent', 'pearlescent-artworks': 'Pearlescent',
  'hand-finished-wallcoverings': 'Hand-Finished',
  'textured-wallcoverings': 'Textured', 'textured-accessories': 'Textured', 'textured-artworks': 'Textured',
  'canvas-artworks': 'Canvas', 'needlepoint-accessories': 'Needlepoint', 'needlepoint-damask-accessories': 'Needlepoint',
  'woodgrain-accessories': 'Woodgrain',
};
// Material specificity — higher wins when a product is in several material buckets.
const MAT_RANK = {
  'Embroidered Silk': 10, 'Hand-Painted Silk': 10, 'Slub Silk': 9, 'Silk': 8,
  'Embroidered': 7, 'Hand-Painted': 7, 'Metallic Paper': 7, 'Pearlescent': 6,
  'Metallic': 6, 'Gilded': 6, 'Velvet': 6, 'Linen': 6, 'Paper': 5, 'Printed': 5,
  'Rexine': 5, 'Vinyl': 5, 'Canvas': 5, 'Needlepoint': 5, 'Woodgrain': 5,
  'Textured': 3, 'Hand-Finished': 2,
};
const MATSET = new Set(Object.keys(MATERIAL));

// Collections that are NOT a design "book" (aggregates / test / status / product-format).
const NOT_DESIGN_RE = /^(all-|best-selling-|newest-|new-arrivals|shop$|quick-order$|frontpage$|everything-test|bespoke-test|accessories-test|artworks-test|bespoke-wallcovering-samples|bespoke-collaborations|wallcovering-collaborations|sample-bundles|non-current|roomskins-|cushions$|luxury-cushions|mid-range-cushion|throws$|wallhangings$)/;
// Color / theme / material-word prefixed collections (not a named design book).
const THEME_RE = /^(abstract|animal|botanical|floral|foliage|chinoiserie|conversational|blue|green|grey|pink|red|yellow|taupe|neutral|orange|purple|brown|black|white|cream|beige|bright|dark|light|colourful|monochromatic|pastel|maximalist|minimalist|dramatic|playful|romantic|heritage|modern-chinois|avant-garde|deco-arches|indienne|papier-chinois|damask|panoramic|plain|safari|nursery|gilded|silk|embroidered|hand-embroidered|hand-painted|paper|metal-paper|linen|velvet|printed|vinyl|rexine|slub|pearlescent|hand-finished|textured|canvas|needlepoint|woodgrain|greek-key|scales|crocodile|fan|construct|calacatta|belgrave|bloomsbury|hampstead|soho|metropolis|moderne|ratio|raw|travertine|rockface|gypsum|molten|lava|nimbus|stardust|kinetic|phantasm|coquilles|flock|paille|equus|faisans|hirondelles)-/;

function isDesign(handle) {
  if (MATSET.has(handle)) return false;
  if (NOT_DESIGN_RE.test(handle)) return false;
  if (THEME_RE.test(handle)) return false;
  return /-(wallcoverings?|wallpapers?|artworks?|collaboration|collection)$/.test(handle);
}
function designName(title) {
  return title
    .replace(/\s+(Wallcoverings?|Wallpapers?|Artworks?|Accessories|Collaboration|Collection)$/i, '')
    .replace(/™/g, '').trim();
}

async function fetchJson(url) {
  for (let a = 0; a < 4; a++) {
    try {
      const r = await fetch(url, { headers: UA });
      if (r.ok) return r.json();
    } catch (_) { /* retry */ }
    await new Promise((x) => setTimeout(x, 400));
  }
  return null;
}

async function fetchCollectionHandles(handle) {
  const out = [];
  let page = 1;
  while (true) {
    const j = await fetchJson(`${BASE}/collections/${handle}/products.json?limit=250&page=${page}`);
    const pp = (j && j.products) || [];
    if (!pp.length) break;
    for (const p of pp) out.push(p.handle);
    if (pp.length < 250) break;
    page += 1;
    if (page > 5) break;
    await new Promise((x) => setTimeout(x, 150));
  }
  return out;
}

async function listCollections() {
  const all = [];
  for (let page = 1; page <= 3; page++) {
    const j = await fetchJson(`${BASE}/collections.json?limit=250&page=${page}`);
    const cc = (j && j.collections) || [];
    if (!cc.length) break;
    all.push(...cc);
    if (cc.length < 250) break;
  }
  const seen = new Set(); const uniq = [];
  for (const c of all) { if (!seen.has(c.handle)) { seen.add(c.handle); uniq.push(c); } }
  return uniq;
}

// body_html / description → material keyword fallback.
function materialFromText(text) {
  if (!text) return null;
  const s = String(text).toLowerCase();
  if (/paper-backed silk|silk[^.]*paper-backed/.test(s)) return 'Paper-Backed Silk';
  if (/slub viscose|viscose thread/.test(s)) return 'Slub Viscose';
  if (/cartonnage|buckskin paper|tea paper|metal paper|parchment|leather book|wood veneer|hand-glazed[^.]*paper/.test(s)) return 'Specialty Paper';
  if (/grasscloth|grass cloth|jute|raffia|sisal/.test(s)) return 'Grasscloth';
  if (/\blinen\b/.test(s)) return 'Linen';
  if (/embroider/.test(s)) return 'Embroidered Silk';
  if (/hand-?painted[^.]*silk|silk[^.]*hand-?painted/.test(s)) return 'Hand-Painted Silk';
  if (/\bsilk\b/.test(s)) return 'Silk';
  if (/metallic|gilded|gold leaf|silver leaf/.test(s)) return 'Metallic';
  if (/velvet/.test(s)) return 'Velvet';
  if (/\bpaper\b/.test(s)) return 'Paper';
  if (/hand-?painted/.test(s)) return 'Hand-Painted';
  return null;
}

function collectionFromType(handle, productType) {
  if (handle === 'sample-bundle' || handle === 'bespoke-sample-bundle') return 'Samples';
  const pt = productType || '';
  if (/Plain/i.test(pt)) return 'Plain Wallcoverings';
  if (/Artwork/i.test(pt)) return 'Artworks';
  if (/Accessory/i.test(pt)) return 'Accessories';
  if (/Shop/i.test(pt)) return 'Shop Wallcoverings';
  return 'Bespoke Wallcoverings';
}

function materialFromHandleType(handle, productType) {
  const pt = productType || '';
  if (handle === 'sample-bundle' || handle === 'bespoke-sample-bundle') return 'N/A';
  if (/^grasscloth-|^woven-|^yarns-/.test(handle)) return 'Grasscloth';
  if (/Artwork/i.test(pt)) return 'Mixed Media';
  if (/cushion/.test(handle)) return 'Velvet';
  if (/wall-hanging/.test(handle)) return 'Embroidered';
  if (/embroider/.test(handle)) return 'Embroidered Silk';
  if (/Plain/i.test(pt)) return 'Silk';
  return 'Hand-Painted Silk'; // bespoke scenic default
}

/**
 * Build the resolver by crawling the collections feed once. Returns two
 * pure functions the scraper calls per product. Feed-first, $0.
 */
async function buildResolver() {
  const cols = await listCollections();
  const design = {}; // handle -> { name: products_count }
  const material = {}; // handle -> { label: 1 }

  for (const c of cols) {
    const mat = MATERIAL[c.handle];
    const isDes = isDesign(c.handle);
    if (!mat && !isDes) continue; // skip color/theme/aggregate buckets
    const handles = await fetchCollectionHandles(c.handle);
    const dn = isDes ? designName(c.title) : null;
    for (const h of handles) {
      if (mat) { (material[h] || (material[h] = {}))[mat] = 1; }
      if (dn) { (design[h] || (design[h] = {}))[dn] = c.products_count; }
    }
    await new Promise((x) => setTimeout(x, 80));
  }

  function collectionFor(handle, product) {
    const o = design[handle];
    if (o) return Object.keys(o).sort((a, b) => o[a] - o[b])[0]; // smallest = most specific
    return collectionFromType(handle, product && product.product_type);
  }
  function materialFor(handle, product) {
    const o = material[handle];
    if (o) return Object.keys(o).sort((a, b) => (MAT_RANK[b] || 0) - (MAT_RANK[a] || 0))[0];
    const body = product && (product.body_html || product.description);
    return materialFromText(body) || materialFromHandleType(handle, product && product.product_type);
  }

  return {
    collectionFor,
    materialFor,
    stats: { collections: cols.length, designHandles: Object.keys(design).length, materialHandles: Object.keys(material).length },
  };
}

module.exports = { buildResolver, materialFromText, collectionFromType, materialFromHandleType, MATERIAL, MAT_RANK };