← back to All Designerwallcoverings
live-stock adapter framework: generalized per-vendor registry (wallquest/romo/shopify-public/woo-public), coverage-driven enable+dim, live-scrape.js dispatcher
af39b75dedd37f6e49084397bbc481233431aaf6 · 2026-07-07 09:28:56 -0700 · Steve
Files touched
M public/index.htmlA scripts/adapters/index.jsA scripts/adapters/romo.jsA scripts/adapters/shopify-public.jsA scripts/adapters/wallquest.jsA scripts/adapters/woo-public.jsM scripts/live-scrape.jsM server.js
Diff
commit af39b75dedd37f6e49084397bbc481233431aaf6
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 7 09:28:56 2026 -0700
live-stock adapter framework: generalized per-vendor registry (wallquest/romo/shopify-public/woo-public), coverage-driven enable+dim, live-scrape.js dispatcher
---
public/index.html | 2 +-
scripts/adapters/index.js | 29 +++++++
scripts/adapters/romo.js | 60 +++++++++++++++
scripts/adapters/shopify-public.js | 83 ++++++++++++++++++++
scripts/adapters/wallquest.js | 89 ++++++++++++++++++++++
scripts/adapters/woo-public.js | 57 ++++++++++++++
scripts/live-scrape.js | 150 +++++++++++++------------------------
server.js | 52 +++++++++----
8 files changed, 409 insertions(+), 113 deletions(-)
diff --git a/public/index.html b/public/index.html
index a82c103..d8882f9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -497,7 +497,7 @@ function actButtonsHTML(r) {
: `<button class="im disabled" type="button" title="No stored stock snapshot — run /livestock ${esc(r.sku || '')}">Check Stock</button>`;
const live = r.live_capable
? `<button class="im livebtn" type="button" data-sku="${esc(r.sku || '')}" title="Metered vendor-portal scrape (~$0.15–0.30 · cached 10 min)">Check Live 🔄</button>`
- : `<button class="im disabled" type="button" title="live check unavailable for this vendor">Check Live 🔄</button>`;
+ : `<button class="im disabled" type="button" title="${esc(r.live_reason || 'live check unavailable for this vendor')}">Check Live 🔄</button>`;
return `${web}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${item}${stock}${live}${stockPopHTML(r.stock)}<div class="livepop" hidden></div></div>`;
}
diff --git a/scripts/adapters/index.js b/scripts/adapters/index.js
new file mode 100644
index 0000000..e5ab39e
--- /dev/null
+++ b/scripts/adapters/index.js
@@ -0,0 +1,29 @@
+// scripts/adapters/index.js — the generalized per-vendor LIVE-STOCK adapter registry.
+//
+// Each adapter is a module exporting `async run(ctx)` where ctx = { sku, pool, newSession,
+// romoLogin, catalogTable, log }. It owns its own login (creds read from the DB — never a
+// hardcoded/echoed secret), resolves the SKU's vendor product page, reads stock/availability,
+// and returns a PUBLIC-SAFE result:
+//
+// success: { available:true, session_opened, vendor, in_stock, lead_time, price, raw_stock_label, as_of, source }
+// failure: { available:false, session_opened, reason }
+//
+// PUBLIC-SAFE CONTRACT (enforced by every adapter): only availability + our-RETAIL price ever
+// leave here. `price` is emitted ONLY when the adapter can produce our retail (WallQuest transforms
+// its cost→retail); portal net/trade prices and vendor DTC prices are NEVER emitted (price:null).
+const ADAPTERS = {
+ wallquest: require('./wallquest'),
+ romo: require('./romo'),
+ 'shopify-public': require('./shopify-public'),
+ 'woo-public': require('./woo-public'),
+};
+
+function get(key) { return ADAPTERS[key] || null; }
+
+async function run(key, ctx) {
+ const a = get(key);
+ if (!a) return { available: false, session_opened: false, reason: `no adapter '${key}'` };
+ return a.run(ctx);
+}
+
+module.exports = { run, get, keys: () => Object.keys(ADAPTERS) };
diff --git a/scripts/adapters/romo.js b/scripts/adapters/romo.js
new file mode 100644
index 0000000..dff90dc
--- /dev/null
+++ b/scripts/adapters/romo.js
@@ -0,0 +1,60 @@
+// adapters/romo.js — Romo trade-portal live-stock adapter (PORTAL+CREDS).
+//
+// Reuses the EXISTING Romo login mechanics from lib/romo-lib.js (the romo-scraper-manager
+// login flow — trade.theromogroup.com, #fmregister-email/#fmregister-password, SSO state
+// handling) rather than reinventing the portal. Creds are read from the DB inside that helper;
+// they never travel on a command line or get echoed.
+//
+// PUBLIC-SAFE: emits availability only. Romo's portal price is the NET/TRADE (cost) price with no
+// known retail transform in this context, so `price` is always null — never emitted.
+async function run({ sku, pool, newSession, romoLogin, log }) {
+ let sessionOpened = false, browser = null, session = null, bb = null;
+ try {
+ // 1. Resolve the SKU → its Romo portal product page.
+ const { rows } = await pool.query(
+ `SELECT dw_sku, mfr_sku, product_url, pattern_name, color_name
+ FROM romo_catalog
+ WHERE (upper(dw_sku)=upper($1) OR upper(mfr_sku)=upper($1)) AND product_url IS NOT NULL AND product_url<>''
+ LIMIT 1`, [sku]);
+ if (!rows.length) return { available: false, reason: 'SKU not found in Romo catalog (or no product URL)' };
+ const url = rows[0].product_url;
+
+ // 2. Metered Browserbase session + Romo portal login (documented mechanics from romo-lib).
+ const sess = await newSession();
+ ({ browser, session, bb } = sess); const page = sess.page;
+ sessionOpened = true;
+ log('bb session', session && session.id);
+ if (typeof romoLogin === 'function') { try { await romoLogin(page); } catch (e) { log('romo login note:', e.message); } }
+
+ // 3. Product page → read availability (broad selectors + schema.org JSON-LD + add-to-cart state).
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
+ await page.waitForTimeout(6000);
+
+ const availTxt = await page.$$eval(
+ '[class*=stock], [class*=availab], [itemprop=availability], .product-form__inventory, button[name=add]',
+ (els) => els.map((e) => (e.getAttribute('content') || e.getAttribute('aria-label') || e.textContent || '').trim()).filter(Boolean).join(' | ')
+ ).catch(() => '');
+ // schema.org availability from any JSON-LD block
+ const ld = await page.$$eval('script[type="application/ld+json"]', (els) => els.map((e) => e.textContent).join(' ')).catch(() => '');
+ const hay = (availTxt + ' ' + ld);
+
+ let in_stock = null;
+ if (/out\s*of\s*stock|sold\s*out|unavailable|OutOfStock|discontinued/i.test(hay)) in_stock = false;
+ else if (/in\s*stock|in-?stock|InStock|add to (cart|basket|bag)|available/i.test(hay)) in_stock = true;
+
+ const label = availTxt ? availTxt.slice(0, 60) : (in_stock === true ? 'in stock' : in_stock === false ? 'out of stock' : 'unknown');
+
+ return {
+ available: true, session_opened: true, vendor: 'Romo',
+ in_stock, lead_time: null, price: null, // Romo portal price is net/cost → never emitted
+ raw_stock_label: label, as_of: new Date().toISOString(), source: 'romo-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/adapters/shopify-public.js b/scripts/adapters/shopify-public.js
new file mode 100644
index 0000000..e087348
--- /dev/null
+++ b/scripts/adapters/shopify-public.js
@@ -0,0 +1,83 @@
+// 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 { Pool } = require('pg');
+
+// which of {dw_sku, mfr_sku, handle} a given catalog table actually carries
+async function catCols(pool, table) {
+ const { rows } = await pool.query(
+ `SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name=$1`, [table]);
+ return new Set(rows.map((r) => r.column_name));
+}
+
+async function run({ sku, pool, newSession, catalogTable, log }) {
+ let sessionOpened = false, browser = null, session = null, bb = null;
+ try {
+ const table = catalogTable;
+ if (!table || !/^[a-z0-9_]+$/.test(table)) return { available: false, reason: 'no catalog table for this vendor' };
+ const cols = await catCols(pool, table);
+ if (!cols.has('product_url')) return { available: false, reason: 'catalog has no product_url' };
+
+ // Resolve the SKU → its Shopify product URL (+ mfr_sku for variant matching).
+ const wh = [];
+ if (cols.has('dw_sku')) wh.push('upper(dw_sku)=upper($1)');
+ if (cols.has('mfr_sku')) wh.push('upper(mfr_sku)=upper($1)');
+ if (!wh.length) return { available: false, reason: 'catalog has no matchable sku column' };
+ const sel = ['product_url', cols.has('mfr_sku') ? 'mfr_sku' : "'' AS mfr_sku", cols.has('handle') ? 'handle' : "'' AS handle"].join(', ');
+ const { rows } = await pool.query(
+ `SELECT ${sel} FROM "${table}" WHERE (${wh.join(' OR ')}) AND product_url IS NOT NULL AND product_url<>'' LIMIT 1`, [sku]);
+ if (!rows.length) return { available: false, reason: 'SKU not found in this vendor catalog' };
+ const { product_url, mfr_sku } = rows[0];
+ 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 {
+ 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/adapters/wallquest.js b/scripts/adapters/wallquest.js
new file mode 100644
index 0000000..df1f628
--- /dev/null
+++ b/scripts/adapters/wallquest.js
@@ -0,0 +1,89 @@
+// adapters/wallquest.js — WallQuest trade-portal live-stock adapter (PORTAL+CREDS).
+//
+// Extracted UNCHANGED from the original monolithic live-scrape.js. Backs the display labels
+// "Malibu Wallpaper" / "PS Removable Wallpaper" / "Malibu Walls" / "Malibu" (WallQuest's real
+// name is never customer-facing). Documented mechanics from wallquest-scraper-manager: #Email +
+// #Password, submit via Enter (button click hangs); price AJAX lazy-loads ~9s; price-value = OUR
+// COST → transformed to our-retail (cost/0.65/0.85) and the raw cost is NEVER emitted.
+const WQ_LOGIN = 'https://www.wallquest.com/login';
+const round2 = (n) => Math.round(n * 100) / 100;
+
+async function run({ sku, pool, newSession, log }) {
+ let sessionOpened = false, browser = null, session = null, bb = null;
+ try {
+ // 1. Resolve the SKU in the WallQuest catalog (the private-label backing line).
+ const { rows } = await pool.query(
+ `SELECT dw_sku, mfr_sku, product_url, price_retail, in_stock, discontinued, pattern_name, color_name
+ FROM wallquest_catalog
+ WHERE upper(dw_sku)=upper($1) OR upper(mfr_sku)=upper($1)
+ LIMIT 1`, [sku]);
+ if (!rows.length) return { available: false, reason: 'not a WallQuest/Malibu catalog SKU' };
+ const row = rows[0];
+ const url = row.product_url || '';
+ const lastSeg = url.replace(/\/+$/, '').split('/').pop() || '';
+ if (!url || /^WQ-/i.test(lastSeg) || /^\d+$/.test(lastSeg)) {
+ return { available: false, reason: 'variant-gated SKU — no single live price on the portal' };
+ }
+
+ // 2. WallQuest trade creds (admin-only; private-label real name never surfaces publicly).
+ const { rows: creds } = await pool.query(
+ `SELECT trade_username, trade_password FROM vendor_registry
+ WHERE vendor_name ILIKE 'wallquest' OR vendor_code ILIKE 'wallquest' LIMIT 1`);
+ const user = (creds[0] && creds[0].trade_username) || 'info@designerwallcoverings.com';
+ const pass = creds[0] && creds[0].trade_password;
+ if (!pass) return { available: false, reason: 'wallquest trade password not configured' };
+
+ // 3. Open a Browserbase session (billable) + login.
+ const sess = await newSession();
+ ({ browser, session, bb } = sess); const page = sess.page;
+ sessionOpened = true;
+ log('bb session', session && session.id);
+
+ await page.goto(WQ_LOGIN, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(2500);
+ const emailEl = await page.$('#Email').catch(() => null);
+ if (emailEl) {
+ await page.fill('#Email', user).catch(() => {});
+ await page.fill('#Password', pass).catch(() => {});
+ await page.waitForTimeout(400);
+ await page.press('#Password', 'Enter').catch(() => {});
+ for (let i = 0; i < 18; i++) { await page.waitForTimeout(1000); if (!/\/login/i.test(page.url())) break; }
+ }
+
+ // 4. Product page → wait ~9s for the AJAX price, then read price-value + availability.
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(9500);
+
+ const priceTxt = await page.$$eval('[class*=price-value]', (els) => els.map((e) => e.textContent).join(' ')).catch(() => '');
+ const m = (priceTxt || '').match(/[\d,]+\.\d{2}/);
+ const cost = m ? parseFloat(m[0].replace(/,/g, '')) : null; // OUR COST — never emitted
+ const price = cost && cost > 0 ? round2(cost / 0.65 / 0.85) : null; // PUBLIC our-retail
+
+ const availTxt = await page.$$eval(
+ '[class*=stock], [class*=availab], [itemprop=availability]',
+ (els) => els.map((e) => (e.getAttribute('content') || e.textContent || '').trim()).filter(Boolean).join(' | ')
+ ).catch(() => '');
+ let in_stock = row.in_stock;
+ if (/out\s*of\s*stock|sold\s*out|unavailable|InStock:\s*false/i.test(availTxt)) in_stock = false;
+ else if (/in\s*stock|in-?stock|available|InStock/i.test(availTxt)) in_stock = true;
+ if (row.discontinued) in_stock = false;
+
+ const label = row.discontinued ? 'discontinued'
+ : (availTxt ? availTxt.slice(0, 60) : (in_stock ? 'in stock' : (in_stock === false ? 'out of stock' : 'unknown')));
+
+ return {
+ available: true, session_opened: true, vendor: 'Malibu Wallpaper',
+ in_stock: in_stock == null ? null : !!in_stock,
+ lead_time: null, price,
+ raw_stock_label: label,
+ as_of: new Date().toISOString(), source: 'wallquest-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/adapters/woo-public.js b/scripts/adapters/woo-public.js
new file mode 100644
index 0000000..1e33d69
--- /dev/null
+++ b/scripts/adapters/woo-public.js
@@ -0,0 +1,57 @@
+// adapters/woo-public.js — PUBLIC-STOCK adapter for WooCommerce storefronts (WIRED, but the
+// coverage file keeps these vendors DIMMED — live_capable:false — until a per-site verification
+// confirms the WooCommerce Store API is exposed. Kept here so enabling is a one-flag change.)
+//
+// Reads the WooCommerce Store API (`/wp-json/wc/store/products?slug=<slug>`) in-page via the same
+// Browserbase mechanism; `stock_status` = 'instock' | 'outofstock' | 'onbackorder'.
+// PUBLIC-SAFE: availability only; price:null (vendor DTC, not our retail).
+async function run({ sku, pool, newSession, catalogTable, log }) {
+ let sessionOpened = false, browser = null, session = null, bb = null;
+ try {
+ const table = catalogTable;
+ if (!table || !/^[a-z0-9_]+$/.test(table)) return { available: false, reason: 'no catalog table for this vendor' };
+ const { rows: colRows } = await pool.query(
+ `SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name=$1`, [table]);
+ const cols = new Set(colRows.map((r) => r.column_name));
+ if (!cols.has('product_url')) return { available: false, reason: 'catalog has no product_url' };
+ const wh = [];
+ if (cols.has('dw_sku')) wh.push('upper(dw_sku)=upper($1)');
+ if (cols.has('mfr_sku')) wh.push('upper(mfr_sku)=upper($1)');
+ if (!wh.length) return { available: false, reason: 'catalog has no matchable sku column' };
+ const { rows } = await pool.query(
+ `SELECT product_url FROM "${table}" WHERE (${wh.join(' OR ')}) AND product_url IS NOT NULL AND product_url<>'' LIMIT 1`, [sku]);
+ if (!rows.length) return { available: false, reason: 'SKU not found in this vendor catalog' };
+
+ const url = rows[0].product_url;
+ const m = /^https?:\/\/([^/]+)\/product\/([^/?#]+)/i.exec(url);
+ if (!m) return { available: false, reason: 'not a WooCommerce product URL' };
+ const [, host, slug] = m;
+ const api = `https://${host}/wp-json/wc/store/products?slug=${encodeURIComponent(slug)}`;
+
+ const sess = await newSession();
+ ({ browser, session, bb } = sess); const page = sess.page;
+ sessionOpened = true;
+ log('bb session', session && session.id, '→', api);
+
+ await page.goto(api, { waitUntil: 'domcontentloaded', timeout: 45000 });
+ const txt = await page.evaluate(() => document.body && document.body.innerText).catch(() => null);
+ let data = null; try { data = JSON.parse(txt); } catch { data = null; }
+ const item = Array.isArray(data) ? data[0] : (data && data.id ? data : null);
+ if (!item) return { available: false, session_opened: true, reason: 'WooCommerce Store API not exposed / no match' };
+
+ const st = String(item.stock_status || '').toLowerCase();
+ const in_stock = st === 'instock' ? true : st === 'outofstock' ? false : (st === 'onbackorder' ? false : null);
+ return {
+ available: true, session_opened: true, vendor: null,
+ in_stock, lead_time: st === 'onbackorder' ? 'backorder' : null, price: null,
+ raw_stock_label: st || 'unknown', as_of: new Date().toISOString(), source: 'woo-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/live-scrape.js b/scripts/live-scrape.js
index 6278068..69aaf8a 100644
--- a/scripts/live-scrape.js
+++ b/scripts/live-scrape.js
@@ -1,126 +1,84 @@
#!/usr/bin/env node
// live-scrape.js — the METERED /livestock --live worker for all.designerwallcoverings.com.
//
-// Reuses the EXISTING vendor scraper layer (Browserbase session helper from the
-// browserbase skill's romo-lib) + the DOCUMENTED WallQuest portal login mechanics
-// (wallquest-scraper-manager skill) — it does NOT reinvent portal login. Spawned by
-// server.js on an explicit "Check Live" click for ONE SKU and prints ONE JSON line to
-// stdout; all diagnostics go to stderr.
+// Now a thin DISPATCHER over the generalized per-vendor adapter registry (scripts/adapters/).
+// Spawned by server.js on an explicit "Check Live" click for ONE SKU; the server passes the
+// resolved adapter key (from data/live-stock-coverage.json). Prints ONE JSON line to stdout;
+// all diagnostics go to stderr. Each adapter owns its own login (creds read from the DB — never
+// hardcoded/echoed) and returns PUBLIC-SAFE availability (+ our-retail price only where defined).
//
-// node scripts/live-scrape.js <dw_sku|mfr_sku>
+// node scripts/live-scrape.js <dw_sku|mfr_sku> [adapterKey]
//
-// Output (stdout, single line):
-// { available:true, vendor, in_stock, lead_time, price, raw_stock_label, as_of, source, session_opened }
-// { available:false, reason, session_opened }
-//
-// PUBLIC-SAFE: only availability + our RETAIL price ever leave here. The WallQuest
-// price-value is OUR COST (price_retail=cost) — it is transformed to our_retail
-// (cost/0.65/0.85) before output and the raw cost is NEVER emitted.
+// If adapterKey is omitted (standalone CLI use), the worker resolves it from the SKU's display
+// vendor via the committed coverage file — so `node scripts/live-scrape.js <sku>` still works.
const fs = require('fs');
const os = require('os');
const path = require('path');
const { Pool } = require('pg');
+const adapters = require('./adapters');
const SKU = (process.argv[2] || '').trim();
+let ADAPTER_KEY = (process.argv[3] || '').trim();
+const CATALOG_ARG = (process.argv[4] || '').trim(); // server passes the coverage catalog_table for public-stock adapters
const DSN = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
-// Browserbase session helper. On the PUBLIC host it's the self-contained copy in
-// this project's lib/ (installed by the backend deploy); on Mac2 dev it can also
-// fall back to the browserbase skill. Explicit BROWSERBASE_LIB env always wins.
+const COVERAGE = path.join(__dirname, '..', 'data', 'live-stock-coverage.json');
+
+// Browserbase session helper — the self-contained project copy on prod, or the skill copy on dev.
const LOCAL_LIB = path.join(__dirname, '..', 'lib', 'romo-lib.js');
const BB_LIB = process.env.BROWSERBASE_LIB
|| (fs.existsSync(LOCAL_LIB) ? LOCAL_LIB : path.join(os.homedir(), '.claude', 'skills', 'browserbase', 'romo-lib.js'));
-const WQ_LOGIN = 'https://www.wallquest.com/login';
const emit = (o) => process.stdout.write(JSON.stringify(o) + '\n');
const log = (...a) => process.stderr.write(a.join(' ') + '\n');
-const round2 = (n) => Math.round(n * 100) / 100;
+
+// Resolve the SKU → display vendor → adapter (+ its catalog table) from the coverage file.
+async function resolveAdapter(pool, sku) {
+ let vendor = null;
+ try {
+ const { rows } = await pool.query(
+ `SELECT vendor FROM shopify_products
+ WHERE upper(coalesce(dw_sku,''))=upper($1) OR upper(coalesce(variant_sku,''))=upper($1) OR upper(coalesce(sku,''))=upper($1)
+ LIMIT 1`, [sku]);
+ vendor = rows[0] && rows[0].vendor;
+ } catch { /* ignore */ }
+ if (!vendor) return { key: null, catalogTable: null, vendor: null };
+ try {
+ const cov = JSON.parse(fs.readFileSync(COVERAGE, 'utf8'));
+ const e = cov.vendors[vendor];
+ if (e && e.live_capable && e.adapter) return { key: e.adapter, catalogTable: e.catalog_table || null, vendor };
+ } catch { /* coverage optional */ }
+ return { key: null, catalogTable: null, vendor };
+}
+
+// The catalog table an adapter should read for this SKU (server passes argv[4]; else per-key default).
+function catalogForKey(key) {
+ if (CATALOG_ARG && /^[a-z0-9_]+$/.test(CATALOG_ARG)) return CATALOG_ARG;
+ if (key === 'wallquest') return 'wallquest_catalog';
+ if (key === 'romo') return 'romo_catalog';
+ return null;
+}
(async () => {
if (!SKU) return emit({ available: false, reason: 'sku required' });
-
const pool = new Pool({ connectionString: DSN, max: 1 });
- let sessionOpened = false, browser = null, session = null, bb = null;
try {
- // 1. Resolve the SKU in the WallQuest catalog (the private-label backing line).
- const { rows } = await pool.query(
- `SELECT dw_sku, mfr_sku, product_url, price_retail, in_stock, discontinued, pattern_name, color_name
- FROM wallquest_catalog
- WHERE upper(dw_sku)=upper($1) OR upper(mfr_sku)=upper($1)
- LIMIT 1`, [SKU]);
- if (!rows.length) return emit({ available: false, reason: 'not a WallQuest/Malibu catalog SKU' });
- const row = rows[0];
- const url = row.product_url || '';
- // Variant-gated (numeric-only or WQ- collection slug) URLs have no single live price/stock.
- const lastSeg = url.replace(/\/+$/, '').split('/').pop() || '';
- if (!url || /^WQ-/i.test(lastSeg) || /^\d+$/.test(lastSeg)) {
- return emit({ available: false, reason: 'variant-gated SKU — no single live price on the portal' });
+ let catalogTable = catalogForKey(ADAPTER_KEY);
+ // No adapter key given → standalone resolve from the SKU's vendor.
+ if (!ADAPTER_KEY) {
+ const r = await resolveAdapter(pool, SKU);
+ ADAPTER_KEY = r.key; catalogTable = r.catalogTable || catalogTable;
+ if (!ADAPTER_KEY) return emit({ available: false, reason: 'live check unavailable for this vendor' });
}
+ // Load the Browserbase helpers (present only where the backend is installed).
+ let newSession, romoLogin;
+ try { const lib = require(BB_LIB); newSession = lib.newSession; romoLogin = lib.login; }
+ catch { return emit({ available: false, reason: 'live scrape backend not available on this host' }); }
- // 2. Load the shared Browserbase session helper (present only where the skill is installed).
- let newSession;
- try { ({ newSession } = require(BB_LIB)); }
- catch (e) { return emit({ available: false, reason: 'live scrape backend not available on this host' }); }
-
- // 3. WallQuest trade creds (admin-only; private-label real name never surfaces publicly).
- const { rows: creds } = await pool.query(
- `SELECT trade_username, trade_password FROM vendor_registry
- WHERE vendor_name ILIKE 'wallquest' OR vendor_code ILIKE 'wallquest' LIMIT 1`);
- const user = (creds[0] && creds[0].trade_username) || 'info@designerwallcoverings.com';
- const pass = creds[0] && creds[0].trade_password;
- if (!pass) return emit({ available: false, reason: 'wallquest trade password not configured' });
-
- // 4. Open a Browserbase session (this is the billable action) + login (documented mechanics:
- // #Email + #Password, submit via Enter — button click hangs intermittently; CF cleared natively).
- const sess = await newSession();
- ({ browser, session, bb } = sess); const page = sess.page;
- sessionOpened = true;
- log('bb session', session && session.id);
-
- await page.goto(WQ_LOGIN, { waitUntil: 'domcontentloaded' });
- await page.waitForTimeout(2500);
- const emailEl = await page.$('#Email').catch(() => null);
- if (emailEl) {
- await page.fill('#Email', user).catch(() => {});
- await page.fill('#Password', pass).catch(() => {});
- await page.waitForTimeout(400);
- await page.press('#Password', 'Enter').catch(() => {});
- // wait for the login POST to settle (redirect off /login)
- for (let i = 0; i < 18; i++) { await page.waitForTimeout(1000); if (!/\/login/i.test(page.url())) break; }
- } // else: already authenticated on this session
-
- // 5. Product page → wait ~9s for the AJAX price, then read price-value + availability.
- await page.goto(url, { waitUntil: 'domcontentloaded' });
- await page.waitForTimeout(9500);
-
- const priceTxt = await page.$$eval('[class*=price-value]', (els) => els.map((e) => e.textContent).join(' ')).catch(() => '');
- const m = (priceTxt || '').match(/[\d,]+\.\d{2}/);
- const cost = m ? parseFloat(m[0].replace(/,/g, '')) : null; // OUR COST — never emitted
- const price = cost && cost > 0 ? round2(cost / 0.65 / 0.85) : null; // PUBLIC our-retail
-
- const availTxt = await page.$$eval(
- '[class*=stock], [class*=availab], [itemprop=availability]',
- (els) => els.map((e) => (e.getAttribute('content') || e.textContent || '').trim()).filter(Boolean).join(' | ')
- ).catch(() => '');
- let in_stock = row.in_stock; // catalog fallback
- if (/out\s*of\s*stock|sold\s*out|unavailable|InStock:\s*false/i.test(availTxt)) in_stock = false;
- else if (/in\s*stock|in-?stock|available|InStock/i.test(availTxt)) in_stock = true;
- if (row.discontinued) in_stock = false;
-
- const label = row.discontinued ? 'discontinued'
- : (availTxt ? availTxt.slice(0, 60) : (in_stock ? 'in stock' : (in_stock === false ? 'out of stock' : 'unknown')));
-
- return emit({
- available: true, session_opened: true, vendor: 'Malibu Wallpaper',
- in_stock: in_stock == null ? null : !!in_stock,
- lead_time: null, price,
- raw_stock_label: label,
- as_of: new Date().toISOString(), source: 'wallquest-live',
- });
+ const result = await adapters.run(ADAPTER_KEY, { sku: SKU, pool, newSession, romoLogin, catalogTable, log });
+ return emit(result);
} catch (e) {
- return emit({ available: false, session_opened: sessionOpened, reason: 'live scrape error: ' + (e.message || e) });
+ return emit({ available: false, 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 {}
try { await pool.end(); } catch {}
setTimeout(() => process.exit(0), 200);
}
diff --git a/server.js b/server.js
index 0386192..86530d9 100644
--- a/server.js
+++ b/server.js
@@ -71,12 +71,25 @@ const slugify = (v) => String(v || '').toLowerCase().normalize('NFD')
const BANNED = /brewster|york|wallquest|wall\s*quest|newwall|new\s*wall\b|command\s*54|justin\s*david|nicolette\s*mayer|seabrook|chesapeake|nextwall|desima|carlsten/i;
// ── LIVE stock (metered vendor-portal scrape) — the paid /livestock --live path ────
-// Distinct from the FREE stored STOCK snapshot. SELF-GATE: only display-vendors backed by
-// a wired portal adapter can fire live. WallQuest surfaces here privacy-scrubbed as
-// "Malibu Wallpaper" / "PS Removable Wallpaper" — both back onto the WallQuest portal.
-// A vendor with no adapter → the card's "Check Live" control dims and never fires.
-const LIVE_ADAPTERS = { 'malibu wallpaper': 'wallquest', 'ps removable wallpaper': 'wallquest' };
-const liveAdapter = (vendor) => LIVE_ADAPTERS[String(vendor || '').trim().toLowerCase()] || null;
+// Distinct from the FREE stored STOCK snapshot. SELF-GATE: a display-vendor can fire live ONLY
+// when data/live-stock-coverage.json classifies it live_capable with a wired adapter. That file
+// (built by scripts/build-coverage.js) is the single source of truth for enable/dim AND per-tier
+// dim reason — no vendor is hardcoded here. Adapters: wallquest (WallQuest-family portal, surfaced
+// as "Malibu Wallpaper"/"PS Removable Wallpaper"/…), romo (Romo trade portal), shopify-public
+// (public Shopify .js stock), woo-public (wired, kept dim until per-site verified).
+const COVERAGE_FILE = path.join(__dirname, 'data', 'live-stock-coverage.json');
+let COVERAGE = new Map(); // lower(vendor) → coverage entry
+function loadCoverage() {
+ try {
+ const d = JSON.parse(fs.readFileSync(COVERAGE_FILE, 'utf8'));
+ const m = new Map();
+ for (const [vendor, e] of Object.entries(d.vendors || {})) m.set(String(vendor).toLowerCase(), e);
+ COVERAGE = m;
+ console.log(`coverage: ${m.size} display-vendors (live-capable ${(d.counts && d.counts.live_capable) || 0})`);
+ } catch (e) { console.error('coverage load failed (non-fatal — live checks dim):', e.message); }
+}
+const coverageFor = (vendor) => COVERAGE.get(String(vendor || '').trim().toLowerCase()) || null;
+const liveAdapter = (vendor) => { const e = coverageFor(vendor); return (e && e.live_capable && e.adapter) ? e.adapter : null; };
const LIVE_WORKER = path.join(__dirname, 'scripts', 'live-scrape.js');
const LIVE_CACHE_MS = 10 * 60 * 1000; // repeat clicks within 10 min = cached, $0
const LIVE_MAX_INFLIGHT = 2; // global burst cap on concurrent portal scrapes
@@ -250,9 +263,12 @@ function deriveRow(r) {
return (entry && entry.handles.has(r.handle)) ? `${entry.url}product/${encodeURIComponent(r.handle)}` : null;
})(),
stock: (sku && STOCK.get(String(sku).toUpperCase())) || null,
- // live_capable = this SKU's display-vendor has a wired portal adapter (WallQuest-backed).
- // Drives the card's "Check Live 🔄" enable/dim; the endpoint self-gates again server-side.
+ // live_capable + live_tier + live_reason come from the committed coverage audit. They drive the
+ // card's "Check Live 🔄" enable/dim AND the per-tier dim reason (no-creds vs quote-only vs no-stock).
+ // The endpoint self-gates again server-side against the same coverage entry.
live_capable: !!liveAdapter(r.vendor),
+ live_tier: (coverageFor(r.vendor) || {}).tier || null,
+ live_reason: (coverageFor(r.vendor) || {}).reason || 'live check unavailable for this vendor',
created: r.created_at_shopify ? new Date(r.created_at_shopify).getTime() : 0,
updated: r.updated_at_shopify ? new Date(r.updated_at_shopify).getTime() : 0,
// one lowercase haystack per row so search is a single .includes() pass
@@ -266,6 +282,7 @@ async function loadSnapshot() {
// Resolve the two side-channels BEFORE mapping rows (both non-fatal — a slow/empty
// stock or viewer map just dims those controls, it never takes the grid down).
buildVendorViewer();
+ loadCoverage();
try { await loadStock(); } catch (e) { console.error('stock load failed (non-fatal):', e.message); }
const res = await pool.query(await snapSql());
ROWS = res.rows
@@ -373,12 +390,13 @@ function logBrowserbase(min, sku) {
} catch { /* ledger is best-effort — never block the response */ }
}
-// Spawn the live-scrape worker for ONE SKU; measure the session, estimate + LOG the
-// Browserbase cost, and return a PUBLIC-SAFE result. Never throws.
-function runLiveScrape(sku) {
+// Spawn the live-scrape worker for ONE SKU via its resolved adapter; measure the session,
+// estimate + LOG the Browserbase cost, and return a PUBLIC-SAFE result. Never throws.
+function runLiveScrape(sku, adapterKey, catalogTable) {
return new Promise((resolve) => {
const t0 = Date.now();
- execFile('node', [LIVE_WORKER, sku], { timeout: LIVE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, env: process.env },
+ const args = [LIVE_WORKER, sku, adapterKey || '', catalogTable || ''];
+ execFile('node', args, { timeout: LIVE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, env: process.env },
(err, stdout) => {
const elapsedMin = Math.max(0.2, (Date.now() - t0) / 60000);
let parsed = null;
@@ -427,11 +445,13 @@ async function handleLiveStock(req, res, u) {
});
}
- // ── LIVE (metered) path ──
- const adapter = liveAdapter(row ? row.vendor : null);
+ // ── LIVE (metered) path — self-gate against the coverage audit (same source as the UI dim) ──
+ const cov = coverageFor(row ? row.vendor : null);
+ const adapter = (cov && cov.live_capable && cov.adapter) ? cov.adapter : null;
if (!adapter) {
return sendJSON(res, 200, { ok: false, live: true, live_available: false, sku,
- vendor: row ? row.vendor : null, reason: 'live check unavailable for this vendor', cost_usd: 0 });
+ vendor: row ? row.vendor : null, tier: cov ? cov.tier : null,
+ reason: (cov && cov.reason) || 'live check unavailable for this vendor', cost_usd: 0 });
}
const key = sku.toUpperCase();
@@ -460,7 +480,7 @@ async function handleLiveStock(req, res, u) {
}
hits.push(now); liveIpHits.set(ip, hits);
- const p = runLiveScrape(sku).finally(() => liveInflight.delete(key));
+ const p = runLiveScrape(sku, adapter, cov.catalog_table).finally(() => liveInflight.delete(key));
liveInflight.set(key, p);
let result;
try { result = await p; } catch (e) { return sendJSON(res, 200, { ok: false, live: true, live_available: true, sku, reason: 'live scrape error: ' + e.message, cost_usd: 0 }); }
← 93e37e2 live-stock coverage audit: classify all 254 display-vendors
·
back to All Designerwallcoverings
·
coverage: resolvability gate — only ENABLE live-capable vend 803861a →