← back to Gmc 425 Supplemental Feed

sample-and-classify.mjs

66 lines

#!/usr/bin/env node
/**
 * sample-and-classify.mjs — DRY-RUN, READ-ONLY, bounded. Touches NO live feed.
 * 1) Pull a BOUNDED sample (N) of the real "leaks" population from Shopify:
 *    active, published-on-Google, min-variant<=4.25 AND max-variant>4.25 (has $4.25 sample + real roll).
 * 2) For each, resolve the Roll variant (max price) + Sample variant ($4.25), check live MC per-offer.
 * 3) Classify SUPP_FIXABLE / SUPP_CANT_HELP / CLEAN and report the distribution → true leak RATE.
 * Extrapolates to the full leaks population count passed as --pop (default 48748).
 * Usage: node sample-and-classify.mjs [N=150] [--pop 48748]
 */
import fs from 'node:fs'; import crypto from 'node:crypto';
const HOME=process.env.HOME, MERCHANT='146735262', SHOP='designer-laboratory-sandbox';
const API=`https://${SHOP}.myshopify.com/admin/api/2024-10/graphql.json`;
const SA_PATH=HOME+'/Projects/secrets-manager/gmc-sa-146735262.json';
const T=(fs.readFileSync(HOME+'/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1].replace(/['"]/g,'').trim();
const OUT=new URL('./out/',import.meta.url); fs.mkdirSync(OUT,{recursive:true});
const N=parseInt(process.argv[2]||'150',10);
const POP=parseInt((process.argv.find(a=>a.startsWith('--pop='))||'--pop=48748').split('=')[1],10);
const b64=b=>Buffer.from(b).toString('base64').replace(/=/g,'').replace(/\+/g,'-').replace(/\//g,'_');
async function gql(q,v){const r=await fetch(API,{method:'POST',headers:{'X-Shopify-Access-Token':T,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v||{}})});return r.json();}
async function mcToken(){const SA=JSON.parse(fs.readFileSync(SA_PATH,'utf8'));const now=Math.floor(Date.now()/1000);
  const si=b64(JSON.stringify({alg:'RS256',typ:'JWT'}))+'.'+b64(JSON.stringify({iss:SA.client_email,scope:'https://www.googleapis.com/auth/content',aud:'https://oauth2.googleapis.com/token',iat:now,exp:now+3600}));
  const s=crypto.createSign('RSA-SHA256');s.update(si);const jwt=si+'.'+b64(s.sign(SA.private_key));
  const r=await fetch('https://oauth2.googleapis.com/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({grant_type:'urn:ietf:params:oauth:grant-type:jwt-bearer',assertion:jwt})});
  const j=await r.json();if(!j.access_token)throw new Error('mc token fail');return j.access_token;}
const GOOG_PUB=await (async()=>{const j=await gql(`{publications(first:25){nodes{id name}}}`);const g=j.data.publications.nodes.find(n=>/google/i.test(n.name));return g.id;})();
const H=await mcToken().then(t=>({Authorization:`Bearer ${t}`}));
async function inFeed(pid,vid){const oid=`online:en:US:shopify_US_${pid}_${vid}`;
  const r=await fetch(`https://shoppingcontent.googleapis.com/content/v2.1/${MERCHANT}/productstatuses/${encodeURIComponent(oid)}`,{headers:H});return {oid,present:r.status!==404};}
// 1) pull bounded leak sample
const picks=[]; let cur=null, scanned=0;
while(picks.length<N){
  const j=await gql(`query($c:String){products(first:100,after:$c,query:"status:active"){pageInfo{hasNextPage endCursor} nodes{id onG:publishedOnPublication(publicationId:"${GOOG_PUB}") featuredImage{url} variants(first:60){nodes{id title price}}}}}`,{c:cur});
  const pg=j.data.products; for(const p of pg.nodes){ scanned++;
    if(!p.onG||!p.featuredImage) continue;
    const vs=p.variants.nodes.map(v=>({...v,p:parseFloat(v.price)}));
    const mn=Math.min(...vs.map(v=>v.p)), mx=Math.max(...vs.map(v=>v.p));
    if(mn<=4.25 && mx>4.25){ // leak signature
      const sample=vs.find(v=>v.p<=4.25), roll=vs.reduce((a,b)=>b.p>a.p?b:a,vs[0]);
      picks.push({pid:p.id.split('/').pop(),rollVid:roll.id.split('/').pop(),sampleVid:sample.id.split('/').pop(),rollPrice:roll.p});
      if(picks.length>=N) break;
    }
  }
  if(!pg.pageInfo.hasNextPage) break; cur=pg.pageInfo.endCursor;
  if(scanned>20000) break; // hard bound
}
// 2+3) classify
const counts={SUPP_FIXABLE:0,SUPP_CANT_HELP:0,CLEAN:0}; const rows=[];
for(const L of picks){
  const roll=await inFeed(L.pid,L.rollVid), samp=await inFeed(L.pid,L.sampleVid);
  let cls = !samp.present ? 'CLEAN' : (roll.present ? 'SUPP_FIXABLE' : 'SUPP_CANT_HELP');
  counts[cls]++; rows.push({...L,rollInFeed:roll.present,sampleInFeed:samp.present,classification:cls});
}
fs.writeFileSync(new URL('sample-classification.json',OUT),JSON.stringify({n:picks.length,scanned,counts,rows},null,2));
const n=picks.length||1;
const leakRate=counts.SUPP_CANT_HELP/n;
console.log(`\nScanned ${scanned} active products to collect ${picks.length} leak-signature products (on Google + $4.25 sample + real roll).`);
console.log(`CLASS DISTRIBUTION (of the sample):`);
for(const k of Object.keys(counts)) console.log(`  ${k.padEnd(15)} ${counts[k]}  (${(100*counts[k]/n).toFixed(1)}%)`);
console.log(`\nTRUE-LEAK interpretation:`);
console.log(`  SUPP_CANT_HELP = Google feeds ONLY the $4.25 Sample (Roll absent) = the ACTUAL advertised-price leak.`);
console.log(`  SUPP_FIXABLE   = both offers in feed (supplemental can suppress sample).`);
console.log(`  CLEAN          = Google already feeds the Roll (NOT leaking, despite having a $4.25 sample).`);
console.log(`\n  Measured true-leak rate: ${(100*leakRate).toFixed(1)}%  →  extrapolated over ${POP} leak-bucket products ≈ ${Math.round(leakRate*POP)} genuinely leaking.`);
console.log(`  (vs the Shopify-side upper bound of ${POP}.) Wrote out/sample-classification.json.`);