← 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(/&amp;/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 };