[object Object]

← back to Gmc 425 Supplemental Feed

gmc-425 dry-run leaker classifier v0.1.0: proves supplemental feed can't fix true leakers (Roll absent from primary feed); root = Shopify Google channel submits single/Sample variant

8e6c43453e108624d721c9d9dd71314600f24090 · 2026-08-10 08:50:22 -0700 · Steve Abrams

Files touched

Diff

commit 8e6c43453e108624d721c9d9dd71314600f24090
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 08:50:22 2026 -0700

    gmc-425 dry-run leaker classifier v0.1.0: proves supplemental feed can't fix true leakers (Roll absent from primary feed); root = Shopify Google channel submits single/Sample variant
---
 .gitignore           |  6 ++++++
 README.md            | 13 ++++++++++++
 classify-leakers.mjs | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json         |  8 +++++++
 4 files changed, 87 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c4dce82
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+out/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1f85f6a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,13 @@
+# gmc-425-supplemental-feed (DRY-RUN)
+
+Investigates the durable fix for the DW $4.25 Google-Merchant leak. **Touches no live feed** — read-only MC queries + local file output only. Upload/register is a separate Steve-gated step.
+
+## Key finding (2026-08-10)
+The two reorder-canary products, byte-identical on Shopify (Roll@pos1), feed differently in MC → variant order is NOT the lever. Classifier run:
+- **Florencecourt** = `SUPP_CANT_HELP` (Roll ABSENT from feed, only $4.25 Sample present → supplemental feed can't inject the missing Roll).
+- **Strawberry Tree** = `CLEAN` (Roll present, Sample absent).
+
+⇒ For true leakers, only the **$4.25 Sample** reaches Google's feed; the Roll is missing. Root = the **Shopify Google & YouTube channel submits a single variant per product to the primary feed** and picks the Sample for leakers. Neither reorder, supplemental feed, nor per-variant metafield fixes that. The durable fix is in the **primary Shopify→Google feed** (make it submit the Roll / all variants). Next: run this classifier over the FULL leaker set to size each class before choosing.
+
+## Usage
+`node classify-leakers.mjs [leakers.json]` → writes `out/leaker-classification.json` + `out/supplemental-feed.tsv`.
diff --git a/classify-leakers.mjs b/classify-leakers.mjs
new file mode 100644
index 0000000..a176648
--- /dev/null
+++ b/classify-leakers.mjs
@@ -0,0 +1,60 @@
+#!/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).');
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..a91e118
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "gmc-425-supplemental-feed",
+  "version": "0.1.0",
+  "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.",
+  "bin": { "classify-leakers": "classify-leakers.mjs" }
+}

(oldest)  ·  back to Gmc 425 Supplemental Feed  ·  add bounded leak sampler/classifier (N=150): true leak rate 60c265d →