← back to Fromental Internal
scripts/scrape-fromental.js
170 lines
#!/usr/bin/env node
/**
* scrape-fromental.js — feed-first ($0) Fromental catalog scraper.
*
* Fromental (https://www.fromental.co.uk) is a CONFIRMED Shopify store, so we hit
* products.json?limit=250&page=N until a page returns 0 products. No auth, no
* proxy, no captcha, no paid API. Cost: $0 (local).
*
* Captures EVERYTHING per product:
* title -> pattern_name, handle, product_type, body_html -> description,
* image srcs -> image_url + gallery_images, variants (price/sku/options),
* tags, vendor, options.
*
* Pricing: QUOTE-ONLY line. Most bespoke items are $0/blank (made-to-order);
* some Artwork / Shop / Accessory items carry real prices. We capture whatever
* price exists (max variant price), else leave NULL and mark quote_only=true.
*
* mfr_sku key resolution (idempotent upsert key):
* 1. first non-empty variant sku
* 2. else "handle:<handle>"
* 3. else "shopify:<id>"
*
* Usage: node scripts/scrape-fromental.js
*/
const { Client } = require('pg');
const { buildResolver } = require('./collection-material');
const BASE = 'https://www.fromental.co.uk';
const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
async function fetchPage(page) {
const url = `${BASE}/products.json?limit=250&page=${page}`;
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh) DW-catalog-sync' } });
if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
const j = await res.json();
return j.products || [];
}
function stripHtml(html) {
if (!html) return null;
return String(html)
.replace(/<[^>]+>/g, ' ')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/'|’|‘/g, "'")
.replace(/"|“|”/g, '"')
.replace(/é/g, 'e')
.replace(/\s{2,}/g, ' ')
.trim() || null;
}
// "Myeoh - Minhwa" -> pattern "Myeoh", color/variant "Minhwa"
function splitTitle(title) {
if (!title) return { pattern: null, color: null };
const parts = title.split(/\s+[-–—]\s+/);
if (parts.length >= 2) return { pattern: parts[0].trim(), color: parts.slice(1).join(' - ').trim() };
return { pattern: title.trim(), color: null };
}
// Fromental SKU pattern e.g. AW-YH01-02-OS-A ; collection ~ the alpha prefix family
function inferCollection(product, sku) {
// prefer a "collection" style tag if present
const tags = product.tags || [];
const t = (Array.isArray(tags) ? tags : String(tags).split(',')).map((s) => s.trim()).filter(Boolean);
const col = t.find((x) => /collection|series/i.test(x));
if (col) return col.replace(/collection[:\s-]*/i, '').trim();
return null;
}
function pickPrice(variants) {
const prices = (variants || [])
.map((v) => parseFloat(v.price))
.filter((n) => Number.isFinite(n) && n > 0);
if (!prices.length) return { price: null, quote: true };
return { price: Math.max(...prices), quote: false };
}
function resolveMfrSku(product) {
const v = (product.variants || []).find((x) => x.sku && String(x.sku).trim());
if (v) return String(v.sku).trim();
if (product.handle) return `handle:${product.handle}`;
return `shopify:${product.id}`;
}
async function main() {
const client = new Client({ connectionString: DB });
await client.connect();
let page = 1, all = [];
while (true) {
const products = await fetchPage(page);
if (!products.length) break;
all = all.concat(products);
process.stdout.write(` page ${page}: ${products.length} (total ${all.length})\n`);
page++;
if (page > 100) break; // safety
await new Promise((r) => setTimeout(r, 300)); // polite
}
console.log(`Fetched ${all.length} products across ${page - 1} pages.`);
// Build the collection + material resolver from the storefront's own
// collection membership (feed-first, $0). Populates collection + material so
// those facets never render empty and never regress on a refresh.
console.log('Building collection + material resolver from /collections feed...');
const resolver = await buildResolver();
console.log(' resolver:', JSON.stringify(resolver.stats));
let up = 0;
for (const p of all) {
const mfr_sku = resolveMfrSku(p);
const { pattern, color } = splitTitle(p.title);
const images = (p.images || []).map((im) => im.src).filter(Boolean);
const { price, quote } = pickPrice(p.variants);
const cmp = (p.variants || [])
.map((v) => parseFloat(v.compare_at_price))
.filter((n) => Number.isFinite(n) && n > 0);
const compareAt = cmp.length ? Math.max(...cmp) : null;
const tags = Array.isArray(p.tags) ? p.tags : String(p.tags || '').split(',').map((s) => s.trim()).filter(Boolean);
// COLLECTION + MATERIAL from the storefront's own collection membership
// (feed-first, $0). Falls back to product_type / body_html so neither is
// ever left empty. Prefer a tag-derived collection only if present, else
// the resolver's named design collection.
const collection = inferCollection(p, mfr_sku) || resolver.collectionFor(p.handle, p);
const material = resolver.materialFor(p.handle, p);
const productUrl = `${BASE}/products/${p.handle}`;
await client.query(
`INSERT INTO fromental_catalog
(mfr_sku, shopify_id, handle, pattern_name, color_name, collection, material,
image_url, product_url, product_type, description, tags, vendor,
gallery_images, variants, options, price, compare_at_price, quote_only,
published_at, vendor_created_at, last_scraped, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,NOW(),NOW())
ON CONFLICT (mfr_sku) DO UPDATE SET
shopify_id=EXCLUDED.shopify_id, handle=EXCLUDED.handle,
pattern_name=EXCLUDED.pattern_name, color_name=EXCLUDED.color_name,
collection=EXCLUDED.collection, material=EXCLUDED.material,
image_url=EXCLUDED.image_url,
product_url=EXCLUDED.product_url, product_type=EXCLUDED.product_type,
description=EXCLUDED.description, tags=EXCLUDED.tags, vendor=EXCLUDED.vendor,
gallery_images=EXCLUDED.gallery_images, variants=EXCLUDED.variants,
options=EXCLUDED.options, price=EXCLUDED.price,
compare_at_price=EXCLUDED.compare_at_price, quote_only=EXCLUDED.quote_only,
published_at=EXCLUDED.published_at, vendor_created_at=EXCLUDED.vendor_created_at,
last_scraped=NOW(), updated_at=NOW()`,
[
mfr_sku, p.id, p.handle, pattern, color, collection, material,
images[0] || null, productUrl, p.product_type || null,
stripHtml(p.body_html), tags, p.vendor || 'Fromental',
images, JSON.stringify(p.variants || []), JSON.stringify(p.options || []),
price, compareAt, quote,
p.published_at ? new Date(p.published_at) : null,
p.created_at ? new Date(p.created_at) : null,
]
);
up++;
}
console.log(`Upserted ${up} rows into fromental_catalog.`);
const { rows } = await client.query(
`SELECT count(*) total,
count(*) FILTER (WHERE price IS NOT NULL) priced,
count(*) FILTER (WHERE quote_only) quote
FROM fromental_catalog`
);
console.log('Table state:', rows[0]);
await client.end();
}
main().catch((e) => { console.error(e); process.exit(1); });