[object Object]

← back to Sku Check Skill

pills app: add last-portal-pull cache (reads yw-results, write-through on live)

803ecd65766895f0bfb353be3afbaa4fb80068ca · 2026-07-10 07:51:03 -0700 · Steve

- readLastLive(mfr): newest saved portal result by mtime, matched on the file's
  mfr field (unifies the 3 AM overnight job + app-triggered pulls).
- Endpoint attaches out.lastLive always; a successful fresh live pull is written
  back via saveLive() so the next locked/cooldown request shows it instantly.
- Use process.execPath (not bare 'node') to spawn the scraper — PATH-safe.
- pills.html renders a 'Last portal pull · Nm ago' section (hidden when a fresh
  live pull succeeded).

Files touched

Diff

commit 803ecd65766895f0bfb353be3afbaa4fb80068ca
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jul 10 07:51:03 2026 -0700

    pills app: add last-portal-pull cache (reads yw-results, write-through on live)
    
    - readLastLive(mfr): newest saved portal result by mtime, matched on the file's
      mfr field (unifies the 3 AM overnight job + app-triggered pulls).
    - Endpoint attaches out.lastLive always; a successful fresh live pull is written
      back via saveLive() so the next locked/cooldown request shows it instantly.
    - Use process.execPath (not bare 'node') to spawn the scraper — PATH-safe.
    - pills.html renders a 'Last portal pull · Nm ago' section (hidden when a fresh
      live pull succeeded).
---
 public/pills.html | 17 +++++++++++++++++
 server.js         | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 67 insertions(+), 1 deletion(-)

diff --git a/public/pills.html b/public/pills.html
index 44843e2..f933bcd 100644
--- a/public/pills.html
+++ b/public/pills.html
@@ -160,6 +160,23 @@
       h += '</div>';
     }
 
+    // last portal pull (from overnight job / prior pull) — skip if a fresh live succeeded
+    const freshLiveOk = d.live && d.live.loggedIn;
+    if (d.lastLive && !freshLiveOk) {
+      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) {
+        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>';
+      } else if (L.locked) {
+        h += '<div class="banner bad">Last attempt hit a portal lockout ('+esc(new Date(L.at).toLocaleString())+').</div>';
+      } else {
+        h += '<div class="banner warn">Last saved pull had no price/stock text.</div>';
+      }
+      h += '</div>';
+    }
+
     // live
     if (d.live) {
       h += '<div class="sect"><h3>Live (trade portal)</h3>';
diff --git a/server.js b/server.js
index 03ef20f..d36b870 100644
--- a/server.js
+++ b/server.js
@@ -1,4 +1,5 @@
 try { require('dotenv').config({ path: '/root/.env' }); } catch (_) {}
+const fs = require('fs');
 const os = require('os');
 const path = require('path');
 // Canonical secrets store — holds YORKWALL_* (parsed safely, never bash-sourced).
@@ -1190,10 +1191,51 @@ let lockoutUntilMs = 0;
 const LIVE_MIN_GAP_MS = 20 * 1000;      // ignore rapid double-clicks
 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');
+
+// 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.
+function readLastLive(mfrSku) {
+  try {
+    if (!fs.existsSync(YW_RESULTS_DIR)) return null;
+    let best = null;
+    for (const f of fs.readdirSync(YW_RESULTS_DIR)) {
+      if (!f.endsWith('.json')) continue;
+      const fp = path.join(YW_RESULTS_DIR, f);
+      let st, data;
+      try { st = fs.statSync(fp); data = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch (_) { continue; }
+      if (!data || data.mfr !== mfrSku) continue;
+      if (!best || st.mtimeMs > best.mtimeMs) best = { mtimeMs: st.mtimeMs, data };
+    }
+    if (!best) return null;
+    const d = best.data;
+    return {
+      at: new Date(best.mtimeMs).toISOString(),
+      ageMin: Math.round((Date.now() - best.mtimeMs) / 60000),
+      loggedIn: !!d.loggedIn,
+      locked: !!d.locked,
+      priceStock: Array.isArray(d.priceStock) ? d.priceStock : [],
+      abort: d.abort || null,
+    };
+  } catch (_) { return null; }
+}
+
+// Persist a successful app-triggered pull so it becomes the next "last live".
+function saveLive(mfrSku, data) {
+  try {
+    fs.mkdirSync(YW_RESULTS_DIR, { recursive: true });
+    fs.writeFileSync(path.join(YW_RESULTS_DIR, `${mfrSku}-app-${Date.now()}.json`), JSON.stringify(data));
+  } catch (_) { /* best-effort cache */ }
+}
+
 // Run the yorkwall scraper for one MFR sku (Brewster/York family only).
 function runYorkwallLive(mfrSku) {
   return new Promise((resolve) => {
-    execFile('node', [YW_SCRIPT, mfrSku], {
+    // Use the exact node binary running this server (process.execPath) rather than
+    // relying on 'node' being on PATH — launchd/pm2 run with a stripped PATH.
+    execFile(process.execPath, [YW_SCRIPT, mfrSku], {
       cwd: path.dirname(YW_SCRIPT),
       timeout: 90000,
       maxBuffer: 4 * 1024 * 1024,
@@ -1245,6 +1287,11 @@ app.post('/api/check/live', async (req, res) => {
       message: specsResult.message,
     };
 
+    // Last-live tier — most recent saved portal pull (overnight job or prior app
+    // pull), shown even when the portal is locked / not queried this call.
+    const last = readLastLive(match.mfr_sku);
+    if (last) out.lastLive = last;
+
     // Live tier — only if the user asked for stock/price.
     if (wantStock || wantPrice) {
       const hasPortal = !!(vc && vc.tradePortal); // Brewster/York family only
@@ -1267,6 +1314,8 @@ app.post('/api/check/live', async (req, res) => {
             const d = r.data;
             // If the scraper reports a lockout, arm the 30-min backoff.
             if (d.locked || d.abort === 'STILL_LOCKED') lockoutUntilMs = Date.now() + LIVE_LOCKOUT_MS;
+            // Write-through: cache a successful pull as the next "last live".
+            if (d.loggedIn) { saveLive(match.mfr_sku, d); out.lastLive = readLastLive(match.mfr_sku); }
             out.live = {
               attempted: true,
               loggedIn: !!d.loggedIn,

← 59bd09a Add pills live-check app: enter MFR or DW number, tick stock  ·  back to Sku Check Skill  ·  pills app: parse yorkwall trade price + stock, show Retail/N a55b434 →