[object Object]

← back to All Designerwallcoverings

live-stock: generic schema-public adapter (login-free schema.org/Magento availability) + reclassify 7 mis-tiered PORTAL_NO_CREDS vendors whose anonymous PDPs expose stock — AS Creation, Designers Guild, Graham & Brown, Innovations USA, Marburg, Mind the Gap, Rebel Walls now live-capable (resolvability-gated); Kravet honestly dimmed 'portal does not expose live stock (price sheet only)'; live-capable 4→11

d1be0d2988340b79366760d006d494ed1233df46 · 2026-07-07 10:15:28 -0700 · Steve

Files touched

Diff

commit d1be0d2988340b79366760d006d494ed1233df46
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 7 10:15:28 2026 -0700

    live-stock: generic schema-public adapter (login-free schema.org/Magento availability) + reclassify 7 mis-tiered PORTAL_NO_CREDS vendors whose anonymous PDPs expose stock — AS Creation, Designers Guild, Graham & Brown, Innovations USA, Marburg, Mind the Gap, Rebel Walls now live-capable (resolvability-gated); Kravet honestly dimmed 'portal does not expose live stock (price sheet only)'; live-capable 4→11
---
 data/live-stock-coverage.json     | 151 +++++++++++++++++++++++---------------
 scripts/adapters/index.js         |   1 +
 scripts/adapters/schema-public.js |  81 ++++++++++++++++++++
 scripts/build-coverage.js         |  28 +++++++
 4 files changed, 201 insertions(+), 60 deletions(-)

