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