[object Object]

← back to Dw Yolo Loop

GMC $4.25 feed-exclusion: staged Shopify-side fix (dry-run: 60,232 mispriced products in Google feed)

0bd224f311367809059a4630c72ff47345e88d61 · 2026-06-15 22:50:37 -0700 · Steve Abrams

Unpublishes Bucket A+B (min-variant=$4.25) from the Google & YouTube channel
(pub 29646651457) only — Steve-controllable, no Merchant Center login needed.
Dry-run default (read-only); --apply gated behind --yes-i-am-steve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 0bd224f311367809059a4630c72ff47345e88d61
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 15 22:50:37 2026 -0700

    GMC $4.25 feed-exclusion: staged Shopify-side fix (dry-run: 60,232 mispriced products in Google feed)
    
    Unpublishes Bucket A+B (min-variant=$4.25) from the Google & YouTube channel
    (pub 29646651457) only — Steve-controllable, no Merchant Center login needed.
    Dry-run default (read-only); --apply gated behind --yes-i-am-steve.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 gmc-feed-exclude.js | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 166 insertions(+)

diff --git a/gmc-feed-exclude.js b/gmc-feed-exclude.js
new file mode 100644
index 0000000..9975202
--- /dev/null
+++ b/gmc-feed-exclude.js
@@ -0,0 +1,166 @@
+#!/usr/bin/env node
+/**
+ * GMC $4.25 feed-exclusion — STAGED FIX (Shopify-side, Steve-controllable).
+ *
+ * Root cause: Google Shopping feeds the MIN variant price; on DW the $4.25
+ * "-Sample" variant is almost always the min, so ~62k active products would
+ * advertise at $4.25. GMC's auto-add (June 18) pulls from products published
+ * to the Shopify "Google & YouTube" channel (publication 29646651457).
+ *
+ * Surgical fix: UNPUBLISH the mispriced products (Bucket A+B: min-variant price
+ * == $4.25) from the Google & YouTube channel ONLY. They leave the feed → GMC
+ * has nothing to auto-add. Online Store + all other channels are untouched.
+ * Does NOT require the wallsandfabrics@gmail.com Merchant Center login — Steve
+ * fires this from the Shopify admin he already controls.
+ *
+ *   node gmc-feed-exclude.js              # DRY-RUN (default): read-only scan,
+ *                                         #   counts + writes worklist JSON. No writes to Shopify.
+ *   node gmc-feed-exclude.js --apply      # GATED: actually unpublishes from Google channel.
+ *                                         #   Requires --yes-i-am-steve to run. DO NOT run autonomously.
+ *
+ * Cost: $0 (Shopify Admin API, no per-call charge; no LLM/paid API).
+ */
+const fs = require('fs');
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const VERSION = '2024-10';
+const ENV = fs.readFileSync('/Users/stevestudio2/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+const URL = `https://${SHOP}/admin/api/${VERSION}/graphql.json`;
+const SAMPLE_PRICE = 4.25;
+const GOOGLE_PUBLICATION_ID = 'gid://shopify/Publication/29646651457'; // "Google & YouTube"
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const CONFIRMED = args.includes('--yes-i-am-steve');
+const WORKLIST = '/Users/stevestudio2/.claude/yolo-queue/gmc-feed-exclude-worklist.json';
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables) {
+  for (let attempt = 0; attempt < 7; attempt++) {
+    let res;
+    try {
+      res = await fetch(URL, {
+        method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables }),
+      });
+    } catch (e) { await sleep(2000 * (attempt + 1)); continue; }
+    if (res.status === 429) { await sleep(2000 * (attempt + 1)); continue; }
+    const json = await res.json();
+    if (json.errors) {
+      if (JSON.stringify(json.errors).includes('THROTTLED')) { await sleep(2000 * (attempt + 1)); continue; }
+      throw new Error(JSON.stringify(json.errors));
+    }
+    const avail = json.extensions?.cost?.throttleStatus?.currentlyAvailable ?? 4000;
+    if (avail < 400) await sleep(1500);
+    return json.data;
+  }
+  throw new Error('exhausted retries');
+}
+
+// Scan query: id, vendor, variant prices, and whether currently on the Google channel.
+const QUERY = `
+query($cursor: String) {
+  products(first: 100, after: $cursor, query: "status:active") {
+    pageInfo { hasNextPage endCursor }
+    nodes {
+      id
+      title
+      vendor
+      onGoogle: publishedOnPublication(publicationId: "${GOOGLE_PUBLICATION_ID}")
+      variants(first: 60) { nodes { title price } }
+    }
+  }
+}`;
+
+function minVariantPrice(variants) {
+  let min = Infinity;
+  for (const v of variants) { const p = parseFloat(v.price); if (!isNaN(p)) min = Math.min(min, p); }
+  return min;
+}
+function hasRealRoll(variants) {
+  // a non-sample, priced variant above the sample price exists
+  return variants.some(v => parseFloat(v.price) > SAMPLE_PRICE);
+}
+
+async function scan() {
+  let cursor = null, total = 0, pages = 0;
+  const targets = []; // Bucket A+B products (min-variant == 4.25) currently ON the Google channel
+  let aPlusB = 0, onGoogleAB = 0, notOnGoogleAB = 0;
+  const t0 = Date.now();
+  while (true) {
+    const data = await gql(QUERY, { cursor });
+    const conn = data.products;
+    for (const n of conn.nodes) {
+      total++;
+      const vs = n.variants.nodes;
+      const min = minVariantPrice(vs);
+      const feeds425 = Math.abs(min - SAMPLE_PRICE) < 0.005 || min < SAMPLE_PRICE + 0.005;
+      if (feeds425) {
+        aPlusB++;
+        if (n.onGoogle) { onGoogleAB++; targets.push({ id: n.id, title: n.title, vendor: n.vendor, min, bucket: hasRealRoll(vs) ? 'B' : 'A' }); }
+        else notOnGoogleAB++;
+      }
+    }
+    pages++;
+    if (pages % 50 === 0) process.stderr.write(`  ...${pages} pages, ${total} products, AB=${aPlusB} (onGoogle=${onGoogleAB})\n`);
+    if (!conn.pageInfo.hasNextPage) break;
+    cursor = conn.pageInfo.endCursor;
+  }
+  const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+  return { total, pages, elapsed, aPlusB, onGoogleAB, notOnGoogleAB, targets };
+}
+
+async function unpublish(ids) {
+  // publishableUnpublish from the Google & YouTube channel only.
+  let done = 0, failed = 0;
+  for (const id of ids) {
+    try {
+      const r = await gql(
+        `mutation($id:ID!,$pubs:[PublicationInput!]!){ publishableUnpublish(id:$id, input:$pubs){ userErrors{ message } } }`,
+        { id, pubs: [{ publicationId: GOOGLE_PUBLICATION_ID }] }
+      );
+      const ue = r.publishableUnpublish?.userErrors;
+      if (ue && ue.length) { failed++; if (failed <= 5) console.error('  ERR', id, ue[0].message); }
+      else done++;
+    } catch (e) { failed++; if (failed <= 5) console.error('  ERR', id, e.message); }
+    if (done % 200 === 0 && done) console.error(`  unpublished ${done}...`);
+  }
+  return { done, failed };
+}
+
+(async () => {
+  console.log(`GMC feed-exclusion — ${APPLY ? 'APPLY' : 'DRY-RUN'} mode`);
+  console.log(`Target: unpublish active products with min-variant price <= $${SAMPLE_PRICE} from "Google & YouTube" (pub 29646651457)\n`);
+  const r = await scan();
+  console.log(`\nScanned ${r.total} active products in ${r.elapsed}s (${r.pages} pages).`);
+  console.log(`Bucket A+B (would feed $4.25): ${r.aPlusB}`);
+  console.log(`  • currently ON Google channel (in the feed → WOULD be excluded): ${r.onGoogleAB}`);
+  console.log(`  • NOT on Google channel (already absent from feed): ${r.notOnGoogleAB}`);
+  fs.writeFileSync(WORKLIST, JSON.stringify({
+    generated_at: new Date().toISOString(),
+    google_publication_id: GOOGLE_PUBLICATION_ID,
+    scanned_active: r.total,
+    bucket_a_plus_b: r.aPlusB,
+    on_google_would_exclude: r.onGoogleAB,
+    not_on_google: r.notOnGoogleAB,
+    targets: r.targets,
+  }, null, 2));
+  console.log(`\nWorklist written: ${WORKLIST} (${r.targets.length} product IDs)`);
+
+  if (!APPLY) {
+    console.log(`\nDRY-RUN only — zero writes to Shopify. To apply (GATED): node gmc-feed-exclude.js --apply --yes-i-am-steve`);
+    return;
+  }
+  if (!CONFIRMED) {
+    console.error(`\nREFUSING to apply without --yes-i-am-steve. This unpublishes ${r.onGoogleAB} products from the Google channel. Aborting.`);
+    process.exit(2);
+  }
+  console.log(`\nAPPLYING: unpublishing ${r.targets.length} products from Google & YouTube...`);
+  const res = await unpublish(r.targets.map(t => t.id));
+  console.log(`Done: ${res.done} unpublished, ${res.failed} failed.`);
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });

← 6e2c6a1 Stage banned-word Wallpaper->Wallcovering rewrite PLAN + AI-  ·  back to Dw Yolo Loop  ·  Stage Kravet below-MAP fix-list (208 ROLL-eligible, 2026-06- 21bdbc0 →