← back to Gmc 425 Supplemental Feed
classify-leakers.mjs
61 lines
#!/usr/bin/env node
/**
* classify-leakers.mjs — DRY-RUN, READ-ONLY. Touches NO live feed.
*
* For each candidate $4.25 leaker (product with a real Roll variant + a $4.25 Sample),
* checks live Merchant Center to classify which durable fix actually applies:
* SUPP_FIXABLE → the Roll offer IS in the feed AND the Sample offer is too:
* a supplemental feed can EXCLUDE the Sample offer (excluded_destination)
* and the Roll stays → keeps Google presence. Best case.
* SUPP_CANT_HELP → the Roll offer is ABSENT from the feed (only the Sample reached Google):
* a supplemental feed cannot inject a missing offer → this product needs a
* Shopify primary-feed fix OR product-level unpublish. (Florencecourt case.)
* CLEAN → no $4.25 Sample offer in the feed (nothing to fix).
*
* Emits out/leaker-classification.json + out/supplemental-feed.tsv (for SUPP_FIXABLE only).
* NEVER uploads. The upload/register step is a separate, Steve-gated action.
*
* Usage: node classify-leakers.mjs # runs on the built-in 2 canary products
* node classify-leakers.mjs leakers.json # [{handle,pid,rollVid,sampleVid}, ...]
*/
import fs from 'node:fs'; import crypto from 'node:crypto';
const HOME=process.env.HOME, MERCHANT='146735262';
const SA_PATH=HOME+'/Projects/secrets-manager/gmc-sa-146735262.json';
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 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 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};
}
const DEFAULT=[
{handle:'Florencecourt dwkk-g0435926b',pid:'7421449928755',rollVid:'44346606026803',sampleVid:'42654156062771'},
{handle:'Strawberry Tree dwkk-g83536e25',pid:'7421450059827',rollVid:'44346606125107',sampleVid:'42654156292147'},
];
const arg=process.argv[2];
const LEAKERS=arg?JSON.parse(fs.readFileSync(arg,'utf8')):DEFAULT;
const rows=[]; const supp=[];
for(const L of LEAKERS){
const roll=await inFeed(L.pid,L.rollVid), samp=await inFeed(L.pid,L.sampleVid);
let cls;
if(!samp.present) cls='CLEAN';
else if(roll.present) cls='SUPP_FIXABLE';
else cls='SUPP_CANT_HELP';
rows.push({...L,rollInFeed:roll.present,sampleInFeed:samp.present,classification:cls});
if(cls==='SUPP_FIXABLE') supp.push({id:samp.oid,excluded_destination:'Shopping_ads'});
console.log(`${cls.padEnd(14)} ${L.handle} roll=${roll.present?'in':'ABSENT'} sample=${samp.present?'in':'absent'}`);
}
fs.writeFileSync(new URL('leaker-classification.json',OUT),JSON.stringify(rows,null,2));
// Supplemental feed (TSV) — Content API "excluded_destination" overlay; matched by offer id.
const tsv=['id\texcluded_destination',...supp.map(s=>`${s.id}\t${s.excluded_destination}`)].join('\n');
fs.writeFileSync(new URL('supplemental-feed.tsv',OUT),tsv+'\n');
const n=rows.length, f=rows.filter(r=>r.classification==='SUPP_FIXABLE').length, c=rows.filter(r=>r.classification==='SUPP_CANT_HELP').length;
console.log(`\nSUMMARY: ${n} candidates → ${f} SUPP_FIXABLE (supplemental feed can suppress the $4.25 sample, Roll stays), ${c} SUPP_CANT_HELP (Roll absent → needs Shopify feed fix or unpublish), ${n-f-c} CLEAN.`);
console.log('Wrote out/leaker-classification.json + out/supplemental-feed.tsv (DRY-RUN — NOT uploaded).');