← back to Crezana Scraper

scrape.mjs

163 lines

#!/usr/bin/env node
// Crezana Design feed-first scraper — GoDaddy Website Builder (DPS) editorial galleries.
// $0 plain-fetch. No SKUs/prices on the site → derive deterministic mfr_sku from image hash.
// Vendor-scoped to Crezana ONLY. product_type = 'Fabric'. QUOTE-ONLY.
import pg from 'pg';

const SITE = 'https://crezanadesign.com';
const SITE_UUID = '1f48ce84-09d7-40fc-8a82-23198f7239f7';
const PGHOST = process.env.PGHOST || '/tmp';

// Category pages discovered from sitemap.website.xml. Path -> friendly collection label.
const CATEGORIES = [
  { path: '/',              collection: 'Home' },
  { path: '/printed',       collection: 'Printed' },
  { path: '/textures',      collection: 'Textures' },
  { path: '/embroideries',  collection: 'Embroideries' },
  { path: '/embroideries-ii',collection: 'Embroideries II' },
  { path: '/madagascars',   collection: 'Madagascars' },
  { path: '/grommets%2Fstuds', collection: 'Grommets & Studs' },
  { path: '/showrooms',     collection: 'Showrooms' },
];

async function fetchText(url) {
  const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' } });
  if (!r.ok) throw new Error(`${r.status} ${url}`);
  return await r.text();
}

// GoDaddy stores captions as escaped DraftJS JSON. Decode to plain text.
function decodeCaption(escaped) {
  if (!escaped) return '';
  try {
    // The JS bundle double-escapes; unescape backslashes then parse.
    let s = escaped.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
    const j = JSON.parse(s);
    if (j && j.blocks) return j.blocks.map(b => b.text).join(' ').trim();
  } catch { /* fall through */ }
  return '';
}

// Boilerplate disclaimer text we want to ignore as a "name".
const BOILERPLATE = /inconsistencies may occur|dye lot|request sample|patterns and colors are approximate/i;

// Parse a gpub script bundle for gallery images with captions.
// Structure (escaped): "galleryImages":[{"image":{... "image":"//img1.wsimg.com/isteam/ip/UUID/HASH.jpg" ...},"caption":"{draftjs}"}]
function parseGpub(js) {
  const out = [];
  // Find each image object + its following caption.
  // Match: "image":"//img1.wsimg.com/isteam/ip/UUID/HASH.ext"  ... "caption":"..."
  const imgRe = new RegExp('\\\\?"image\\\\?":\\\\?"(\\/\\/img1\\.wsimg\\.com\\/isteam\\/ip\\/' + SITE_UUID + '\\/[a-f0-9]+\\.(?:jpg|jpeg|png|webp))\\\\?"', 'gi');
  let m;
  const seen = new Set();
  while ((m = imgRe.exec(js)) !== null) {
    let url = m[1];
    if (seen.has(url)) continue;
    seen.add(url);
    // Try to grab a caption within the next ~600 chars after this image.
    const tail = js.slice(m.index, m.index + 1200);
    const capM = tail.match(/\\?"caption\\?":\\?"(\{.*?entityMap.*?\})\\?"/);
    let caption = '';
    if (capM) caption = decodeCaption(capM[1]);
    if (BOILERPLATE.test(caption)) caption = '';
    out.push({ url: 'https:' + url, caption });
  }
  return out;
}

function hashFromUrl(url) {
  const m = url.match(/\/([a-f0-9]{16,})\.(?:jpg|jpeg|png|webp)/i);
  return m ? m[1] : null;
}

async function main() {
  const items = []; // {mfr_sku, collection, image_url, product_url, caption}
  const globalSeenHash = new Set();

  for (const cat of CATEGORIES) {
    const pageUrl = SITE + cat.path;
    let html;
    try { html = await fetchText(pageUrl); }
    catch (e) { console.error(`  ! page ${cat.path}: ${e.message}`); continue; }

    const hashes = [...html.matchAll(/gpub\/([a-f0-9]+)\/script\.js/g)].map(x => x[1]);
    const uniqHashes = [...new Set(hashes)];
    let catImages = [];
    for (const h of uniqHashes) {
      try {
        const js = await fetchText(`https://img1.wsimg.com/blobby/go/${SITE_UUID}/gpub/${h}/script.js`);
        catImages = catImages.concat(parseGpub(js));
      } catch (e) { console.error(`    ! gpub ${h}: ${e.message}`); }
    }

    let n = 0;
    for (const img of catImages) {
      const hash = hashFromUrl(img.url);
      if (!hash) continue;
      if (globalSeenHash.has(hash)) continue; // an image appears in one canonical collection only
      globalSeenHash.add(hash);
      n++;
      const mfr_sku = `CREZ-${hash.slice(0, 12).toUpperCase()}`;
      const collSlug = cat.collection.replace(/[^A-Za-z0-9]+/g, '-').replace(/-+$/,'');
      const pattern_name = img.caption && img.caption.length > 2 && img.caption.length < 120
        ? img.caption
        : `Crezana ${cat.collection} ${String(n).padStart(3, '0')}`;
      items.push({
        mfr_sku,
        pattern_name,
        collection: cat.collection,
        image_url: img.url,
        product_url: pageUrl,
      });
    }
    console.log(`  ${cat.collection}: ${n} images`);
  }

  console.log(`\nTotal unique gallery images: ${items.length}`);

  // Upsert
  const client = new pg.Client({ host: PGHOST, database: 'dw_unified' });
  await client.connect();
  let ins = 0, upd = 0;
  for (const it of items) {
    const r = await client.query(
      `INSERT INTO crezana_catalog (mfr_sku, pattern_name, collection, image_url, product_url, product_type, crawled_at, last_scraped, updated_at)
       VALUES ($1,$2,$3,$4,$5,'Fabric',now(),now(),now())
       ON CONFLICT (mfr_sku) DO UPDATE SET
         pattern_name=EXCLUDED.pattern_name,
         collection=EXCLUDED.collection,
         image_url=EXCLUDED.image_url,
         product_url=EXCLUDED.product_url,
         last_scraped=now(),
         updated_at=now()
       RETURNING (xmax=0) AS inserted`,
      [it.mfr_sku, it.pattern_name, it.collection, it.image_url, it.product_url]
    );
    if (r.rows[0].inserted) ins++; else upd++;
  }

  // Assign DW series SKUs (DWCZ- + 6-digit number) to any rows still missing one —
  // continue from the current max so the series never regresses to NULL and never
  // collides. HARD RULE (Steve): DWCZ-###### 6-digit numbers ONLY, never collection
  // letters. Idempotent: a normal re-run touches 0 rows.
  const dw = await client.query(`
    WITH mx AS (
      SELECT COALESCE(MAX((regexp_replace(dw_sku,'^DWCZ-',''))::int), 890999) AS n
      FROM crezana_catalog WHERE dw_sku ~ '^DWCZ-[0-9]{6}$'
    ),
    todo AS (
      SELECT id, (SELECT n FROM mx) + row_number() OVER (ORDER BY id) AS seq
      FROM crezana_catalog WHERE dw_sku IS NULL
    )
    UPDATE crezana_catalog c
    SET dw_sku = 'DWCZ-' || todo.seq, updated_at = now()
    FROM todo WHERE c.id = todo.id
    RETURNING c.id`);
  if (dw.rowCount) console.log(`Assigned ${dw.rowCount} new DWCZ-###### series SKU(s).`);

  await client.end();
  console.log(`Upserted: ${ins} inserted, ${upd} updated. Cost: $0 (local)`);
}

main().catch(e => { console.error(e); process.exit(1); });