← back to Majilite Quote Cta
scripts/gmc-exclude-majilite.js
117 lines
#!/usr/bin/env node
/**
* gmc-exclude-majilite.js — prepare the GMC (and sibling ad-channel) feed
* exclusion for the Majilite quote-only line so the $4.25 sample NEVER
* advertises as the product price on Google Merchant Center.
*
* WHY: every Majilite product's ONLY variant is the $4.25 sample. Google
* Shopping feeds the MIN variant price, so GMC would advertise $4.25 for
* material actually sold by the yard → price-mismatch DISAPPROVAL. Live check
* confirmed all Majilite are currently published to "Google & YouTube" (and
* Facebook & Instagram, Pinterest, Houzz, Rakuten).
*
* This mirrors the existing dw-yolo-loop/gmc-feed-exclude.js mechanism
* (unpublish mispriced products from the Google & YouTube channel), scoped to
* Majilite. DRY-RUN BY DEFAULT — read-only; writes a worklist of product GIDs +
* their ad-channel publication state. The live unpublish is Steve-gated.
*
* Usage:
* node scripts/gmc-exclude-majilite.js # DRY-RUN (default): scan + worklist
* node scripts/gmc-exclude-majilite.js --apply --yes-i-am-steve # GATED unpublish
*
* Cost: $0 (Shopify Admin API only).
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const STORE = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || (() => {
const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
return (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].replace(/["']/g, '').trim();
})();
const URL = `https://${STORE}/admin/api/${API}/graphql.json`;
// Ad channels that surface the $4.25 mismatch. Google & YouTube is the primary
// GMC disapproval risk; the others advertise the same feed price.
const AD_CHANNELS = {
'gid://shopify/Publication/29646651457': 'Google & YouTube',
};
// Optional broader set (Steve's call — see memo): Facebook & Instagram, Pinterest.
// Kept OUT of the default exclude to stay surgical; listed for the memo.
const args = process.argv.slice(2);
const APPLY = args.includes('--apply');
const CONFIRMED = args.includes('--yes-i-am-steve');
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function gql(query, variables) {
for (let attempt = 0; attempt < 6; attempt++) {
const res = await fetch(URL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
const j = await res.json();
if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(1500 * (attempt + 1)); continue; } throw new Error(JSON.stringify(j.errors).slice(0, 300)); }
return j.data;
}
throw new Error('throttled');
}
async function fetchAll() {
let cursor = null; const out = [];
for (;;) {
const after = cursor ? `, after: "${cursor}"` : '';
const d = await gql(`{ products(first: 60, query: "vendor:Majilite"${after}) {
pageInfo { hasNextPage endCursor }
edges { node { id handle title
variants(first: 5) { edges { node { price } } }
resourcePublications(first: 15) { edges { node { publication { id name } isPublished } } } } } } }`);
for (const e of d.products.edges) out.push(e.node);
if (!d.products.pageInfo.hasNextPage) break;
cursor = d.products.pageInfo.endCursor; await sleep(500);
}
return out;
}
(async () => {
console.log(`GMC-exclude Majilite ${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'} → ${STORE}`);
const prods = await fetchAll();
const worklist = [];
for (const p of prods) {
const prices = p.variants.edges.map(v => Number(v.node.price));
const minPrice = Math.min(...prices);
const onGoogle = p.resourcePublications.edges.find(e => AD_CHANNELS[e.node.publication.id] && e.node.isPublished);
worklist.push({
id: p.id, handle: p.handle, minVariantPrice: minPrice,
onGoogleYouTube: !!onGoogle,
publications: p.resourcePublications.edges.filter(e => e.node.isPublished).map(e => e.node.publication.name),
});
}
const toExclude = worklist.filter(w => w.onGoogleYouTube && w.minVariantPrice <= 4.25);
fs.mkdirSync(path.join(ROOT, 'data'), { recursive: true });
fs.writeFileSync(path.join(ROOT, 'data/gmc-exclude-worklist.json'), JSON.stringify({
at: new Date().toISOString(), channel: AD_CHANNELS, total: worklist.length,
toExclude: toExclude.length, worklist,
}, null, 2));
console.log(`Majilite products: ${worklist.length} | on Google & YouTube with $<=4.25 min: ${toExclude.length}`);
console.log(`Worklist → data/gmc-exclude-worklist.json`);
if (!APPLY) { console.log('\nDRY-RUN complete. No writes. Steve-gated: --apply --yes-i-am-steve to unpublish from Google & YouTube.'); return; }
if (!CONFIRMED) { console.error('\nRefusing to apply without --yes-i-am-steve.'); process.exit(2); }
const pubId = Object.keys(AD_CHANNELS)[0];
let done = 0, failed = 0;
const runs = [];
for (const w of toExclude) {
try {
const d = await gql(`mutation($id:ID!,$pid:ID!){ publishableUnpublish(id:$id, input:[{publicationId:$pid}]){ userErrors{ field message } } }`, { id: w.id, pid: pubId });
const ue = d.publishableUnpublish.userErrors;
if (ue && ue.length) { failed++; runs.push({ ...w, ok: false, err: ue }); }
else { done++; runs.push({ ...w, ok: true }); }
} catch (e) { failed++; runs.push({ ...w, ok: false, err: e.message.slice(0, 200) }); }
if ((done + failed) % 20 === 0) await sleep(1000);
}
fs.mkdirSync(path.join(ROOT, 'data/runs'), { recursive: true });
fs.writeFileSync(path.join(ROOT, 'data/runs', `gmc-${new Date().toISOString().replace(/[:.]/g, '-')}.json`), JSON.stringify({ done, failed, runs }, null, 2));
console.log(`\nAPPLIED unpublish from Google & YouTube. done=${done} failed=${failed}`);
})();