← 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}`);
})();