[object Object]

← 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

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 →