← back to Gmc 425 Supplemental Feed
full-classify.mjs
59 lines
#!/usr/bin/env node
/** full-classify.mjs — READ-ONLY, bounded, $0. Classifies the ENTIRE leaks population efficiently:
* 1) Page the whole MC feed ONCE → Set of present offer-ids (no per-product MC calls).
* 2) Page all active+on-Google Shopify products with the leak signature ($4.25 sample + real roll).
* 3) Classify each via in-memory Set membership. Writes out/full-classification.json + per-class lists.
* Touches NO live feed. */
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 b64=b=>Buffer.from(b).toString('base64').replace(/=/g,'').replace(/\+/g,'-').replace(/\//g,'_');
async function gql(q,v){for(let a=0;a<5;a++){const r=await fetch(API,{method:'POST',headers:{'X-Shopify-Access-Token':T,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v||{}})});const j=await r.json();if(j.errors&&/throttl/i.test(JSON.stringify(j.errors))){await new Promise(s=>setTimeout(s,2000*(a+1)));continue;}return j;}throw new Error('gql throttled');}
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;}
// 1) whole MC feed → Set of present offer ids (productId like online:en:US:shopify_US_<pid>_<vid>)
let tok=await mcToken(), tokAt=Date.now();
const present=new Set(); let page=null, mcPages=0;
do{ if(Date.now()-tokAt>3000000){tok=await mcToken();tokAt=Date.now();}
const r=await fetch(`https://shoppingcontent.googleapis.com/content/v2.1/${MERCHANT}/productstatuses?maxResults=250`+(page?`&pageToken=${page}`:''),{headers:{Authorization:`Bearer ${tok}`}});
const j=await r.json();
for(const ps of (j.resources||[])){ if(ps.productId) present.add(ps.productId); }
page=j.nextPageToken; mcPages++;
if(mcPages%20===0) console.error(` MC feed pages=${mcPages} offers=${present.size}`);
}while(page);
console.error(`MC feed loaded: ${present.size} offer-ids across ${mcPages} pages`);
const inFeed=(pid,vid)=>present.has(`online:en:US:shopify_US_${pid}_${vid}`);
// 2+3) page all active on-Google leak-signature products, classify in-memory
const GOOG_PUB=await (async()=>{const j=await gql(`{publications(first:25){nodes{id name}}}`);return j.data.publications.nodes.find(n=>/google/i.test(n.name)).id;})();
const counts={SUPP_FIXABLE:0,SUPP_CANT_HELP:0,CLEAN:0}; const byClass={SUPP_FIXABLE:[],SUPP_CANT_HELP:[],CLEAN:[]};
let cur=null, scanned=0, leakSig=0, spages=0;
do{ const j=await gql(`query($c:String){products(first:150,after:$c,query:"status:active"){pageInfo{hasNextPage endCursor} nodes{id vendor onG:publishedOnPublication(publicationId:"${GOOG_PUB}") featuredImage{url} variants(first:60){nodes{id 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=>({id:v.id.split('/').pop(),pr:parseFloat(v.price)}));
const mn=Math.min(...vs.map(v=>v.pr)), mx=Math.max(...vs.map(v=>v.pr));
if(!(mn<=4.25&&mx>4.25)) continue; leakSig++;
const pid=p.id.split('/').pop();
const sample=vs.find(v=>v.pr<=4.25), roll=vs.reduce((a,b)=>b.pr>a.pr?b:a,vs[0]);
const cls = !inFeed(pid,sample.id) ? 'CLEAN' : (inFeed(pid,roll.id)?'SUPP_FIXABLE':'SUPP_CANT_HELP');
counts[cls]++; byClass[cls].push({pid,vendor:p.vendor,rollVid:roll.id,sampleVid:sample.id,rollPrice:roll.pr});
}
spages++; if(spages%20===0) console.error(` shopify pages=${spages} scanned=${scanned} leakSig=${leakSig}`);
cur=pg.pageInfo.hasNextPage?pg.pageInfo.endCursor:null;
}while(cur);
const total=leakSig||1;
const summary={generated:new Date().toISOString(),scanned,leakSignature:leakSig,mcFeedOffers:present.size,counts,
pct:{SUPP_FIXABLE:+(100*counts.SUPP_FIXABLE/total).toFixed(1),SUPP_CANT_HELP:+(100*counts.SUPP_CANT_HELP/total).toFixed(1),CLEAN:+(100*counts.CLEAN/total).toFixed(1)}};
fs.writeFileSync(new URL('full-classification.json',OUT),JSON.stringify({summary,rows:[...byClass.SUPP_FIXABLE.map(r=>({...r,classification:'SUPP_FIXABLE'})),...byClass.SUPP_CANT_HELP.map(r=>({...r,classification:'SUPP_CANT_HELP'})),...byClass.CLEAN.map(r=>({...r,classification:'CLEAN'}))]},null,2));
fs.writeFileSync(new URL('supp-fixable.json',OUT),JSON.stringify(byClass.SUPP_FIXABLE,null,2));
fs.writeFileSync(new URL('supp-cant-help.json',OUT),JSON.stringify(byClass.SUPP_CANT_HELP,null,2));
console.log(JSON.stringify(summary,null,2));
console.log('Wrote out/full-classification.json + supp-fixable.json + supp-cant-help.json');