← back to Imageless Rescue
scripts/audit-active-gate.js
161 lines
#!/usr/bin/env node
'use strict';
/**
* READ-ONLY live audit: scan the LIVE ACTIVE catalog for activation-gate
* violations — ACTIVE products missing SPECS (no width metafield) OR a real
* DESCRIPTION OR an IMAGE. Groups by vendor × violation type with counts.
*
* Uses a Shopify bulkOperation (async, not rate-limited) to export every ACTIVE
* product with the gate fields, then evaluates each against the same shared
* validator the activation chokepoints use (no all_images cross-check available
* here — that's a vendor-row join — so IMAGE check is "has >=1 image").
*
* Output: data/active-gate-violations-<date>.json + a printed table.
* NO writes to Shopify or dw_unified.
*/
const https = require('https');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { validateBeforeActivate } = require(path.join(os.homedir(),
'Projects/Designer-Wallcoverings/shopify/scripts/lib/validate-before-activate.js'));
const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
const TODAY = new Date().toISOString().slice(0, 10);
const OUT = path.join(__dirname, '..', 'data', `active-gate-violations-${TODAY}.json`);
const sleep = ms => new Promise(r => setTimeout(r, ms));
function gql(query, variables) {
return new Promise(res => {
const data = JSON.stringify({ query, variables: variables || {} });
const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
r => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{ try{res(JSON.parse(d))}catch{res({_raw:d.slice(0,300)})} }); });
req.on('error', e => res({ _err: e.message }));
req.write(data); req.end();
});
}
// Stream the (potentially multi-GB) bulk JSONL to disk — never buffer in a JS
// string (>512MB blows the string length cap).
function downloadTo(u, dest) {
return new Promise((res, rej) => {
const f = fs.createWriteStream(dest);
https.get(u, r => { r.pipe(f); f.on('finish', () => f.close(() => res(dest))); }).on('error', rej);
});
}
// Bulk query: every ACTIVE product + its images, variants, and the spec metafields.
const BULK = `mutation {
bulkOperationRunQuery(query: """
{
products(query: "status:active") {
edges { node {
id title vendor descriptionHtml
images(first: 5) { edges { node { url } } }
variants(first: 5) { edges { node { sku price } } }
metafields(first: 30) { edges { node { namespace key value } } }
} }
}
}
""") { bulkOperation { id status } userErrors { field message } }
}`;
(async () => {
if (!TOKEN) { console.error('no token'); process.exit(1); }
const start = await gql(BULK);
const ue = start.data?.bulkOperationRunQuery?.userErrors;
if (ue && ue.length) { console.error('bulk start error', JSON.stringify(ue)); process.exit(1); }
console.log('bulk op started, polling…');
let url = null;
for (let i = 0; i < 240; i++) {
await sleep(5000);
const p = await gql(`{ currentBulkOperation { id status errorCode objectCount url } }`);
const op = p.data?.currentBulkOperation;
if (!op) continue;
process.stdout.write(`\r ${op.status} objects=${op.objectCount || 0} `);
if (op.status === 'COMPLETED') { url = op.url; break; }
if (['FAILED','CANCELED'].includes(op.status)) { console.error('\nbulk', op.status, op.errorCode); process.exit(1); }
}
console.log('');
if (!url) { console.error('bulk did not complete in time'); process.exit(1); }
const readline = require('readline');
const jsonlPath = path.join(__dirname, '..', 'data', `active-bulk-${TODAY}.jsonl`);
console.log('downloading bulk JSONL…');
await downloadTo(url, jsonlPath);
console.log(` saved ${(fs.statSync(jsonlPath).size/1e6).toFixed(0)} MB; parsing…`);
// The bulk export is JSONL; top-level Product rows have id gid://shopify/Product
// and child connection rows carry __parentId. Reassemble per-product, streamed.
const products = new Map();
await new Promise((resolve) => {
const rl = readline.createInterface({ input: fs.createReadStream(jsonlPath), crlfDelay: Infinity });
rl.on('line', (line) => {
if (!line.trim()) return;
let o; try { o = JSON.parse(line); } catch { return; }
const id = o.id || '';
if (id.includes('/Product/') && o.title !== undefined) {
products.set(id, { id, title:o.title, vendor:o.vendor||'(none)', descriptionHtml:o.descriptionHtml||'', images:[], variants:[], metafields:[] });
} else if (o.__parentId && products.has(o.__parentId)) {
const p = products.get(o.__parentId);
if (o.url) p.images.push(o.url);
else if (o.sku !== undefined || o.price !== undefined) p.variants.push({ sku:o.sku, price:o.price });
else if (o.namespace !== undefined) p.metafields.push({ namespace:o.namespace, key:o.key, value:o.value });
}
});
rl.on('close', resolve);
});
const mfVal = (mfs, ns, key) => { const m = mfs.find(x => x.namespace===ns && x.key===key); return m ? m.value : ''; };
const violations = [];
let activeTotal = 0;
for (const p of products.values()) {
activeTotal++;
const mfs = p.metafields;
const g = validateBeforeActivate({
title: p.title, dwSku: '',
descriptionHtml: p.descriptionHtml,
specs: {
width: mfVal(mfs,'global','width') || mfVal(mfs,'custom','width'),
length: mfVal(mfs,'global','length'),
repeat: mfVal(mfs,'global','repeat'),
material: mfVal(mfs,'global','material') || mfVal(mfs,'custom','material'),
unitOfMeasure: mfVal(mfs,'global','unit_of_measure'),
},
vendorSpecs: {}, // no vendor-row join in this audit; we only flag width-missing for specs
images: p.images,
vendorImages: p.images, // no cross-check; image gate = has >=1
variants: p.variants.length ? p.variants : [{ sku: 'x-Sample' }], // ignore sample-variant gate in audit (no DW SKU here)
});
// classify ONLY the three gate dimensions of interest (specs/desc/image)
const types = [];
if (g.tags.includes('Needs-Specs') || g.tags.includes('Needs-Width')) types.push('specs');
if (g.tags.includes('Needs-Description')) types.push('description');
if (g.tags.includes('Needs-Image')) types.push('image');
if (types.length) violations.push({ id:p.id, title:p.title, vendor:p.vendor, types, reasons:g.reasons.filter(r=>!/sample variant/i.test(r)), images:p.images.length });
}
// group by vendor × type
const byVendorType = {};
const byType = { specs:0, description:0, image:0 };
for (const v of violations) {
byVendorType[v.vendor] = byVendorType[v.vendor] || { specs:0, description:0, image:0, total:0 };
for (const t of v.types) { byVendorType[v.vendor][t]++; byType[t]++; }
byVendorType[v.vendor].total++;
}
const result = { date: TODAY, activeTotal, violationProducts: violations.length, byType, byVendorType, violations };
fs.writeFileSync(OUT, JSON.stringify(result, null, 2));
console.log(`\nACTIVE scanned: ${activeTotal}`);
console.log(`ACTIVE with >=1 gate violation: ${violations.length}`);
console.log(` specs(width)-missing: ${byType.specs} · description-missing/placeholder: ${byType.description} · image-missing: ${byType.image}`);
console.log('\nVendor × violation (top 25 by total):');
console.log('vendor'.padEnd(34), 'specs'.padStart(6), 'desc'.padStart(6), 'img'.padStart(6), 'total'.padStart(7));
Object.entries(byVendorType).sort((a,b)=>b[1].total-a[1].total).slice(0,25).forEach(([v,c]) => {
console.log(v.slice(0,33).padEnd(34), String(c.specs).padStart(6), String(c.description).padStart(6), String(c.image).padStart(6), String(c.total).padStart(7));
});
console.log(`\nwrote ${OUT}`);
})();