diff --git a/data/live-stock-coverage.json b/data/live-stock-coverage.json
index 62dabd7..63e15a3 100644
--- a/data/live-stock-coverage.json
+++ b/data/live-stock-coverage.json
@@ -1,14 +1,14 @@
 {
-  "generated_at": "2026-07-07T16:37:56.295Z",
+  "generated_at": "2026-07-07T17:15:01.445Z",
   "generator": "scripts/build-coverage.js",
   "total_display_vendors": 254,
   "counts": {
     "PORTAL_CREDS": 5,
-    "PUBLIC_STOCK": 12,
-    "PORTAL_NO_CREDS": 30,
+    "PUBLIC_STOCK": 24,
+    "PORTAL_NO_CREDS": 18,
     "QUOTE_ONLY": 35,
     "NO_STOCK": 172,
-    "live_capable": 4
+    "live_capable": 11
   },
   "vendors": {
     "1838 Wallcoverings": {
@@ -191,15 +191,18 @@
       "active": 144
     },
     "AS Creation": {
-      "tier": "PORTAL_NO_CREDS",
-      "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "tier": "PUBLIC_STOCK",
+      "live_capable": true,
+      "adapter": "schema-public",
+      "reason": "public schema.org/Magento availability (no login required)",
       "catalog_table": "as_creation_catalog",
       "backing": "AS Creation",
       "match": "exact",
       "products": 505,
-      "active": 327
+      "active": 327,
+      "resolvable_active": 326,
+      "resolvable_pct": 100,
+      "verified": "wired — not yet live-verified"
     },
     "Atomic 50 Ceilings": {
       "tier": "NO_STOCK",
@@ -512,15 +515,17 @@
       "active": 0
     },
     "Contrado": {
-      "tier": "PORTAL_NO_CREDS",
+      "tier": "PUBLIC_STOCK",
       "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "adapter": "schema-public",
+      "reason": "adapter wired (schema-public) but display SKUs don't align with the contrado_catalog catalog (0 of 15 active resolvable) — needs SKU remap",
       "catalog_table": "contrado_catalog",
       "backing": "Contrado",
       "match": "exact",
       "products": 19,
-      "active": 15
+      "active": 15,
+      "resolvable_active": 0,
+      "resolvable_pct": 0
     },
     "Coordonn��": {
       "tier": "NO_STOCK",
@@ -611,26 +616,31 @@
       "active": 0
     },
     "Designers Guild": {
-      "tier": "PORTAL_NO_CREDS",
-      "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "tier": "PUBLIC_STOCK",
+      "live_capable": true,
+      "adapter": "schema-public",
+      "reason": "public schema.org/Magento availability (no login required)",
       "catalog_table": "designers_guild_catalog",
       "backing": "Designers Guild",
       "match": "exact",
       "products": 879,
-      "active": 853
+      "active": 853,
+      "resolvable_active": 189,
+      "resolvable_pct": 22,
+      "verified": "wired — not yet live-verified"
     },
     "Designtex": {
-      "tier": "PORTAL_NO_CREDS",
+      "tier": "PUBLIC_STOCK",
       "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "adapter": "schema-public",
+      "reason": "adapter wired (schema-public) but display SKUs don't align with the designtex_catalog catalog (0 of 288 active resolvable) — needs SKU remap",
       "catalog_table": "designtex_catalog",
       "backing": "Designtex",
       "match": "exact",
       "products": 317,
-      "active": 288
+      "active": 288,
+      "resolvable_active": 0,
+      "resolvable_pct": 0
     },
     "Dolce & Gabbana": {
       "tier": "QUOTE_ONLY",
@@ -1073,15 +1083,18 @@
       "active": 35
     },
     "Graham & Brown": {
-      "tier": "PORTAL_NO_CREDS",
-      "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "tier": "PUBLIC_STOCK",
+      "live_capable": true,
+      "adapter": "schema-public",
+      "reason": "public schema.org/Magento availability (no login required)",
       "catalog_table": "graham_brown_catalog",
       "backing": "Graham & Brown",
       "match": "exact",
       "products": 1679,
-      "active": 405
+      "active": 405,
+      "resolvable_active": 399,
+      "resolvable_pct": 99,
+      "verified": "wired — not yet live-verified"
     },
     "Grasscloth": {
       "tier": "QUOTE_ONLY",
@@ -1284,26 +1297,31 @@
       "active": 0
     },
     "Innovations": {
-      "tier": "PORTAL_NO_CREDS",
+      "tier": "PUBLIC_STOCK",
       "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "adapter": "schema-public",
+      "reason": "adapter wired (schema-public) but display SKUs don't align with the innovations_catalog catalog (0 of 8 active resolvable) — needs SKU remap",
       "catalog_table": "innovations_catalog",
       "backing": "Innovations",
       "match": "exact",
       "products": 9,
-      "active": 8
+      "active": 8,
+      "resolvable_active": 0,
+      "resolvable_pct": 0
     },
     "Innovations USA": {
-      "tier": "PORTAL_NO_CREDS",
-      "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "tier": "PUBLIC_STOCK",
+      "live_capable": true,
+      "adapter": "schema-public",
+      "reason": "public schema.org/Magento availability (no login required)",
       "catalog_table": "innovations_catalog",
       "backing": "Innovations",
       "match": "suffix-strip",
       "products": 471,
-      "active": 210
+      "active": 210,
+      "resolvable_active": 91,
+      "resolvable_pct": 43,
+      "verified": "wired — not yet live-verified"
     },
     "Jane Churchill": {
       "tier": "NO_STOCK",
@@ -1386,7 +1404,7 @@
       "tier": "PORTAL_NO_CREDS",
       "live_capable": false,
       "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "reason": "vendor portal does not expose live stock (wholesale price sheet / SFTP only)",
       "catalog_table": "kravet_catalog",
       "backing": "Kravet",
       "match": "exact",
@@ -1632,15 +1650,18 @@
       "active": 0
     },
     "Marburg": {
-      "tier": "PORTAL_NO_CREDS",
-      "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "tier": "PUBLIC_STOCK",
+      "live_capable": true,
+      "adapter": "schema-public",
+      "reason": "public schema.org/Magento availability (no login required)",
       "catalog_table": "marburg_catalog",
       "backing": "Marburg",
       "match": "exact",
       "products": 121,
-      "active": 112
+      "active": 112,
+      "resolvable_active": 96,
+      "resolvable_pct": 86,
+      "verified": "wired — not yet live-verified"
     },
     "Marimekko Exclusive": {
       "tier": "NO_STOCK",
@@ -1720,15 +1741,18 @@
       "active": 1
     },
     "Mind the Gap": {
-      "tier": "PORTAL_NO_CREDS",
-      "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "tier": "PUBLIC_STOCK",
+      "live_capable": true,
+      "adapter": "schema-public",
+      "reason": "public schema.org/Magento availability (no login required)",
       "catalog_table": "mindthegap_catalog",
       "backing": "Mind the Gap",
       "match": "exact",
       "products": 1261,
-      "active": 1134
+      "active": 1134,
+      "resolvable_active": 924,
+      "resolvable_pct": 81,
+      "verified": "wired — not yet live-verified"
     },
     "Missoni Fabric": {
       "tier": "QUOTE_ONLY",
@@ -1891,26 +1915,30 @@
       "active": 1
     },
     "Osborne & Little": {
-      "tier": "PORTAL_NO_CREDS",
+      "tier": "PUBLIC_STOCK",
       "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "adapter": "schema-public",
+      "reason": "adapter wired (schema-public) but display SKUs don't align with the osborne_catalog catalog (49 of 957 active resolvable) — needs SKU remap",
       "catalog_table": "osborne_catalog",
       "backing": "Osborne & Little",
       "match": "exact",
       "products": 1041,
-      "active": 957
+      "active": 957,
+      "resolvable_active": 49,
+      "resolvable_pct": 5
     },
     "Osborne & Little Fabrics": {
-      "tier": "PORTAL_NO_CREDS",
+      "tier": "PUBLIC_STOCK",
       "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "adapter": "schema-public",
+      "reason": "adapter wired (schema-public) but display SKUs don't align with the osborne_catalog catalog (0 of 0 active resolvable) — needs SKU remap",
       "catalog_table": "osborne_catalog",
       "backing": "Osborne & Little",
       "match": "suffix-strip",
       "products": 1101,
-      "active": 0
+      "active": 0,
+      "resolvable_active": 0,
+      "resolvable_pct": 0
     },
     "Parkertex": {
       "tier": "NO_STOCK",
@@ -2201,15 +2229,18 @@
       "active": 1
     },
     "Rebel Walls": {
-      "tier": "PORTAL_NO_CREDS",
-      "live_capable": false,
-      "adapter": null,
-      "reason": "vendor portal exists but no trade credentials on file — supply creds to enable",
+      "tier": "PUBLIC_STOCK",
+      "live_capable": true,
+      "adapter": "schema-public",
+      "reason": "public schema.org/Magento availability (no login required)",
       "catalog_table": "rebelwalls_catalog",
       "backing": "Rebel Walls",
       "match": "exact",
       "products": 3721,
-      "active": 3613
+      "active": 3613,
+      "resolvable_active": 1028,
+      "resolvable_pct": 28,
+      "verified": "wired — not yet live-verified"
     },
     "Retro Walls": {
       "tier": "NO_STOCK",
diff --git a/scripts/adapters/index.js b/scripts/adapters/index.js
index e5ab39e..d637c0d 100644
--- a/scripts/adapters/index.js
+++ b/scripts/adapters/index.js
@@ -16,6 +16,7 @@ const ADAPTERS = {
   romo: require('./romo'),
   'shopify-public': require('./shopify-public'),
   'woo-public': require('./woo-public'),
+  'schema-public': require('./schema-public'), // login-free schema.org/Magento availability
 };
 
 function get(key) { return ADAPTERS[key] || null; }
diff --git a/scripts/adapters/schema-public.js b/scripts/adapters/schema-public.js
new file mode 100644
index 0000000..323aea8
--- /dev/null
+++ b/scripts/adapters/schema-public.js
@@ -0,0 +1,81 @@
+// 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 } = 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;
+    if (ldRaw) {
+      const m = ldRaw.match(/"availability"\s*:\s*"([^"]+)"/i);
+      if (m) { ldAvail = m[1]; if (/OutOfStock|SoldOut|Discontinued|BackOrder/i.test(ldAvail)) in_stock = false; else if (/InStock|LimitedAvailability|PreOrder|OnlineOnly|InStoreOnly/i.test(ldAvail)) in_stock = true; }
+    }
+
+    // 2. product-scoped DOM stock markup (avoid global nav chrome).
+    let domTxt = '';
+    if (in_stock === null) {
+      domTxt = await page.$$eval(
+        '[itemprop=availability], .product [class*=stock], .product-info [class*=stock], [class*=availab], .stock, .product-form__inventory',
+        (els) => els.map((e) => (e.getAttribute('content') || e.getAttribute('title') || e.textContent || '').trim()).filter(Boolean).join(' | ')
+      ).catch(() => '');
+      const NAV = /(discontinued|sale|stock)\s+list|check\s+stock|stock\s+list/ig;
+      const t = String(domTxt).replace(NAV, '');
+      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;
+    }
+
+    // 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(
+        'button[name=add], button[type=submit], [class*=add-to], [class*=addto], a[href*=cart]',
+        (els) => els.filter((e) => /add to (cart|basket|bag|sample)|add sample/i.test(e.textContent || e.getAttribute('aria-label') || ''))
+          .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 = 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 {
+    try { if (browser) await browser.close(); } catch {}
+    try { if (bb && session) await bb.sessions.update(session.id, { projectId: session.projectId, status: 'REQUEST_RELEASE' }); } catch {}
+  }
+}
+
+module.exports = { run };
diff --git a/scripts/build-coverage.js b/scripts/build-coverage.js
index d7328c3..1b8f00e 100644
--- a/scripts/build-coverage.js
+++ b/scripts/build-coverage.js
@@ -59,6 +59,25 @@ const VERIFIED = {
   'Romo': 'reaches live portal 2026-07-07 ($0.08) — stock-parse selectors pending tuning',
 };
 
+// PUBLIC-SCHEMA vendors — to-the-trade / Magento / custom storefronts whose ANONYMOUS product
+// pages emit schema.org Offer availability (or visible Magento stock markup), so live stock is
+// readable WITHOUT a trade login. Probed 2026-07-07 (free HTTP HEAD/GET, no session). These were
+// mis-tiered as PORTAL_NO_CREDS purely because a login_url exists — but a login_url gates checkout,
+// not the public availability signal. Routed to the login-free `schema-public` adapter; still
+// subject to the resolvability gate (display SKUs must align with the catalog product_urls).
+const SCHEMA_PUBLIC = new Set([
+  'mind the gap', 'osborne & little', 'osborne & little fabrics', 'designers guild',
+  'innovations', 'innovations usa', 'marburg', 'designtex', 'graham & brown',
+  'as creation', 'contrado', 'rebel walls',
+]);
+
+// Portals that require a login AND publish only PRICE (a wholesale price sheet / SFTP feed), never
+// live inventory — enabling a metered scrape would burn a session to read a price we can't surface.
+// Honest dim: creds may exist, but the portal has no live-stock signal to read.
+const NO_LIVE_STOCK_PORTAL = new Set([
+  'kravet', // Kravet SFTP master = MAP/wholesale price sheet only; no per-SKU live inventory
+]);
+
 const QUOTE_ONLY = new Set([
   'dedar', 'de gournay', 'de gournay murals', 'fromental', 'gracie', 'iksel', 'zuber',
   'porter teleo', 'holland and sherry', 'holland & sherry', 'clarence house', 'fortuny',
@@ -172,6 +191,15 @@ async function main() {
     if (oneToOne && dedicated(tbl) && hasUrls(tbl) && catShopify.get(tbl))
       return { tier: 'PUBLIC_STOCK', live_capable: true, adapter: 'shopify-public',
         reason: 'public Shopify storefront stock (.js feed)', catalog_table: tbl };
+    // 2b. PUBLIC_STOCK (schema.org) — probed vendors whose anonymous PDP emits Offer availability.
+    //     Precedes the portal branches so a login_url doesn't force these to dim as PORTAL_NO_CREDS.
+    if (SCHEMA_PUBLIC.has(v) && oneToOne && dedicated(tbl) && hasUrls(tbl))
+      return { tier: 'PUBLIC_STOCK', live_capable: true, adapter: 'schema-public',
+        reason: 'public schema.org/Magento availability (no login required)', catalog_table: tbl };
+    // 2c. PORTAL, PRICE-ONLY — login publishes a price sheet, not live stock → honest dim.
+    if (NO_LIVE_STOCK_PORTAL.has(v))
+      return { tier: 'PORTAL_NO_CREDS', live_capable: false, adapter: null,
+        reason: 'vendor portal does not expose live stock (wholesale price sheet / SFTP only)', catalog_table: tbl };
     if (backing.has_pass) // creds exist but no adapter wired for this specific portal yet
       return { tier: 'PORTAL_CREDS', live_capable: false, adapter: null,
         reason: 'backing portal has trade creds but no live adapter wired yet', catalog_table: tbl };

← 2c397b4 live-stock: daily $ kill-switch — global per-day spend ceili  ·  back to All Designerwallcoverings  ·  live-stock verify: 4 NEW vendors live-verified via schema-pu 3790ba8 →