← 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
M public/pills.htmlM server.js
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 => ({'<':'<','>':'>','&':'&'}[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)