[object Object]

← back to Gmc 425 Supplemental Feed

add full-population classifier (MC-feed-set based, bounded) + reorder dry-run plan generator; v0.1.3

92213f91ceefe76f3646ea636b6381b55e2fa471 · 2026-08-10 09:33:09 -0700 · Steve Abrams

Files touched

Diff

commit 92213f91ceefe76f3646ea636b6381b55e2fa471
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 09:33:09 2026 -0700

    add full-population classifier (MC-feed-set based, bounded) + reorder dry-run plan generator; v0.1.3
---
 full-classify.mjs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json      |  2 +-
 reorder-plan.mjs  | 28 +++++++++++++++++++++++++++
 3 files changed, 87 insertions(+), 1 deletion(-)

diff --git a/full-classify.mjs b/full-classify.mjs
new file mode 100644
index 0000000..d006710
--- /dev/null
+++ b/full-classify.mjs
@@ -0,0 +1,58 @@
+#!/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');
diff --git a/package.json b/package.json
index 934134d..3440a43 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "gmc-425-supplemental-feed",
-  "version": "0.1.2",
+  "version": "0.1.3",
   "private": true,
   "type": "module",
   "description": "DRY-RUN classifier for the DW $4.25 GMC leak: determines per-offer whether a supplemental feed can fix it, or whether the Roll is absent from the primary feed (needs a Shopify feed fix / unpublish). Never uploads.",
diff --git a/reorder-plan.mjs b/reorder-plan.mjs
new file mode 100644
index 0000000..9ac95fe
--- /dev/null
+++ b/reorder-plan.mjs
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+/** reorder-plan.mjs — DRY-RUN PLAN generator for the SUPP_CANT_HELP source fix. Writes NO changes.
+ * For each product (Sample@pos1 → only Sample reaches Google), plan: make Roll position 1, Sample position 2,
+ * so Shopify's Google channel submits the Roll offer. Emits the productVariantsBulkReorder payloads
+ * (NOT executed) + a re-sync + verify checklist + reversibility. Upload/execute is Steve-gated.
+ * Usage: node reorder-plan.mjs [supp-cant-help.json] */
+import fs from 'node:fs';
+const OUT=new URL('./out/',import.meta.url); fs.mkdirSync(OUT,{recursive:true});
+const src=process.argv[2]||new URL('supp-cant-help.json',OUT);
+let rows; try{ rows=JSON.parse(fs.readFileSync(src,'utf8')); }catch{ console.error('No SUPP_CANT_HELP list yet at '+src+' — run full-classify.mjs first.'); process.exit(2); }
+const plans=rows.map(r=>({
+  pid:r.pid, vendor:r.vendor, rollPrice:r.rollPrice,
+  mutation:'productVariantsBulkReorder',
+  variables:{ productId:`gid://shopify/Product/${r.pid}`,
+    positions:[ {id:`gid://shopify/ProductVariant/${r.rollVid}`,position:1},
+                {id:`gid://shopify/ProductVariant/${r.sampleVid}`,position:2} ] },
+}));
+fs.writeFileSync(new URL('reorder-plan.json',OUT),JSON.stringify({
+  generated:'DRY-RUN PLAN (not executed)',
+  count:plans.length,
+  action:'Set Roll variant to position 1 (default) so Shopify Google & YouTube channel submits the Roll offer, not the $4.25 Sample.',
+  post_step:'After reorder: force a Google channel re-sync for each product, then RE-VERIFY via classify (Roll offer should appear + $4.25 Sample offer should drop). Note: already-ingested Sample offers are STICKY in Google — allow re-crawl time and confirm.',
+  reversibility:'Reorder is fully reversible (swap positions back). No price/variant/inventory mutated.',
+  gated:'The productVariantsBulkReorder writes + the channel re-sync are customer-facing Shopify writes — Steve-gated. This file is the plan only.',
+  plans,
+},null,2));
+console.log(`Reorder DRY-RUN plan written for ${plans.length} SUPP_CANT_HELP products → out/reorder-plan.json (NOT executed).`);
+if(plans[0]) console.log('Sample payload:',JSON.stringify(plans[0].variables));

← 05b55a3 supplemental-feed dry-run generator + primary-feed spike: RO  ·  back to Gmc 425 Supplemental Feed  ·  canary: 10-product reorder applied (reversible, ledger); fee 8c108c6 →