← 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); });