← 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); });