← 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
M README.mdA canary-shopify-prices.js
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 →