[object Object]

← back to Cncp Failure Collector

shopify cached-page price canary + nightly launchd at 4:17 AM PT; posts source=shopify-cache rows to /api/failures (throttled fetches)

2573de7a7007343a2371b3a5b8950805371a1873 · 2026-05-11 14:26:51 -0700 · SteveStudio2

Files touched

Diff

commit 2573de7a7007343a2371b3a5b8950805371a1873
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 11 14:26:51 2026 -0700

    shopify cached-page price canary + nightly launchd at 4:17 AM PT; posts source=shopify-cache rows to /api/failures (throttled fetches)
---
 README.md                |  33 ++++++++++
 canary-shopify-prices.js | 158 +++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 191 insertions(+)

diff --git a/README.md b/README.md
index 3aba639..c26479a 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,39 @@ CNCP-side env (in CNCP's own start, not this collector):
 
 Failures shell shows `N open / M total` with top-6 source-badged process names; click to open the main view.
 
+## Shopify cached-page price canary
+
+`canary-shopify-prices.js` — separate daily script (NOT the live pm2 daemon). Compares each Shopify product's variant price (from `/products/<handle>.json`) against what's rendered in the *cached* product page HTML. Files a `source: 'shopify-cache'` row to `/api/failures` for any product where the cache is missing/wrong relative to the data.
+
+Diagnosed first 2026-05-11 — `flore-batik-pillow-square-22x22-indigo-scalamandre` had `$504.94` in the variant but the cached HTML rendered with no price block. Re-saving the product in Shopify Admin rotates the cache. Audit found 46 confirmed-stale Scalamandre products created that day; the canary now watches for new ones nightly.
+
+### Run
+
+Ad-hoc:
+```bash
+VENDOR_FILTER=scalamandre LOOKBACK_HOURS=24 node canary-shopify-prices.js
+```
+
+Nightly (launchd at 4:17 AM PT):
+```bash
+launchctl load ~/Library/LaunchAgents/com.steve.shopify-price-canary.plist
+```
+
+### Env
+
+| Var | Default | Notes |
+|-----|---------|-------|
+| `STORE` | `https://www.designerwallcoverings.com` | Public Shopify storefront URL |
+| `CNCP_URL` | `http://127.0.0.1:3333` | Where to POST failure rows |
+| `LOOKBACK_HOURS` | `24` | Products created/updated in this window |
+| `REQUESTS_PER_SECOND` | `2` | Throttle to stay under Shopify edge rate limits |
+| `MAX_PAGES` | `20` | Hard cap on /products.json paging |
+| `VENDOR_FILTER` | (none) | Regex; only check products whose vendor or tags match |
+
+### What gets posted to /api/failures
+
+`source: 'shopify-cache'`, `processName: <SKU>`, `lastLines` containing handle / product ID / data price / cached price / fresh price / admin URL. The Hermes classifier will pick these up via the existing fire-and-forget path and surface them in the Failures UI alongside pm2/launchd failures.
+
 ## What's NOT in here
 
 - **Cloud agents** — local-only by Steve's standing rule.
diff --git a/canary-shopify-prices.js b/canary-shopify-prices.js
new file mode 100644
index 0000000..3796e32
--- /dev/null
+++ b/canary-shopify-prices.js
@@ -0,0 +1,158 @@
+#!/usr/bin/env node
+// Shopify cached-page price-parity canary.
+//
+// For products created or updated in the last LOOKBACK_HOURS, compare
+//   (A) /products/<handle>.json variant price (data)
+//   (B) /products/<handle> default HTML (visitor-cached)
+// If (A) has a non-zero price but (B) does NOT render that price, post a row to
+// CNCP /api/failures with source='shopify-cache' so it shows up in the Failures UI.
+//
+// Throttled: REQUESTS_PER_SECOND keeps us well under Shopify edge rate limits.
+// Run via launchd nightly (see ~/Library/LaunchAgents/com.steve.shopify-price-canary.plist).
+
+const STORE = process.env.STORE || 'https://www.designerwallcoverings.com';
+const CNCP_URL = process.env.CNCP_URL || 'http://127.0.0.1:3333';
+const LOOKBACK_HOURS = parseInt(process.env.LOOKBACK_HOURS, 10) || 24;
+const REQUESTS_PER_SECOND = parseInt(process.env.REQUESTS_PER_SECOND, 10) || 2;
+const MAX_PAGES = parseInt(process.env.MAX_PAGES, 10) || 20;
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15';
+const VENDOR_FILTER_RE = process.env.VENDOR_FILTER ? new RegExp(process.env.VENDOR_FILTER, 'i') : null;
+
+const REQ_INTERVAL_MS = Math.ceil(1000 / REQUESTS_PER_SECOND);
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+function log(...a) { console.log(new Date().toISOString(), ...a); }
+
+async function throttledFetch(url) {
+  await sleep(REQ_INTERVAL_MS);
+  const ctrl = AbortSignal.timeout(15_000);
+  return fetch(url, { headers: { 'User-Agent': UA }, signal: ctrl });
+}
+
+async function getJson(url) {
+  const r = await throttledFetch(url);
+  if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`);
+  return r.json();
+}
+async function getText(url) {
+  const r = await throttledFetch(url);
+  return r.ok ? r.text() : '';
+}
+
+function htmlPrice(html) {
+  if (!html) return null;
+  const m = html.match(/"price":"([0-9.]+)"/);
+  return m ? Number(m[1]) : null;
+}
+
+async function postFailure(payload) {
+  try {
+    const r = await fetch(`${CNCP_URL}/api/failures`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload)
+    });
+    if (!r.ok) { log('failure post failed', r.status); return false; }
+    return true;
+  } catch (e) { log('failure post threw:', e.message); return false; }
+}
+
+(async () => {
+  const now = Date.now();
+  const cutoff = now - LOOKBACK_HOURS * 3600 * 1000;
+  log(`canary start: store=${STORE} lookback=${LOOKBACK_HOURS}h rps=${REQUESTS_PER_SECOND} vendor_filter=${VENDOR_FILTER_RE || 'none'}`);
+
+  // ----- 1) Page through products.json to find candidates touched in lookback window -----
+  const candidates = [];
+  for (let page = 1; page <= MAX_PAGES; page++) {
+    let data;
+    try { data = await getJson(`${STORE}/products.json?limit=250&page=${page}`); }
+    catch (e) { log('list page failed:', page, e.message); break; }
+    if (!data.products || data.products.length === 0) break;
+    let touchedInWindow = false;
+    for (const p of data.products) {
+      const created = new Date(p.created_at).getTime();
+      const updated = new Date(p.updated_at).getTime();
+      const inWindow = created >= cutoff || updated >= cutoff;
+      if (inWindow) {
+        touchedInWindow = true;
+        if (VENDOR_FILTER_RE && !VENDOR_FILTER_RE.test(p.vendor || '') && !VENDOR_FILTER_RE.test(p.tags || '')) continue;
+        candidates.push(p);
+      }
+    }
+    log(`page ${page}: ${data.products.length} products, ${candidates.length} candidates so far, hit=${touchedInWindow}`);
+    if (!touchedInWindow && candidates.length > 0) break;
+  }
+  log(`${candidates.length} candidates in window`);
+
+  // ----- 2) For each candidate, compare cached vs uncached -----
+  let stale = 0, ok = 0, zero = 0, errs = 0;
+  const staleList = [];
+  for (const p of candidates) {
+    const handle = p.handle;
+    const v0 = (p.variants || [])[0];
+    const dataPrice = v0 && v0.price ? Number(v0.price) : 0;
+    if (!(dataPrice > 0)) { zero++; continue; }
+    try {
+      const cachedHtml = await getText(`${STORE}/products/${handle}`);
+      const cachedPrice = htmlPrice(cachedHtml);
+      if (cachedPrice == null) {
+        // Fresh-fetch with cache-bust to confirm it's not a deeper template issue.
+        const freshHtml = await getText(`${STORE}/products/${handle}?cb=${Date.now()}`);
+        const freshPrice = htmlPrice(freshHtml);
+        if (freshPrice != null) {
+          stale++;
+          staleList.push({ handle, sku: v0.sku, productId: p.id, dataPrice });
+          await postFailure({
+            source: 'shopify-cache',
+            processName: `${v0.sku || handle}`,
+            exitCode: null,
+            lastLines:
+              `Shopify-cached product page is missing the variant price.\n` +
+              `Handle: ${handle}\n` +
+              `SKU: ${v0.sku}\n` +
+              `Product ID: ${p.id}\n` +
+              `Vendor: ${p.vendor || '(unset)'}\n` +
+              `Variant price (data): $${dataPrice.toFixed(2)}\n` +
+              `Cached HTML price: (missing)\n` +
+              `Fresh HTML price: $${freshPrice.toFixed(2)}\n` +
+              `Created: ${p.created_at}\n` +
+              `Updated: ${p.updated_at}\n` +
+              `Fix: re-save in Shopify Admin (https://www.designerwallcoverings.com/admin/products/${p.id}) to rotate the cache.`
+          });
+        } else {
+          // Even fresh fetch missing — could be rate-limited or genuine template gap. Don't post.
+        }
+      } else if (Math.abs(cachedPrice - dataPrice) < 0.01) {
+        ok++;
+      } else {
+        // Cache has A DIFFERENT price than data — also stale, but show both.
+        stale++;
+        staleList.push({ handle, sku: v0.sku, productId: p.id, dataPrice, cachedPrice });
+        await postFailure({
+          source: 'shopify-cache',
+          processName: `${v0.sku || handle}`,
+          exitCode: null,
+          lastLines:
+            `Shopify-cached page price disagrees with variant data.\n` +
+            `Handle: ${handle}\n` +
+            `SKU: ${v0.sku}\n` +
+            `Variant price (data): $${dataPrice.toFixed(2)}\n` +
+            `Cached HTML price: $${cachedPrice.toFixed(2)}\n` +
+            `Created: ${p.created_at}\n` +
+            `Updated: ${p.updated_at}\n` +
+            `Fix: re-save in Shopify Admin (https://www.designerwallcoverings.com/admin/products/${p.id}).`
+        });
+      }
+    } catch (e) {
+      errs++;
+      log(`canary: ${handle} failed`, e.message);
+    }
+  }
+  log(`done: ${ok} ok, ${stale} stale, ${zero} zero-priced (skipped), ${errs} fetch-errs`);
+  if (staleList.length > 0) {
+    log('STALE LIST:');
+    for (const s of staleList) {
+      log(`  $${s.dataPrice.toFixed(2)} ${s.sku} → ${STORE}/admin/products/${s.productId}`);
+    }
+  }
+})().catch(e => { console.error('FATAL:', e); process.exit(1); });

← 78ad17a tick 7: prune classifier rate-limit Map of entries older tha  ·  back to Cncp Failure Collector  ·  tick 9: cross-platform — also runs on Kamatera, host-tagged 064993f →