[object Object]

← back to Sku Check Skill

pills app: parse yorkwall trade price + stock, show Retail/Net/Stock boxes

a55b4346a19e69fe9a18ae39bed8369962747e18 · 2026-07-10 08:14:19 -0700 · Steve

- parseYorkwall(bodyText): MSRP (retail), NET PRICE (trade cost + discount%),
  Quantity Available (summed batches), roll width/length — scoped by section
  header since values are unlabeled table cells.
- Attach parsed to live + lastLive; robust JSON extraction from scraper stdout.
- pills.html parsedBoxes() renders Retail/Net/Stock. Verified live: 437-RD336
  = MSRP $40, Net $20 (50% off), 412 single rolls in stock.

Files touched

Diff

commit a55b4346a19e69fe9a18ae39bed8369962747e18
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 10 08:14:19 2026 -0700

    pills app: parse yorkwall trade price + stock, show Retail/Net/Stock boxes
    
    - parseYorkwall(bodyText): MSRP (retail), NET PRICE (trade cost + discount%),
      Quantity Available (summed batches), roll width/length — scoped by section
      header since values are unlabeled table cells.
    - Attach parsed to live + lastLive; robust JSON extraction from scraper stdout.
    - pills.html parsedBoxes() renders Retail/Net/Stock. Verified live: 437-RD336
      = MSRP $40, Net $20 (50% off), 412 single rolls in stock.
---
 public/pills.html | 26 +++++++++++++++++++++++---
 server.js         | 32 +++++++++++++++++++++++++++++++-
 2 files changed, 54 insertions(+), 4 deletions(-)

diff --git a/public/pills.html b/public/pills.html
index f933bcd..19534f5 100644
--- a/public/pills.html
+++ b/public/pills.html
@@ -114,6 +114,23 @@
   const esc = s => String(s ?? '').replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
   const money = v => (v==null||v==='') ? '—' : (isNaN(+v) ? esc(v) : '$'+(+v).toFixed(2));
 
