← back to Jf Fabrics Recrawl
extract-jffabrics.js
190 lines
// ============================================================================
// JF Fabrics — FIXED product-page extractor (all_images + full specs)
// ----------------------------------------------------------------------------
// Drop-in replacement for the image/spec logic in
// /root/DW-Agents/vendor-scrapers/scrape-jffabrics.js
//
// WHY: the deployed scraper only ever captured a single primary `image_url`
// (the 650px product-image / og:image) and one spec field (material). That left
// every net-new jf_fabrics row with an EMPTY all_images and no fire/flammability,
// width, repeat, match, or finish — failing Steve's standing
// "always pull all data and images" completeness gate.
//
// This module mirrors the proven 2026-06-10 osborne/hollywood fix:
// all_images = JSON.stringify(<distinct full-res images for THIS sku>)
// plus a full spec sweep of the "More Details" block.
//
// JF page facts (verified on https://www.jffabrics.com/wallcovering/10000-48):
// - The product swatch is served at several sizes (250/650/1024/2000). The
// full-res 2000x2000 lives in the main <img>'s data-zoom attribute.
// - The page ALSO lists 150x150 thumbnails of OTHER colorways in the same
// book (10000_61, 10000_68 ...). Those belong to OTHER SKUs and must be
// EXCLUDED — we key every image to THIS sku's filename stem so no foreign
// swatch ever lands in this row (honors the never-duplicate / no-shared-image rule).
// - Specs live in <span class="more-details-label">Label:</span> Value, in
// both <p> and <li> shapes.
// ============================================================================
const SITE_BASE = 'https://www.jffabrics.com';
function absolutize(u) {
if (!u) return null;
u = u.trim();
if (u.startsWith('//')) return 'https:' + u;
if (u.startsWith('/')) return SITE_BASE + u;
return u;
}
// Junk = chrome/theme/favicon/social/share, never a product swatch. Share links
// (pinterest/facebook/twitter) embed the real upload path in a ?media= query —
// reject the whole candidate so we never store a pinterest.com URL.
function isJunkImage(u) {
return /favicon|emblem|cropped-|\/themes\/|\/plugins\/|\/social\/|\.svg(\?|$)|logo|placeholder|spinner|sprite/i.test(u)
|| /pinterest|facebook|twitter|sharer|intent\/tweet|[?&](url|media|description)=/i.test(u)
|| /https?:\/\/[^/]+\/[^"'\s]*https?:\/\//i.test(u); // a second scheme = embedded/share url
}
// Strip the WordPress "-WIDTHxHEIGHT" resize suffix to get the canonical base,
// and return the resolution so we can keep the largest variant per image.
function imgBaseAndRes(u) {
const m = u.match(/-(\d{2,4})x(\d{2,4})(\.(?:jpg|jpeg|png|webp))(?:\?.*)?$/i);
if (m) {
const base = u.replace(/-(\d{2,4})x(\d{2,4})(\.(?:jpg|jpeg|png|webp))(?:\?.*)?$/i, m[3]);
return { base: base, res: parseInt(m[1], 10) * parseInt(m[2], 10) };
}
return { base: u.replace(/\?.*$/, ''), res: 0 };
}
// mfr_sku "10000-48" -> filename stem "10000_48" (JF uploads use underscores).
function skuStem(mfrSku) {
return String(mfrSku || '').trim().toLowerCase().replace(/-/g, '_');
}
// Pull EVERY upload image whose filename belongs to THIS sku (any resolution,
// plus any data-zoom full-res), then collapse to the largest variant per base.
function extractImages(html, mfrSku) {
const stem = skuStem(mfrSku);
if (!stem) return { primary: null, all: [] };
const candidates = [];
// 1. plain src/href/data-zoom/data-src upload urls
const urlRe = /(?:src|href|data-zoom|data-src|data-image|content)=['"]([^'"]*wp-content\/uploads\/[^'"]+\.(?:jpg|jpeg|png|webp)[^'"]*)['"]/gi;
let m;
while ((m = urlRe.exec(html)) !== null) candidates.push(m[1]);
// 2. bare urls (srcset, json) as a backstop
// (No bare-URL backstop: it false-matches share links that embed an upload
// path in their ?media= query. The attribute regex above already catches the
// real src + data-zoom full-res image.)
// Keep only THIS sku's images. The filename stem appears as e.g.
// "10000_48W8771-2000x2000.jpg" — match stem at a filename boundary so
// "10000_4" never swallows "10000_48" and siblings (_61) are excluded.
const bestByBase = new Map(); // base -> { url, res }
for (let raw of candidates) {
const u = absolutize(raw);
if (!u || isJunkImage(u)) continue;
const file = u.split('/').pop().toLowerCase();
// stem must be followed by a non-digit (W, _, -, .) so _48 != _489
const stemRe = new RegExp('(^|/|_)' + stem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=[^0-9]|$)', 'i');
if (!stemRe.test(file) && !stemRe.test(u)) continue;
const { base, res } = imgBaseAndRes(u);
const prev = bestByBase.get(base);
if (!prev || res > prev.res) bestByBase.set(base, { url: u, res: res });
}
// Sort largest-first so the highest-res becomes the primary.
const all = Array.from(bestByBase.values()).sort((a, b2) => b2.res - a.res).map(x => x.url);
return { primary: all[0] || null, all: all };
}
// Sweep the "More Details" block: <span class="more-details-label">Label:</span> Value
// in both <p>...</p> and <li>...</li> shapes (quotes vary ' vs ").
function extractSpecs(html) {
const specs = {};
const re = /more-details-label['"]\s*>\s*([^<:]+?):?\s*<\/span>\s*([^<]*?)\s*<\/(?:p|li|span)>/gi;
let m;
while ((m = re.exec(html)) !== null) {
const label = m[1].trim().toLowerCase();
const val = m[2].replace(/&/g, '&').replace(/\s+/g, ' ').trim();
if (!val) continue;
specs[label] = val;
}
return specs;
}
function pickSpec(specs, ...keys) {
for (const k of keys) {
for (const sk of Object.keys(specs)) {
if (sk.includes(k)) return specs[sk];
}
}
return null;
}
// Full product object — additive: only sets keys we actually found, so the
// ON CONFLICT (mfr_sku) DO UPDATE upsert backfills without nulling good data.
function extractFromHtml(html, url) {
const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
if (!h1Match) return null;
const patternNum = h1Match[1].trim();
const urlMatch = url.match(/\/wallcovering\/([^/?#]+)/);
const mfrSku = urlMatch ? urlMatch[1].replace(/\/$/, '').toUpperCase() : patternNum;
const h2Match = html.match(/<h2[^>]*>([^<]+)<\/h2>/);
const colorBook = h2Match ? h2Match[1].trim() : '';
let colorName = null;
if (colorBook) { const cm = colorBook.match(/^(\d+)/); if (cm) colorName = 'Color ' + cm[1]; }
let collection = null;
const bm = html.match(/<h2>Book<\/h2>\s*<ul[^>]*>\s*<li>\s*<a[^>]*>([^<]+)<\/a>/i);
if (bm) collection = bm[1].trim();
const { primary, all } = extractImages(html, mfrSku);
const specs = extractSpecs(html);
// Map specs -> jf_fabrics_catalog columns; collect the rest into features.
const material = pickSpec(specs, 'content', 'composition', 'material', 'fabric');
const fire = pickSpec(specs, 'flammab', 'flame', 'fire', 'astm');
const width = pickSpec(specs, 'width');
const repeat = pickSpec(specs, 'repeat');
const matchType = pickSpec(specs, 'match');
const finish = pickSpec(specs, 'finish', 'texture');
// product type only from an explicit "product type"/"category" label — NOT a
// bare "type" substring (which would steal the "Match Type" value).
const productType = pickSpec(specs, 'product type', 'category');
const application = pickSpec(specs, 'application', 'suitab');
const featureKeys = Object.keys(specs).filter(k =>
/washab|scrubb|strippab|peelab|origin|cleaning|backing|durab|martindale|prepasted|paste/i.test(k));
const features = featureKeys.length
? featureKeys.map(k => specs[k] && (k.replace(/\b\w/g, c => c.toUpperCase()) + ': ' + specs[k])).filter(Boolean).join(' | ')
: null;
const inStock = !html.toLowerCase().includes('discontinued');
const product = {
mfr_sku: mfrSku,
pattern_name: patternNum,
color_name: colorName,
collection: collection,
product_url: url.replace(/\/$/, ''),
in_stock: inStock,
};
if (primary) product.image_url = primary; // upgraded to full-res
if (all && all.length) product.all_images = JSON.stringify(all);
if (material) product.material = material;
if (fire) product.fire_rating = fire;
if (width) product.width = width;
if (repeat) product.repeat_v = repeat;
if (matchType) product.match_type = matchType;
if (finish) product.finish = finish;
if (productType) product.product_type = productType;
if (application) product.application = application;
if (features) product.features = features;
return product;
}
module.exports = { extractFromHtml, extractImages, extractSpecs };