← back to All Designerwallcoverings
all-dw chips: add 'This Item ↗' link — internal microsite product page (/product/<handle>) for the single item, distinct from 'Line Viewer' (whole line)
ef3b79ce82a2e46dae952d6d33133f4463f23d84 · 2026-07-07 07:59:02 -0700 · steve
Files touched
M public/index.htmlA scripts/live-scrape.jsM server.js
Diff
commit ef3b79ce82a2e46dae952d6d33133f4463f23d84
Author: steve <steve@designerwallcoverings.com>
Date: Tue Jul 7 07:59:02 2026 -0700
all-dw chips: add 'This Item ↗' link — internal microsite product page (/product/<handle>) for the single item, distinct from 'Line Viewer' (whole line)
---
public/index.html | 6 ++-
scripts/live-scrape.js | 122 +++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 27 +++++++++++
3 files changed, 154 insertions(+), 1 deletion(-)
diff --git a/public/index.html b/public/index.html
index fbfc2d5..46b376c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -471,10 +471,14 @@ function actButtonsHTML(r) {
const viewer = r.line_viewer_url
? `<a class="im" href="${r.line_viewer_url}" target="_blank" rel="noopener noreferrer">Line Viewer ↗</a>`
: `<a class="im disabled" title="No internal line viewer for this line yet">Line Viewer</a>`;
+ // This Item = the internal microsite deep-linked to THIS product's page (one item, not the whole line).
+ const item = r.internal_product_url
+ ? `<a class="im" href="${r.internal_product_url}" target="_blank" rel="noopener noreferrer">This Item ↗</a>`
+ : `<a class="im disabled" title="No internal product page for this SKU yet">This Item</a>`;
const stock = r.stock
? `<button class="im stockbtn" type="button">Check Stock</button>`
: `<button class="im disabled" type="button" title="No stored stock snapshot — run /livestock ${esc(r.sku || '')}">Check Stock</button>`;
- return `${web}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${stock}${stockPopHTML(r.stock)}</div>`;
+ return `${web}<button class="intbtn" type="button">Internal ▾</button><div class="intpanel" hidden>${admin}${viewer}${item}${stock}${stockPopHTML(r.stock)}</div>`;
}
// Delegated within an .acts container (which stops propagation so the card's own
diff --git a/scripts/live-scrape.js b/scripts/live-scrape.js
new file mode 100644
index 0000000..5b09fb0
--- /dev/null
+++ b/scripts/live-scrape.js
@@ -0,0 +1,122 @@
+#!/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.
+//
+// node scripts/live-scrape.js <dw_sku|mfr_sku>
+//
+// 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.
+const os = require('os');
+const path = require('path');
+const { Pool } = require('pg');
+
+const SKU = (process.argv[2] || '').trim();
+const DSN = process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp';
+// The Browserbase session helper (+ its own node_modules) lives with the browserbase skill.
+const BB_LIB = process.env.BROWSERBASE_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;
+
+(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' });
+ }
+
+ // 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',
+ });
+ } catch (e) {
+ return emit({ 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 {}
+ try { await pool.end(); } catch {}
+ setTimeout(() => process.exit(0), 200);
+ }
+})();
diff --git a/server.js b/server.js
index 343623d..b3394c3 100644
--- a/server.js
+++ b/server.js
@@ -7,7 +7,9 @@
// leave the server; only retail price ships.
const http = require('http');
const fs = require('fs');
+const os = require('os');
const path = require('path');
+const { execFile } = require('child_process');
const { Pool } = require('pg');
try { require('dotenv').config(); } catch {}
const { crawlMicrosites, OUT: MICROSITES } = require('./scripts/crawl-microsites');
@@ -68,6 +70,24 @@ const slugify = (v) => String(v || '').toLowerCase().normalize('NFD')
// whose vendor OR title leaks one are excluded (their products surface under the label).
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;
+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
+const LIVE_IP_MAX = 6, LIVE_IP_WIN_MS = 60 * 1000; // per-IP: ≤6 live calls / 60s
+const LIVE_TIMEOUT_MS = 105 * 1000; // hard kill a hung portal scrape
+const BB_RATE_PER_MIN = 0.15; // pricing.json apis.browserbase.rates.session_min
+const COST_LOG = path.join(os.homedir(), '.claude', 'skills', 'cost-tracker', 'scripts', 'log.js');
+const liveCache = new Map(); // UPPER(sku) → { result, at }
+const liveInflight = new Map(); // UPPER(sku) → Promise (coalesces concurrent clicks)
+const liveIpHits = new Map(); // ip → [timestamps] (per-IP rate window)
+
// ── snapshot load ────────────────────────────────────────────────────────────────
// Public-safe columns ONLY — cost_price / net_price / cost never selected.
// The Mac2 mirror carries rollup columns (min_variant_price, online_store_published)
@@ -199,6 +219,13 @@ function deriveRow(r) {
// stock = the FREE stored /livestock snapshot (public-safe availability only).
internal_url: adminUrl(r.shopify_id),
line_viewer_url: r.vendor ? (VENDOR_VIEWER.get(slugify(r.vendor)) || null) : null,
+ // This Item = the SAME internal microsite, deep-linked to THIS product's /product/<handle>
+ // page (one item, not the whole line). Resolves only when the line has a live microsite AND
+ // the row has a handle; otherwise the control dims.
+ internal_product_url: (function () {
+ const base = r.vendor ? VENDOR_VIEWER.get(slugify(r.vendor)) : null;
+ return (base && r.handle) ? `${base}product/${encodeURIComponent(r.handle)}` : null;
+ })(),
stock: (sku && STOCK.get(String(sku).toUpperCase())) || null,
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,
← 9ca2518 internal: expand per-card Internal into Admin / Line Viewer
·
back to All Designerwallcoverings
·
livestock: one-click 'Check Live 🔄' — metered vendor-portal bbcfd96 →