← back to Dw Yolo Loop
gmc-feed-exclude.js
186 lines
#!/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/macstudio3/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');
// --orphans-only (2026-08-10, TK-10446): exclude ONLY true orphans (Bucket A =
// min-price $4.25 AND NO real roll variant), NOT Bucket B (has a sellable roll —
// those must stay on Google and get the roll-price feed fix instead). Bucket is
// computed LIVE from actual variant prices (hasRealRoll), so this does NOT rely on
// the mirror's stale has_product_variant flag. Without the flag, legacy behavior
// (A+B, ~80k) is preserved.
const ORPHANS_ONLY = args.includes('--orphans-only');
const WORKLIST = '/Users/macstudio3/.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();
// Split the on-Google $4.25 targets by bucket (live-verified via hasRealRoll):
// A = TRUE orphan (no sellable roll) — safe to exclude from Google
// B = HAS a real roll but $4.25 is min — do NOT delist; needs roll-price feed fix
const bucketA = r.targets.filter(t => t.bucket === 'A');
const bucketB = r.targets.filter(t => t.bucket === 'B');
console.log(`\nScanned ${r.total} active products in ${r.elapsed}s (${r.pages} pages).`);
console.log(`On Google channel & feeding $4.25: ${r.onGoogleAB}`);
console.log(` • Bucket A — TRUE orphan (sample-only, no sellable roll): ${bucketA.length}`);
console.log(` • Bucket B — HAS a real roll (leave on Google; needs roll-price feed fix): ${bucketB.length}`);
if (ORPHANS_ONLY) {
r.targets = bucketA; // C-orphans: exclude ONLY the true orphans
console.log(`\n--orphans-only: targeting Bucket A only (${bucketA.length} products). Bucket B untouched.`);
} else {
console.log(`\n(legacy A+B mode — would exclude all ${r.onGoogleAB}. Use --orphans-only to limit to Bucket A.)`);
}
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.`);
console.log(`To apply C-orphans (GATED): node gmc-feed-exclude.js --orphans-only --apply --yes-i-am-steve`);
return;
}
if (!CONFIRMED) {
console.error(`\nREFUSING to apply without --yes-i-am-steve. This unpublishes ${r.targets.length} 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); });