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