← back to Interiordesignershowroom

scripts/tracked-coverage-canary.js

92 lines

#!/usr/bin/env node
// Tracked-link coverage canary for Interior Designer's Showroom.
// The whole affiliate model earns $0 if /go/:id redirects to RAW merchant URLs
// instead of CJ tracked click-links (which only mint once advertisers are JOINED
// in CJ). This canary samples the LIVE site's /go redirects and measures what
// fraction land on a CJ tracked domain. HTTP-only, read-only — no DB, no tunnel.
//
// Classifies OK (>=50% tracked) / WARN (1-49%) / CRITICAL (0%). Alerts (CNCP
// parking-lot card) only on a worsening transition, never on steady-state or
// recovery. Writes data/coverage-canary.json every run (heartbeat + last state).
'use strict';
const https = require('https');
const fs = require('fs');
const path = require('path');

const SITE = 'https://interiordesignershowroom.com';
const CNCP = 'http://127.0.0.1:3333/api/parking-lot';
const STATE = path.join(__dirname, '..', 'data', 'coverage-canary.json');
const CJ_TRACKED = /dpbolvw|anrdoezrs|kqzyfj|tkqlhce|jdoqocy|cj\.com|emjcd|ftjcd|awltovhc/i;
const SAMPLE = 20;

function get(url, { method = 'GET', followRedirect = true } = {}) {
  return new Promise((resolve) => {
    const req = https.request(url, { method, timeout: 10000 }, (res) => {
      let body = '';
      res.on('data', (c) => { body += c; });
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
    });
    req.on('error', () => resolve({ status: 0, headers: {}, body: '' }));
    req.on('timeout', () => { req.destroy(); resolve({ status: 0, headers: {}, body: '' }); });
    req.end();
  });
}

async function main() {
  // 1. harvest product ids off the live /shop grid
  const shop = await get(`${SITE}/shop`);
  const ids = [...new Set((shop.body.match(/data-id="(\d+)"/g) || [])
    .map((m) => m.match(/\d+/)[0]))];
  if (!ids.length) { return finish('UNKNOWN', 0, 0, 'could not read product ids from /shop'); }

  // 2. sample /go/:id redirects, inspect the Location header (302 -> merchant)
  const pick = ids.sort(() => 0.5 - Math.random()).slice(0, SAMPLE);
  let tracked = 0, checked = 0;
  for (const id of pick) {
    const r = await get(`${SITE}/go/${id}`, { method: 'HEAD' });
    const loc = r.headers.location || '';
    if (!loc) continue;
    checked++;
    if (CJ_TRACKED.test(loc)) tracked++;
  }
  if (!checked) return finish('UNKNOWN', 0, 0, 'no /go redirects resolved');

  const pct = Math.round((tracked / checked) * 100);
  const status = pct === 0 ? 'CRITICAL' : pct < 50 ? 'WARN' : 'OK';
  return finish(status, tracked, checked, `${pct}% tracked (${tracked}/${checked})`);
}

function rank(s) { return { OK: 0, WARN: 1, CRITICAL: 2, UNKNOWN: -1 }[s] ?? -1; }

async function finish(status, tracked, checked, detail) {
  let prev = {};
  try { prev = JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch (e) {}
  const now = { status, tracked, checked, detail, ts: new Date().toISOString() };
  try { fs.mkdirSync(path.dirname(STATE), { recursive: true }); fs.writeFileSync(STATE, JSON.stringify(now, null, 2)); } catch (e) {}

  // alert ONLY on a worsening transition (e.g. OK->WARN, WARN->CRITICAL)
  const worsened = rank(status) > rank(prev.status || 'OK');
  console.log(`[tracked-coverage] ${status} — ${detail}${worsened ? '  (WORSENED, alerting)' : ''}`);
  if (worsened && (status === 'WARN' || status === 'CRITICAL')) {
    const title = `IDC affiliate tracking ${status}: ${detail}`;
    const note = `interiordesignershowroom.com /go redirects are ${detail}. `
      + `Untracked links earn $0 commission. Fix = approve CJ advertiser programs (Wayfair, Herman Miller, etc.) `
      + `in members.cj.com, then re-run scripts/ingest-cj-catalog.js. Prev: ${prev.status || 'n/a'}.`;
    await postCncp(title, note);
  }
  process.exit(0);
}

function postCncp(title, note) {
  return new Promise((resolve) => {
    const data = JSON.stringify({ project: 'interiordesignershowroom', title, note, source: 'tracked-coverage-canary' });
    const req = require('http').request(CNCP, {
      method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }, timeout: 5000,
    }, (res) => { res.on('data', () => {}); res.on('end', resolve); });
    req.on('error', resolve); req.on('timeout', () => { req.destroy(); resolve(); });
    req.write(data); req.end();
  });
}

main().catch((e) => { console.error(e); process.exit(1); });