← back to Cncp Failure Collector
canary-shopify-prices.js
159 lines
#!/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 signal = AbortSignal.timeout(15_000);
return fetch(url, { headers: { 'User-Agent': UA }, signal });
}
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); });