← back to Dw Yolo Loop

gmc-425-count.js

136 lines

#!/usr/bin/env node
// READ-ONLY GMC $4.25 feed-price blast-radius count. No writes anywhere except local result json.
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 token'); process.exit(1); }

const URL = `https://${SHOP}/admin/api/${VERSION}/graphql.json`;
const SAMPLE_PRICE = 4.25;

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) {
      const throttled = JSON.stringify(json.errors).includes('THROTTLED');
      if (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');
}

const QUERY = `
query($cursor: String) {
  products(first: 100, after: $cursor, query: "status:active") {
    pageInfo { hasNextPage endCursor }
    nodes {
      title
      vendor
      hasOnlyDefaultVariant
      variants(first: 60) {
        nodes { title price }
      }
    }
  }
}`;

(async () => {
  let cursor = null;
  let total = 0, pages = 0;

  let bucketA = 0; // sample-only
  let bucketB = 0; // multi-variant, min/feed is 4.25 but a real roll exists
  let bucketC = 0; // real sellable feed price
  let bucketD = 0; // no price / $0 only

  let feedMin425 = 0;
  let feedFirst425 = 0;

  const vendorCount = {};       // a+b by MIN model
  const vendorCountFirst = {};  // first-variant == 4.25

  const is425 = (x) => Math.abs(x - SAMPLE_PRICE) < 0.005;
  const startedAt = Date.now();

  while (true) {
    const data = await gql(QUERY, { cursor });
    const conn = data.products;
    pages++;
    for (const p of conn.nodes) {
      total++;
      const variants = p.variants.nodes || [];
      const prices = variants.map(v => parseFloat(v.price)).filter(x => !isNaN(x));
      if (prices.length === 0) { bucketD++; continue; }

      const firstPrice = prices[0];
      const minPrice = Math.min(...prices);
      const maxPrice = Math.max(...prices);
      const hasRealRoll = prices.some(x => x > SAMPLE_PRICE + 0.005);
      const minIs425 = is425(minPrice);
      const firstIs425 = is425(firstPrice);

      if (minIs425) feedMin425++;
      if (firstIs425) feedFirst425++;

      if (prices.every(x => x === 0)) {
        bucketD++;
      } else if (is425(maxPrice) && !hasRealRoll) {
        bucketA++;
        vendorCount[p.vendor] = (vendorCount[p.vendor] || 0) + 1;
      } else if (minIs425 && hasRealRoll) {
        bucketB++;
        vendorCount[p.vendor] = (vendorCount[p.vendor] || 0) + 1;
      } else if (minPrice === 0 && hasRealRoll) {
        bucketD++;
      } else {
        bucketC++;
      }

      if (firstIs425) vendorCountFirst[p.vendor] = (vendorCountFirst[p.vendor] || 0) + 1;
    }
    if (!conn.pageInfo.hasNextPage) break;
    cursor = conn.pageInfo.endCursor;
    if (pages % 10 === 0) {
      process.stderr.write(`  ...${total} products, ${pages} pages, ${((Date.now()-startedAt)/1000).toFixed(0)}s\n`);
    }
  }

  const result = {
    generated_at: new Date().toISOString(),
    total_active: total,
    pages,
    elapsed_s: ((Date.now() - startedAt) / 1000).toFixed(1),
    buckets_min_model: {
      a_sample_only: bucketA,
      b_has_real_roll_but_feeds_425: bucketB,
      c_real_feed_price_ok: bucketC,
      d_no_price_or_zero: bucketD,
    },
    feed_425_min_model: feedMin425,
    feed_425_first_model: feedFirst425,
    would_feed_425_a_plus_b: bucketA + bucketB,
    vendor_breakdown_a_plus_b: Object.entries(vendorCount).sort((x,y)=>y[1]-x[1]),
    vendor_breakdown_first_model: Object.entries(vendorCountFirst).sort((x,y)=>y[1]-x[1]),
  };
  console.log(JSON.stringify(result, null, 2));
  fs.writeFileSync('/Users/macstudio3/Projects/designerwallcoverings/gmc-425-result.json', JSON.stringify(result, null, 2));
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });