← back to Zero Price Draft Disarm

measure.mjs

92 lines

#!/usr/bin/env node
// TK-12276 — READ-ONLY re-measure of $0 NON-sample sellable variants on DRAFT products
// that are "armed" (qty>0, or inventoryPolicy CONTINUE, or untracked) — i.e. would be
// instantly orderable at $0 the moment the product is set ACTIVE.
// Paged GraphQL QUERIES only (no mutations, no bulk op). Token: SHOPIFY_ADMIN_TOKEN.
// Output: data/draft-variants.jsonl (every draft variant) + data/armed.json (the armed set).
import fs from 'node:fs';
import path from 'node:path';

const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const DIR = path.dirname(new URL(import.meta.url).pathname);
const DATA = path.join(DIR, 'data');
fs.mkdirSync(DATA, { recursive: true });

function token() {
  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
  const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
  const m = env.match(/^SHOPIFY_ADMIN_TOKEN=["']?([^"'\n]+)/m);
  if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN missing');
  return m[1];
}
const TOK = token();

async function gql(query, variables) {
  for (let attempt = 0; attempt < 6; attempt++) {
    const r = await fetch(`https://${SHOP}/admin/api/${API}/graphql.json`, {
      method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOK, 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables }),
    });
    if (r.status === 429 || r.status >= 500) { await new Promise(s => setTimeout(s, 2000 * (attempt + 1))); continue; }
    if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
    const j = await r.json();
    if (j.errors?.some(e => e.extensions?.code === 'THROTTLED')) { await new Promise(s => setTimeout(s, 3000)); continue; }
    if (j.errors) throw new Error(JSON.stringify(j.errors));
    const t = j.extensions?.cost?.throttleStatus;
    if (t && t.currentlyAvailable < 1500) await new Promise(s => setTimeout(s, 4000));
    return j.data;
  }
  throw new Error('gql retries exhausted');
}

const Q = `query($after:String){ products(first:40, after:$after, query:"status:draft"){
  pageInfo{ hasNextPage endCursor }
  nodes{ id title vendor handle status createdAt tags
    variants(first:25){ nodes{ id sku title price inventoryPolicy inventoryQuantity
      inventoryItem{ id tracked } } } } } }`;

const isSample = v => /sample|memo/i.test(v.title || '') || /-sample$/i.test(v.sku || '');

const out = fs.createWriteStream(path.join(DATA, 'draft-variants.jsonl'));
let after = null, products = 0, variants = 0, page = 0;
const armed = [];
const t0 = Date.now();
do {
  const d = await gql(Q, { after });
  const p = d.products;
  for (const prod of p.nodes) {
    products++;
    for (const v of prod.variants.nodes) {
      variants++;
      const row = { product_id: prod.id, vendor: prod.vendor, handle: prod.handle, title: prod.title,
        status: prod.status, created_at: prod.createdAt, variant_id: v.id, sku: v.sku, vtitle: v.title,
        price: Number(v.price), policy: v.inventoryPolicy, qty: v.inventoryQuantity,
        inventory_item_id: v.inventoryItem?.id, tracked: v.inventoryItem?.tracked, sample: isSample(v) };
      out.write(JSON.stringify(row) + '\n');
      const zero = !(row.price > 0);
      const orderable = (row.qty > 0) || row.policy === 'CONTINUE' || row.tracked === false;
      if (zero && !row.sample && orderable) armed.push(row);
    }
  }
  after = p.pageInfo.hasNextPage ? p.pageInfo.endCursor : null;
  if (++page % 25 === 0) console.error(`page ${page} products ${products} variants ${variants} armed ${armed.length} ${(Date.now()-t0)/1000|0}s`);
} while (after);
out.end();

const byVendor = {};
for (const a of armed) byVendor[a.vendor] = (byVendor[a.vendor] || 0) + 1;
const armedProducts = new Set(armed.map(a => a.product_id));
const summary = { measured_at: new Date().toISOString(), draft_products_scanned: products,
  draft_variants_scanned: variants, armed_variants: armed.length, armed_products: armedProducts.size,
  armed_by_vendor: Object.fromEntries(Object.entries(byVendor).sort((a, b) => b[1] - a[1])),
  by_reason: {
    qty_gt_0: armed.filter(a => a.qty > 0).length,
    policy_continue: armed.filter(a => a.policy === 'CONTINUE').length,
    untracked: armed.filter(a => a.tracked === false).length,
    qty_2026: armed.filter(a => a.qty === 2026).length },
  variants_truncated_note: 'variants(first:25) per product' };
fs.writeFileSync(path.join(DATA, 'armed.json'), JSON.stringify({ summary, armed }, null, 1));
console.log(JSON.stringify(summary, null, 2));