← back to Fineartamerica Price Sync

bin/faa-fetch.js

113 lines

#!/usr/bin/env node
// Populate data/cost-cache.json with real FAA per-config costs by driving the
// live FAA configurator in a logged-in Browserbase session.
//
//   node bin/faa-fetch.js --url "<faa product page>" --sizes 7x8,20x24 --inspect
//   node bin/faa-fetch.js --url "<faa product page>" --from-shopify 7549840293939
//
// --inspect        : dump the configurator's interactive controls + captured
//                    pricing XHRs (use this first to learn the DOM for a listing).
// --from-shopify   : read the needed size/frame/mat/finish configs off a Shopify
//                    product's variant SKUs, fetch each, cache by configKey.
//
// METERED: one Browserbase session (~$0.05–0.30). Costs are cached so re-runs
// only re-fetch configs not already priced.

const fs = require('fs');
const path = require('path');
const { newSession, loginFaa, readPrice, loadCache, saveCache } = require('../lib/faa-cost');
const { parseSku, configKey, configLabel } = require('../lib/parse-sku');
const { getProduct } = require('../lib/shopify');

const args = process.argv.slice(2);
const has = f => args.includes(f);
const val = f => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
const URL0 = val('--url');
const INSPECT = has('--inspect');

(async () => {
  if (!URL0) { console.error('need --url <FAA product page>'); process.exit(1); }

  // Determine which configs we need.
  let configs = [];
  if (val('--from-shopify')) {
    const p = await getProduct(val('--from-shopify'));
    const seen = new Set();
    for (const v of p.variants) {
      const cfg = parseSku(v.sku); if (!cfg) continue;
      const k = configKey(cfg); if (seen.has(k)) continue; seen.add(k);
      configs.push(cfg);
    }
    console.log(`need ${configs.length} distinct FAA configs from Shopify product ${val('--from-shopify')}`);
  }

  const { session, browser, page } = await newSession();
  console.log('browserbase session', session.id, '→ https://browserbase.com/sessions/' + session.id);
  const xhr = [];
  page.on('response', async r => {
    try {
      const u = r.url();
      if (/configurator|price|orderitem|product/i.test(u)) {
        const ct = r.headers()['content-type'] || '';
        if (/json|text|javascript/i.test(ct)) xhr.push({ url: u, status: r.status(), body: (await r.text()).slice(0, 500) });
      }
    } catch {}
  });

  try {
    const li = await loginFaa(page);
    console.log('login:', JSON.stringify(li));
    await page.goto(URL0 + (URL0.includes('?') ? '&' : '?') + 'product=framed-print', { waitUntil: 'domcontentloaded' });
    await page.waitForTimeout(6000);
    console.log('landed:', page.url(), '| default price:', await readPrice(page));

    if (INSPECT) {
      const controls = await page.evaluate(() => {
        const out = { selects: [], buttons: [], priceEls: [] };
        document.querySelectorAll('select').forEach(s => out.selects.push({
          id: s.id, name: s.name, opts: Array.from(s.options).slice(0, 12).map(o => o.value + '=' + o.text.trim()),
        }));
        document.querySelectorAll('[onclick*=size],[data-size],[class*=size]').forEach(b =>
          out.buttons.push({ tag: b.tagName, cls: b.className, txt: (b.textContent || '').trim().slice(0, 40) }));
        document.querySelectorAll('#productPrice,[class*=productPrice]').forEach(e =>
          out.priceEls.push({ cls: e.className, txt: e.textContent.trim() }));
        return out;
      });
      fs.writeFileSync(path.join(__dirname, '..', 'data', 'inspect.json'),
        JSON.stringify({ url: page.url(), controls, xhr }, null, 2));
      console.log('\n=== CONFIGURATOR CONTROLS ===');
      console.log(JSON.stringify(controls, null, 2).slice(0, 3000));
      console.log('\n=== PRICING XHRs ===');
      console.log(JSON.stringify(xhr.slice(0, 8), null, 2).slice(0, 2000));
    }

    // Best-effort per-config fetch: FAA exposes size via a #sizeSelect-style
    // <select>; set it, let the price XHR settle, read #productPrice. Exact
    // selector is confirmed by --inspect first (data/inspect.json).
    if (configs.length) {
      const cache = loadCache();
      for (const cfg of configs) {
        try {
          const sizeVal = `${cfg.imagewidth}x${cfg.imageheight}`;
          const set = await page.evaluate(({ sizeVal }) => {
            const sel = document.querySelector('#selectSize, select[name*=size i], select[id*=size i]');
            if (!sel) return 'no-size-select';
            const opt = Array.from(sel.options).find(o => o.text.replace(/\s/g, '').includes(sizeVal) || o.value.includes(sizeVal));
            if (!opt) return 'no-matching-size-option';
            sel.value = opt.value; sel.dispatchEvent(new Event('change', { bubbles: true }));
            return 'set';
          }, { sizeVal });
          await page.waitForTimeout(3500);
          const price = await readPrice(page);
          console.log(`  ${configLabel(cfg)}  → set:${set}  price:${price}`);
          if (price != null && set === 'set') cache[configKey(cfg)] = price;
        } catch (e) { console.log('  ! ' + configLabel(cfg) + ' — ' + e.message); }
      }
      saveCache(cache);
      console.log('cost-cache updated:', path.join('data', 'cost-cache.json'));
    }
  } finally {
    await browser.close().catch(() => {});
  }
})().catch(e => { console.error(e); process.exit(1); });