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