← back to All Designerwallcoverings

scripts/adapters/schema-public.js

124 lines

// adapters/schema-public.js — PUBLIC-STOCK adapter for vendors whose product pages render
// availability WITHOUT a login. Many "to-the-trade" storefronts (Magento, custom CMS) still
// emit schema.org Product/Offer JSON-LD (offers.availability = InStock/OutOfStock) and/or a
// visible stock indicator + add-to-cart state on the ANONYMOUS product page. This adapter reads
// that public signal — it never logs in, so no trade credentials are required for these vendors.
//
// Shape-general (NOT per-vendor special-cased): it resolves the grid SKU → the catalog product_url
// (bridged through shopify_products), opens the SAME Browserbase session mechanism the portal
// adapters use, and derives availability from, in priority order:
//   1. schema.org JSON-LD  offers.availability  (authoritative when present)
//   2. product-scoped DOM stock markup ([itemprop=availability], .stock, [class*=availab])
//   3. add-to-cart button presence/enabled state (a disabled/absent CTA ⇒ out of stock)
//
// PUBLIC-SAFE: emits availability only. price is ALWAYS null — a vendor's public retail is NOT our
// retail, so it's never surfaced (same contract as shopify-public / woo-public).
const { resolveCatalogUrl, NAV_RE, releaseSession } = require('./resolve');

