← back to Dw Yolo Loop

scripts/gmc-feed-guard/gmc-feed-guard.mjs

83 lines

// gmc-feed-guard (c59) — READ-ONLY standing canary. Enforces Steve's policy
// (2026-07-08): "$4.25 is fine to show on SAMPLE-ONLY items; always show the
// HIGHEST price only." So a violation is a product that HAS a real roll variant
// (>$4.25) yet still leaks its $4.25 sample as the min price while published to
// the Google & YouTube channel (pub 29646651457) — Google reads minVariantPrice,
// so those get price-mismatch DISAPPROVED. Sample-only $4.25 products are LEGIT
// and are counted separately (informational), never a FAIL.
// Hardened: retry/backoff on non-JSON/429/5xx. Writes data/latest.json (heartbeat).
// FAIL if any sample-leak found. $0.
// TK-10952: added global unhandledRejection handler so mid-run crashes write FAIL
// to data/latest.json (heartbeat stays honest) and exit 1 (not silent exit 2).
import fs from 'fs';
const _DIR_GUARD = process.env.HOME+'/Projects/dw-yolo-loop/scripts/gmc-feed-guard/data';
const _SKILL_GUARD = process.env.HOME+'/.claude/skills/gmc-feed-guard/data';
process.on('unhandledRejection', (err) => {
  console.error('[gmc-feed-guard] CRASH:', err?.message || err);
  const crashOut = JSON.stringify({
    generated_at: new Date().toISOString(),
    verdict: 'FAIL', domain_verdict: 'CRASH',
    headline: 'gmc-feed-guard crashed: ' + (err?.message || String(err)),
    status: 'FAIL', sample_leaks: -1, error: String(err?.message || err),
  }, null, 2);
  try { fs.mkdirSync(_DIR_GUARD, {recursive:true}); fs.writeFileSync(_DIR_GUARD+'/latest.json', crashOut); } catch(_) {}
  try { fs.mkdirSync(_SKILL_GUARD, {recursive:true}); fs.writeFileSync(_SKILL_GUARD+'/latest.json', crashOut); } catch(_) {}
  process.exit(1);
});
const T=(fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1];
const API='https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
const GOOG='gid://shopify/Publication/29646651457';
const DIR=process.env.HOME+'/Projects/dw-yolo-loop/scripts/gmc-feed-guard/data';
const SKILL_DIR=process.env.HOME+'/.claude/skills/gmc-feed-guard/data'; // mirrors artifact so cron-fire-canary manifest discovers it
const Q=process.env.HOME+'/.claude/yolo-queue';
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
// A SAMPLE variant = its SKU carries a -sample/-memo/-swatch suffix (authoritative signal), OR its
// title/option says sample/memo/swatch. Samples often have a generic variant title, so the SKU suffix
// is the reliable signal — NOT the price. (TK-11061, 2026-09-01: the old min<=4.25/max>4.25 heuristic
// counted EVERY roll that merely HAD a $4.25 sample variant as a "leak" → 50,991 false alarms; a real
// leak is a product on Google whose NON-sample variant is itself <= $4.25. Classify by variant, not price.
// Ported from ~/.claude/skills/google-merchant-agent/check.mjs.)
const isSampleVariant=v=>/-(sample|memo|swatch)\b|(^|[^a-z])(sample|memo|swatch)([^a-z]|$)/i
  .test((v.sku||'')+' '+(v.title||'')+' '+(v.selectedOptions||[]).map(o=>o.value).join(' '));
async function gql(q,v){
  for(let a=0;a<7;a++){
    try{ const r=await fetch(API,{method:'POST',headers:{'X-Shopify-Access-Token':T,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v||{}}),signal:AbortSignal.timeout(20000)});
      if(r.status===429||r.status>=500){await sleep(2500*(a+1));continue;}
      const txt=await r.text(); let j; try{j=JSON.parse(txt);}catch(e){await sleep(2500*(a+1));continue;}
      if(j.errors&&JSON.stringify(j.errors).match(/THROTTLED/)){await sleep(2500*(a+1));continue;}
      return j;
    }catch(e){ await sleep(2000*(a+1)); }
  }
  throw new Error('exhausted retries');
}
let cur=null,scanned=0,leaks=0,sampleOnly=0,pages=0; const offenders=[]; const t0=Date.now();
do{
  const d=await gql(`query($c:String){products(first:150,after:$c,query:"status:active"){pageInfo{hasNextPage endCursor} nodes{handle vendor onGoogle:publishedOnPublication(publicationId:"${GOOG}") variants(first:60){nodes{price sku title selectedOptions{value}}}}}}`,{c:cur});
  const pg=d.data.products;
  for(const p of pg.nodes){ scanned++;
    const prices=p.variants.nodes.map(v=>parseFloat(v.price)).filter(x=>!isNaN(x));
    if(!prices.length) continue;
    if(p.onGoogle){
      // Real leak = a product ON Google whose NON-sample (roll/main) variant is priced <= $4.25.
      // Sample-only products (every variant is a sample) at $4.25 are LEGIT, not a leak.
      const nonSample=p.variants.nodes.filter(v=>!isSampleVariant(v)&&!isNaN(parseFloat(v.price)));
      if(nonSample.length===0){ sampleOnly++; }
      else{
        const mnNon=Math.min(...nonSample.map(v=>parseFloat(v.price)));
        if(mnNon<=4.25){ leaks++; if(offenders.length<20)offenders.push(`${p.handle} [${p.vendor}] non-sample-min=$${mnNon}`); }
      }
    }
  }
  pages++; if(pages%80===0)process.stderr.write(`  ...${scanned} scanned, sample-leaks=${leaks}\n`);
  cur=pg.pageInfo.hasNextPage?pg.pageInfo.endCursor:null;
}while(cur);
const verdict = leaks>0 ? 'FAIL' : 'PASS';
const out={generated_at:new Date().toISOString(), verdict, status:verdict, headline:`sample-leak(non-sample variant <=$4.25 on Google)=${leaks} of ${scanned} active (target 0); sample-only-$4.25=${sampleOnly} (legit)`, scanned, sample_leaks:leaks, sample_only_425:sampleOnly, offenders, elapsed_s:((Date.now()-t0)/1000)|0};
fs.mkdirSync(DIR,{recursive:true}); fs.writeFileSync(DIR+'/latest.json',JSON.stringify(out,null,2));
fs.mkdirSync(SKILL_DIR,{recursive:true}); fs.writeFileSync(SKILL_DIR+'/latest.json',JSON.stringify(out,null,2)); // mirror for cron-fire-canary + fleet-health-rollup
const stamp=new Date().toISOString().slice(0,10);
fs.writeFileSync(Q+`/gmc-feed-guard-${stamp}.json`,JSON.stringify(out,null,2));
console.log(`[gmc-feed-guard] ${verdict} — ${out.headline} (full catalog, ${out.elapsed_s}s)`);
if(leaks) offenders.forEach(o=>console.log('  ⚠️ sample-leak: '+o));
process.exit(verdict==='FAIL'?2:0);