← back to Atomic50 Onboard

scraper/scrape-atomic50.mjs

160 lines

#!/usr/bin/env node
/**
 * scrape-atomic50.mjs — feed-first Atomic 50 Ceilings refresh.
 *
 * Atomic 50 is a Squarespace site. Despite the skill note ("requires headless
 * browser"), it exposes the feed-first crack: sitemap.xml lists every INDIVIDUAL
 * product URL, and each returns full structured JSON at `<url>?format=json`.
 * $0 plain-fetch — NO Puppeteer, NO captcha, NO login.
 *
 * CATALOG SHAPE (important): atomic50_catalog AT50-* rows are TWO kinds:
 *   1. individual product pages (/at50-24, /at50-c1, /at50-m11, /at50g34) — crawlable.
 *   2. accessory/molding line-items (SP1..SP9, NAIL, SNIP, CLIP, TCRO, APAD, ...) that
 *      ALL share ONE page (/molding-accessories) — NOT individually crawlable.
 * So matching + disco are scoped to INDIVIDUAL pages, matched BY product_url (slug->mfr_sku
 * is unreliable: /at50g34 vs catalog AT50-G34). Accessory-page rows are never disco-touched.
 *
 * Behaviour (idempotent, NON-DESTRUCTIVE):
 *   - UPSERT by product_url: existing individual page -> refresh all_images/image_url/
 *     last_scraped, discontinued=false; PRESERVE shopify_product_id, on_shopify,
 *     product_type, dw_sku, ai_*. New individual page -> INSERT with next DWJT-6000xx.
 *   - DISCO: an individual-page catalog row NOT seen in the sitemap is re-checked with a
 *     live GET; only a real 404/410 sets discontinued=true. Never deletes. Guarded by a
 *     70%-of-individual-count floor (transient-hiccup guard). Set env DISCO=0 to skip.
 *
 * Cost: $0 (local PG socket + plain HTTPS). Never writes Shopify.
 */
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { Client } = require('pg');

const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36';
const SITEMAP = 'https://www.atomic50ceilings.com/sitemap.xml';
const IMG_CAP = 40;
const DISCO_ON = process.env.DISCO !== '0';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const normUrl = (u) => String(u || '').trim().replace(/\/+$/, '').replace(/\?.*$/, '').toLowerCase();

async function fetchText(url, tries = 4) {
  for (let a = 0; a < tries; a++) {
    try { const r = await fetch(url, { headers: { 'User-Agent': UA } }); if (r.ok) return await r.text(); }
    catch (_) { /* retry */ }
    await sleep(800 * (a + 1));
  }
  throw new Error('fetch failed: ' + url);
}
async function statusOf(url) {
  try { const r = await fetch(url, { headers: { 'User-Agent': UA } }); return r.status; }
  catch (_) { return 0; }
}

