← back to Whatsmystyle

scripts/clothing-apis/calibrate-spa.js

121 lines

#!/usr/bin/env node
/**
 * SPA selector calibrator.
 *
 * Usage:
 *   node scripts/clothing-apis/calibrate-spa.js <brand-id>
 *
 * Steps:
 *   1. Load the brand entry from brands.json (must exist under headless_spa[]).
 *   2. Open the products page via Browserbase, wait for network idle, pull
 *      the rendered HTML, cache to /tmp/spa-<id>.html.
 *   3. Probe a battery of common card selectors. Print the count for each.
 *   4. For the best-scoring card selector, probe likely title/price/image/link
 *      sub-selectors inside the first card.
 *   5. Print a ready-to-paste `selectors` block.
 *
 * No interactive prompt — pure batch mode so it can run from CI / cron.
 * Once selectors look good, paste them into brands.json + flip `disabled: false`.
 *
 * Idempotent: re-running uses the cached HTML if it's < 1 hour old.
 */
const fs = require('fs');
const path = require('path');
const { fetchRenderedHtml, available } = require('./headless-spa');

const BRANDS = JSON.parse(fs.readFileSync(path.join(__dirname, 'brands.json'), 'utf8'));

// Common card selectors across the e-commerce SPA ecosystem.
const CARD_SELECTORS = [
  '.product-card', '.product', '.ProductCard', '.product-tile', '.product-item',
  '[data-test-id="product-card"]', '[data-testid="product-card"]', '[data-test="product-card"]',
  '[data-product-card]', '[data-product]', 'article.product',
  '.grid__item--product', '.collection-product', '.collection-product-card',
  'li.product', '.product-grid-item', '.shop-item', '.shop-card',
];

// Within a card, candidate selectors per field.
const TITLE_SELECTORS = ['.product-card__title', '.product__title', 'h2', 'h3', '.title', '[data-test*="title"]', '.name', '.product-name'];
const PRICE_SELECTORS = ['.product-card__price', '.product__price', '.price', '[data-test*="price"]', '.amount', '.product-price'];
const IMAGE_SELECTORS = ['img'];
const LINK_SELECTORS = ['a'];

async function main() {
  const brandId = process.argv[2];
  if (!brandId) {
    console.error('usage: node scripts/clothing-apis/calibrate-spa.js <brand-id>');
    console.error('available: ' + (BRANDS.headless_spa || []).map(b => b.id).join(', '));
    process.exit(1);
  }
  const brand = (BRANDS.headless_spa || []).find(b => b.id === brandId);
  if (!brand) {
    console.error(`brand ${brandId} not in brands.json headless_spa`);
    process.exit(1);
  }
  if (!available()) {
    console.error('BROWSERBASE_API_KEY / BROWSERBASE_PROJECT_ID not set in env or skill .env');
    process.exit(1);
  }

  const cacheFile = `/tmp/spa-${brandId}.html`;
  const cacheAgeMs = fs.existsSync(cacheFile) ? Date.now() - fs.statSync(cacheFile).mtimeMs : Infinity;
  let html;
  if (cacheAgeMs < 60 * 60 * 1000) {
    console.log(`[cache hit] reusing ${cacheFile} (age ${Math.round(cacheAgeMs/1000)}s)`);
    html = fs.readFileSync(cacheFile, 'utf8');
  } else {
    const url = `https://${brand.domain}${brand.products_path || '/products'}`;
    console.log(`[fetch] ${url} via Browserbase (this takes ~10-30s)`);
    html = await fetchRenderedHtml(url, { waitMs: 5000 });
    fs.writeFileSync(cacheFile, html);
    console.log(`[cache] wrote ${cacheFile} (${(html.length / 1024).toFixed(0)} KB)`);
  }

  let cheerio;
  try { cheerio = require('cheerio'); }
  catch { console.error('cheerio not installed — run `npm i cheerio`'); process.exit(1); }
  const $ = cheerio.load(html);

  // Probe 1 — which card selector matches a sensible number of cards?
  console.log('\n=== card selector candidates ===');
  const cardScores = CARD_SELECTORS.map(s => ({ s, n: $(s).length }));
  cardScores.sort((a, b) => b.n - a.n);
  cardScores.slice(0, 8).forEach(c => console.log(`  ${String(c.n).padStart(4)} × ${c.s}`));
  const best = cardScores.find(c => c.n >= 4 && c.n <= 500) || cardScores[0];
  if (!best || best.n === 0) {
    console.log('\nNo card selector matched. The page may be lazy-rendered (try a longer waitMs in headless-spa.js fetchRenderedHtml) or use a fully custom DOM structure (open /tmp/spa-' + brandId + '.html and craft selectors by hand).');
    process.exit(2);
  }
  console.log(`\n[pick] card = "${best.s}" (${best.n} matches)`);

  // Probe 2 — inside the first card, which sub-selectors hit?
  const firstCard = $(best.s).first();
  function probe(name, candidates) {
    const hits = candidates.map(c => ({ c, txt: firstCard.find(c).first().text().trim().slice(0, 60), exists: firstCard.find(c).length > 0 })).filter(x => x.exists);
    console.log(`\n=== ${name} candidates inside first card ===`);
    hits.forEach(h => console.log(`  ${h.c.padEnd(35)} → ${JSON.stringify(h.txt)}`));
    return hits[0]?.c || candidates[0];
  }
  const titleSel = probe('title', TITLE_SELECTORS);
  const priceSel = probe('price', PRICE_SELECTORS);

  // image + link probes (different — they want attr not text)
  const imgEl = firstCard.find('img').first();
  const imgSrc = imgEl.attr('src') || imgEl.attr('data-src') || imgEl.attr('srcset')?.split(',')[0]?.trim().split(' ')[0];
  const linkEl = firstCard.find('a').first();
  const linkHref = linkEl.attr('href');
  console.log(`\n=== image inside first card ===`);
  console.log(`  ${imgSrc ? 'FOUND: ' + (imgSrc.slice(0, 90)) : 'no <img> with src/data-src/srcset'}`);
  console.log(`\n=== link inside first card ===`);
  console.log(`  ${linkHref ? 'FOUND: ' + linkHref.slice(0, 90) : 'no <a href>'}`);

  // Final paste-ready block
  console.log(`\n=== READY-TO-PASTE selectors for brands.json → headless_spa[${brandId}] ===`);
  console.log(JSON.stringify({
    selectors: { card: best.s, title: titleSel, price: priceSel, image: 'img', link: 'a' }
  }, null, 2));
  console.log(`\nIf the values above look right, flip "disabled": false in brands.json and run:\n  curl -sS -X POST -H 'content-type: application/json' -d '{"kind":"headless","id":"${brandId}"}' http://127.0.0.1:9777/api/admin/import-catalog`);
}

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