async function run({ sku, pool, newSession, catalogTable, log }) {
  let sessionOpened = false, browser = null, session = null, bb = null;
  try {
    const r = await resolveCatalogUrl(pool, catalogTable, sku);
    if (r.error) return { available: false, reason: r.error };
    const url = r.product_url;

    const sess = await newSession();
    ({ browser, session, bb } = sess); const page = sess.page;
    sessionOpened = true;
    log('bb session', session && session.id, '→', url);

    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
    await page.waitForTimeout(5000); // let any client-rendered availability settle

    // 1. schema.org JSON-LD — parse every ld+json block, look for an Offer availability.
    const ldRaw = await page.$$eval('script[type="application/ld+json"]', (els) => els.map((e) => e.textContent).join('\n')).catch(() => '');
    let in_stock = null, ldAvail = null;
    const readAvail = (s) => { if (/OutOfStock|SoldOut|Discontinued|BackOrder/i.test(s)) return false; if (/InStock|LimitedAvailability|PreOrder|OnlineOnly|InStoreOnly/i.test(s)) return true; return null; };
    if (ldRaw) {
      const m = ldRaw.match(/"availability"\s*:\s*"([^"]+)"/i);
      if (m) { ldAvail = m[1]; in_stock = readAvail(ldAvail); }
    }

    // 2. product-scoped DOM stock markup — read microdata too. Magento often emits availability as
    //    <link itemprop="availability" href="http://schema.org/InStock"> (empty textContent, signal
    //    lives in the HREF), so we read href/content/title/text and match the bare schema token.
    let domTxt = '';
    if (in_stock === null) {
      domTxt = await page.$$eval(
        // Magento renders its stock state as <div class="stock_status" data-bind="text: stockStatus">
        // (Knockout — empty in raw HTML, filled after JS; our 5s settle lets it populate), so
        // .stock_status / [class*=stock_status] are added to the read set (catches Osborne & Little).
        '[itemprop=availability], link[itemprop=availability], meta[itemprop=availability], .product [class*=stock], .product-info [class*=stock], .stock_status, [class*=stock_status], [class*=stock-status], [class*=availab], .stock, .product-form__inventory',
        (els) => els.map((e) => (e.getAttribute('href') || e.getAttribute('content') || e.getAttribute('title') || e.textContent || '').trim()).filter(Boolean).join(' | ')
      ).catch(() => '');
      const t = String(domTxt).replace(NAV_RE, '');
      const tok = readAvail(t); // bare schema.org/InStock|OutOfStock token in an href/content
      if (tok !== null) in_stock = tok;
      else if (/out\s*of\s*stock|sold\s*out|unavailable|no longer available/i.test(t)) in_stock = false;
      else if (/in\s*stock|in-?stock|available/i.test(t)) in_stock = true;
    }

    // 2.5 whole-document availability VOTE — some storefronts embed the true stock state in places
    //     steps 1–2 don't reach: WooCommerce stamps it as a body class on each product tile
    //     (<li class="product … instock|outofstock">, e.g. Marburg), and some Magento/CMS sites emit
    //     it only as deeply escaped JSON inside a script string (e.g. Designers Guild's
    //     \"itemAvailability\":\"InStock\"). We scan the rendered document for BOTH the schema
    //     availability tokens and the Woo stock classes, then vote: any out-of-stock signal with NO
    //     in-stock signal ⇒ false; any in-stock signal ⇒ true. Shape-general, not per-vendor.
    let voteLabel = null;
    if (in_stock === null) {
      const vote = await page.evaluate(() => {
        const html = document.documentElement.innerHTML || '';
        const n = (re) => (html.match(re) || []).length;
        // STRUCTURED-ONLY: count availability tokens only where they appear in a machine-readable
        // context — a schema.org/<token> URL, or a JSON "…availability":"<token>" key (incl.
        // backslash-escaped JSON strings), or a WooCommerce structural stock class. This deliberately
        // does NOT match the bare English words in prose: e.g. Osborne & Little stamps a
        // "…Limited and Discontinued fabrics…" Warehouse-Sale banner on EVERY page, and a loose word
        // match would read every Osborne product out-of-stock. Structural gating kills that false read.
        const wooIn = document.querySelectorAll('[class~="instock"]').length;
        const wooOut = document.querySelectorAll('[class~="outofstock"]').length;
        const inTok = n(/schema\.org\/(?:InStock|LimitedAvailability|PreOrder)/gi)
          + n(/[Aa]vailability\\?"?\s*:\s*\\?"?(?:InStock|LimitedAvailability|PreOrder)/g) + wooIn;
        const outTok = n(/schema\.org\/(?:OutOfStock|SoldOut|Discontinued|BackOrder)/gi)
          + n(/[Aa]vailability\\?"?\s*:\s*\\?"?(?:OutOfStock|SoldOut|Discontinued|BackOrder)/g) + wooOut;
        return { inTok, outTok };
      }).catch(() => ({ inTok: 0, outTok: 0 }));
      if (vote.inTok || vote.outTok) {
        if (vote.outTok > 0 && vote.inTok === 0) in_stock = false;
        else if (vote.inTok > 0) in_stock = true;
        voteLabel = `${in_stock ? 'in stock' : 'out of stock'} (${vote.inTok} in / ${vote.outTok} out signals)`;
      }
    }

    // 3. add-to-cart CTA state (last resort — presence of an enabled add-to-cart ⇒ purchasable).
    if (in_stock === null) {
      const cta = await page.$$eval(
        // [class*=tocart] = Magento's canonical add-to-cart button class (Osborne & Little). Magento
        // often keeps the visible label in title=/aria-label rather than textContent, so we test all three.
        'button[name=add], button[type=submit], [class*=add-to], [class*=addto], [class*=tocart], a[href*=cart]',
        (els) => els.filter((e) => /add to (cart|basket|bag|sample)|add sample/i.test(
            [e.textContent, e.getAttribute('aria-label'), e.getAttribute('title')].filter(Boolean).join(' ')))
          .map((e) => (e.disabled || /disabled/i.test(e.className) ? 'disabled' : 'enabled')).join(',')
      ).catch(() => '');
      if (/enabled/.test(cta)) in_stock = true;
      else if (cta && !/enabled/.test(cta)) in_stock = false;
    }

    const label = voteLabel || (in_stock === true ? 'in stock' : in_stock === false ? 'out of stock'
      : (ldAvail || (domTxt ? domTxt.slice(0, 60) : 'unknown')));

    return {
      available: true, session_opened: true, vendor: null,
      in_stock, lead_time: null, price: null, // public retail is NOT our retail → never emitted
      raw_stock_label: label, as_of: new Date().toISOString(), source: 'schema-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 };