function extractImages(rawJson) {
  const out = [], seen = new Set();
  const re = /https:(?:\\\/|\/){2}images\.squarespace-cdn\.com(?:\\\/|\/)[^"'\\)\s]+/g;
  let m;
  while ((m = re.exec(rawJson))) {
    let u = m[0].replace(/\\\//g, '/').replace(/\?.*$/, '') + '?format=2500w';
    const key = u.replace(/\?format=2500w$/, '');
    if (u.includes('/content/v1/') && !seen.has(key)) { seen.add(key); out.push(u); }
    if (out.length >= IMG_CAP) break;
  }
  return out;
}
function extractTitle(rawJson, slug) {
  const m = rawJson.match(/"(?:title|websiteTitle|seoTitle)"\s*:\s*"([^"]{2,120})"/);
  if (m && m[1] && !/atomic\s*50/i.test(m[1])) return m[1];
  return slug.toUpperCase();
}

async function main() {
  const t0 = Date.now();
  const xml = await fetchText(SITEMAP);
  const locs = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
  const products = [];
  const seenUrl = new Set();
  for (const u of locs) {
    if (!/\/at50[-a-z0-9]+$/i.test(u)) continue;          // individual product pages (incl. at50g34)
    const nu = normUrl(u);
    if (seenUrl.has(nu)) continue; seenUrl.add(nu);
    products.push({ url: u, nurl: nu, slug: u.split('/').pop() });
  }
  console.log(`sitemap: ${locs.length} locs -> ${products.length} individual at50 product pages`);

  const scraped = [];
  for (const p of products) {
    try {
      const raw = await fetchText(p.url + '?format=json');
      scraped.push({ ...p, images: extractImages(raw), title: extractTitle(raw, p.slug) });
    } catch (e) { console.log(`  ! skip ${p.slug}: ${e.message}`); }
    await sleep(150);
  }
  const scrapedByUrl = new Map(scraped.map((s) => [s.nurl, s]));

  const db = new Client({ host: '/tmp', database: 'dw_unified' });
  await db.connect();

  const existing = (await db.query(
    `SELECT dw_sku, mfr_sku, product_url FROM atomic50_catalog WHERE mfr_sku IS NOT NULL`
  )).rows;
  // individual-page catalog rows = those whose product_url is an /at50* page (not /molding-accessories)
  const indivRows = existing.filter((r) => /\/at50[-a-z0-9]+$/i.test(r.product_url || ''));
  const haveByUrl = new Map(indivRows.map((r) => [normUrl(r.product_url), r]));

  const maxRow = await db.query(
    `SELECT MAX((regexp_replace(dw_sku,'\\D','','g'))::bigint) AS n FROM atomic50_catalog WHERE dw_sku ~ '^DWJT-'`
  );
  let nextNum = (Number(maxRow.rows[0].n) || 600059) + 1;

  let inserted = 0, updated = 0;
  for (const s of scraped) {
    const imgsJson = JSON.stringify(s.images);
    const firstImg = s.images[0] || null;
    const row = haveByUrl.get(s.nurl);
    if (row) {
      await db.query(
        `UPDATE atomic50_catalog SET all_images=$2::jsonb, image_url=COALESCE($3,image_url),
             discontinued=false, last_scraped=now(), updated_at=now() WHERE dw_sku=$1`,
        [row.dw_sku, imgsJson, firstImg]
      );
      updated++;
    } else {
      const dw = `DWJT-${nextNum++}`;
      const mfr = s.slug.toUpperCase();
      await db.query(
        `INSERT INTO atomic50_catalog
           (mfr_sku,dw_sku,pattern_name,product_type,all_images,image_url,product_url,
            discontinued,on_shopify,last_scraped,created_at,updated_at)
         VALUES ($1,$2,$3,'Tin Ceiling Tile',$4::jsonb,$5,$6,false,false,now(),now(),now())`,
        [mfr, dw, s.title, imgsJson, firstImg, s.url]
      );
      inserted++; console.log(`  + NEW ${mfr} -> ${dw} (${s.url})`);
    }
  }

  // DISCO: individual-page rows absent from sitemap -> confirm with live 404 before flagging
  let discoFlagged = 0, missing = indivRows.filter((r) => !scrapedByUrl.has(normUrl(r.product_url)));
  const floorOk = scraped.length >= Math.floor(indivRows.length * 0.7);
  if (!DISCO_ON) {
    console.log(`  · disco DISABLED (DISCO=0); ${missing.length} individual rows not in sitemap this pass`);
  } else if (!floorOk) {
    console.log(`  ! disco SKIPPED — scraped ${scraped.length} < 70% of ${indivRows.length} individual rows (hiccup guard)`);
  } else {
    for (const r of missing) {
      const st = await statusOf(r.product_url);
      if (st === 404 || st === 410) {
        await db.query(`UPDATE atomic50_catalog SET discontinued=true, updated_at=now()
                          WHERE dw_sku=$1 AND discontinued IS DISTINCT FROM true`, [r.dw_sku]);
        discoFlagged++; console.log(`  - DISCO ${r.mfr_sku} (${r.product_url} -> ${st})`);
      } else {
        console.log(`  · ${r.mfr_sku} not in sitemap but url still ${st} — left as-is`);
      }
      await sleep(120);
    }
  }

  await db.end();
  const secs = ((Date.now() - t0) / 1000).toFixed(1);
  console.log(`\nDONE (${secs}s, $0 local): scraped=${scraped.length} inserted=${inserted} updated=${updated} disco_flagged=${discoFlagged}`);
}
main().catch((e) => { console.error('FATAL', e); process.exit(1); });