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