+  // Structured trade price + stock from the portal parse.
+  function parsedBoxes(P){
+    if(!P) return '';
+    let h='<div class="price-row">';
+    if(P.msrp!=null) h+='<div class="price-box"><div class="lbl">Retail (MSRP)</div><div class="val">'+money(P.msrp)+'</div><div class="lbl">'+esc(P.unit||'')+'</div></div>';
+    if(P.netPrice!=null) h+='<div class="price-box"><div class="lbl">Net / Trade'+(P.discountPct!=null?' ('+P.discountPct+'% off)':'')+'</div><div class="val">'+money(P.netPrice)+'</div><div class="lbl">'+esc(P.netUnit||'')+'</div></div>';
+    if(P.quantityAvailable!=null) h+='<div class="price-box"><div class="lbl">In Stock</div><div class="val">'+P.quantityAvailable+'</div><div class="lbl">'+esc(P.netUnit||P.unit||'rolls')+'</div></div>';
+    h+='</div>';
+    if(P.rollWidth||P.rollLength){
+      h+='<div class="kv" style="margin-top:8px">';
+      if(P.rollWidth) h+='<span class="chip"><b>Width</b> '+esc(P.rollWidth)+'</span>';
+      if(P.rollLength) h+='<span class="chip"><b>Length</b> '+esc(P.rollLength)+'</span>';
+      h+='</div>';
+    }
+    return h;
+  }
+
   async function run() {
     const query = $('q').value.trim();
     if (!query) { $('q').focus(); return; }
@@ -166,9 +183,10 @@
       const L = d.lastLive;
       const age = L.ageMin < 60 ? L.ageMin+'m ago' : Math.round(L.ageMin/60)+'h ago';
       h += '<div class="sect"><h3>Last portal pull · '+age+'</h3>';
-      if (L.loggedIn && L.priceStock && L.priceStock.length) {
+      if (L.loggedIn && (L.parsed || (L.priceStock && L.priceStock.length))) {
         h += '<div class="banner ok">Saved from the trade portal at '+esc(new Date(L.at).toLocaleString())+'</div>';
-        h += '<ul class="live-list">'+ L.priceStock.map(x=>'<li>'+esc(x)+'</li>').join('') +'</ul>';
+        if (L.parsed) h += parsedBoxes(L.parsed);
+        else h += '<ul class="live-list">'+ L.priceStock.map(x=>'<li>'+esc(x)+'</li>').join('') +'</ul>';
       } else if (L.locked) {
         h += '<div class="banner bad">Last attempt hit a portal lockout ('+esc(new Date(L.at).toLocaleString())+').</div>';
       } else {
@@ -187,7 +205,9 @@
         h += '<div class="banner bad">'+esc(L.message||L.error)+'</div>';
       } else if (L.loggedIn) {
         h += '<div class="banner ok">'+esc(L.message)+'</div>';
-        if (L.priceStock && L.priceStock.length) {
+        if (L.parsed) {
+          h += parsedBoxes(L.parsed);
+        } else if (L.priceStock && L.priceStock.length) {
           h += '<ul class="live-list">'+ L.priceStock.map(x=>'<li>'+esc(x)+'</li>').join('') +'</ul>';
         } else {
           h += '<p class="sub" style="margin:8px 0 0">Logged in, but no price/stock text captured on the product page.</p>';
diff --git a/server.js b/server.js
index d36b870..efb9c3b 100644
--- a/server.js
+++ b/server.js
@@ -1194,6 +1194,29 @@ const LIVE_LOCKOUT_MS = 30 * 60 * 1000; // honor the portal's 30-min lockout
 // Cache dir where the overnight job (and app-triggered pulls) drop portal results.
 const YW_RESULTS_DIR = path.join(path.dirname(YW_SCRIPT), 'yw-results');
 
+// Parse the yorkwall product page's plain text into structured trade price + stock.
+// The portal shows MSRP (retail), a NET PRICE table (discount% + trade cost), and
+// an INVENTORY table (batches × Quantity Available). Values sit in unlabeled table
+// cells, so we scope by section header rather than by keyword-per-cell.
+function parseYorkwall(t) {
+  if (!t) return null;
+  const num = s => +String(s).replace(/,/g, '');
+  const P = {};
+  let m = t.match(/MSRP:\s*\$?\s*([\d,]+(?:\.\d+)?)\s*(single roll|double roll|yard|each|roll)?/i);
+  if (m) { P.msrp = num(m[1]); if (m[2]) P.unit = m[2]; }
+  // Net price = first "<discount%>  <price> <unit>" row AFTER the NET PRICE header.
+  const netSection = (t.split(/NET PRICE/i)[1] || '');
+  const net = netSection.match(/(\d{1,3})\s+([\d,]+(?:\.\d+)?)\s+(single roll|double roll|yard|each)/i);
+  if (net) { P.discountPct = num(net[1]); P.netPrice = num(net[2]); P.netUnit = net[3]; }
+  // Quantity available = sum of integer "<n> Single Roll" rows between INVENTORY/PRICING.
+  const invBlock = (t.split(/INVENTORY/i)[1] || '').split(/PRICING/i)[0] || '';
+  const qtys = [...invBlock.matchAll(/([\d,]+)\s+single roll/gi)].map(x => num(x[1]));
+  if (qtys.length) { P.quantityAvailable = qtys.reduce((a, b) => a + b, 0); P.batches = qtys; }
+  m = t.match(/Roll Width\s+([^\n]+)/i); if (m) P.rollWidth = m[1].trim();
+  m = t.match(/Roll Length\s+([^\n]+)/i); if (m) P.rollLength = m[1].trim();
+  return P;
+}
+
 // Read the most-recent saved portal result for this MFR (from the 3 AM overnight
 // job or a prior app pull). Matches by the file's `mfr` field, newest by mtime —
 // so the pills app can show the last known live number without hitting the portal.
@@ -1217,6 +1240,7 @@ function readLastLive(mfrSku) {
       loggedIn: !!d.loggedIn,
       locked: !!d.locked,
       priceStock: Array.isArray(d.priceStock) ? d.priceStock : [],
+      parsed: d.bodyText ? parseYorkwall(d.bodyText) : null,
       abort: d.abort || null,
     };
   } catch (_) { return null; }
@@ -1241,7 +1265,12 @@ function runYorkwallLive(mfrSku) {
       maxBuffer: 4 * 1024 * 1024,
     }, (err, stdout) => {
       let parsed = null;
-      try { parsed = JSON.parse(stdout); } catch (_) { /* non-JSON */ }
+      try { parsed = JSON.parse(stdout); } catch (_) {
+        // Tolerate any non-JSON preamble (e.g. dotenv tips) by grabbing the last
+        // top-level {...} block that runs to end-of-output.
+        const m = stdout && stdout.match(/\{[\s\S]*\}\s*$/);
+        if (m) { try { parsed = JSON.parse(m[0]); } catch (__) { /* still bad */ } }
+      }
       if (!parsed) return resolve({ ok: false, error: err ? err.message : 'no JSON from scraper', raw: (stdout || '').slice(0, 500) });
       resolve({ ok: true, data: parsed });
     });
@@ -1323,6 +1352,7 @@ app.post('/api/check/live', async (req, res) => {
               authError: !!d.authError,
               abort: d.abort || null,
               priceStock: Array.isArray(d.priceStock) ? d.priceStock : [],
+              parsed: d.bodyText ? parseYorkwall(d.bodyText) : null,
               pageTitle: d.pageTitle || null,
               message: d.loggedIn
                 ? 'Live portal pull complete.'

← 803ecd6 pills app: add last-portal-pull cache (reads yw-results, wri  ·  back to Sku Check Skill  ·  (newest)