← back to All Designerwallcoverings

scripts/adapters/shopify-public.js

66 lines

// adapters/shopify-public.js — PUBLIC-STOCK adapter for vendors on a public Shopify storefront.
//
// No login. Per Steve's uniform-metered decision, this still runs through the SAME Browserbase
// cloud-browser mechanism as the portal adapters (not a special-cased free server-side fetch):
// it opens a session, navigates the storefront, and reads the canonical Shopify `<product>.js`
// stock feed IN-PAGE. That feed exposes `available` (product) + `variants[].available` + `sku`.
//
// PUBLIC-SAFE: emits availability only. `price` is null — the storefront price is the vendor's DTC
// retail, NOT our retail, so we never surface it (avoids implying it's our price).
const { resolveCatalogUrl, releaseSession } = require('./resolve');

async function run({ sku, pool, newSession, catalogTable, log }) {
  let sessionOpened = false, browser = null, session = null, bb = null;
  try {
    // Resolve the grid SKU → its Shopify product URL (+ mfr_sku for variant matching), bridging
    // through shopify_products so the display SKU aligns with the catalog's own keys.
    const r = await resolveCatalogUrl(pool, catalogTable, sku);
    if (r.error) return { available: false, reason: r.error };
    const { product_url, mfr_sku } = r;
    const jsUrl = String(product_url).replace(/[?#].*$/, '').replace(/\/+$/, '') + '.js';

    // Metered Browserbase session — read the .js feed in-page (uniform mechanism; redirects followed).
    const sess = await newSession();
    ({ browser, session, bb } = sess); const page = sess.page;
    sessionOpened = true;
    log('bb session', session && session.id, '→', jsUrl);

    await page.goto(jsUrl, { waitUntil: 'domcontentloaded', timeout: 45000 });
    let txt = await page.evaluate(() => document.body && document.body.innerText).catch(() => null);
    let data = null;
    try { data = JSON.parse(txt); } catch { /* fall back below */ }
    if (!data || !Array.isArray(data.variants)) {
      // fallback: load the product page and fetch the feed relative to the resolved location
      await page.goto(String(product_url).replace(/[?#].*$/, ''), { waitUntil: 'domcontentloaded', timeout: 45000 }).catch(() => {});
      txt = await page.evaluate(async () => {
        try { const r = await fetch(location.pathname.replace(/\/+$/, '') + '.js', { headers: { Accept: 'application/json' } }); return await r.text(); } catch { return null; }
      }).catch(() => null);
      try { data = JSON.parse(txt); } catch { data = null; }
    }
    if (!data) return { available: false, session_opened: true, reason: 'storefront stock feed unavailable' };

    // Prefer the specific variant matching our mfr_sku; else the product-level availability.
    const variants = Array.isArray(data.variants) ? data.variants : [];
    let v = null;
    if (mfr_sku) {
      const key = String(mfr_sku).toUpperCase();
      v = variants.find((x) => x.sku && (String(x.sku).toUpperCase().includes(key) || key.includes(String(x.sku).toUpperCase())));
    }
    const in_stock = v ? !!v.available : (data.available != null ? !!data.available : (variants.some((x) => x.available) || null));
    const label = in_stock === true ? (v ? 'variant in stock' : 'in stock')
      : in_stock === false ? 'out of stock' : 'unknown';

    return {
      available: true, session_opened: true, vendor: data.vendor || null,
      in_stock, lead_time: null, price: null, // vendor DTC price is NOT our retail → never emitted
      raw_stock_label: label, as_of: new Date().toISOString(), source: 'shopify-public-live',
    };
  } catch (e) {
    return { available: false, session_opened: sessionOpened, reason: 'live scrape error: ' + (e.message || e) };
  } finally {
    await releaseSession({ browser, bb, session });
  }
}

module.exports = { run };