← back to Yolo Agent
orphan-audit.js
261 lines
#!/usr/bin/env node
const path = require('path');
try { require('dotenv').config({ path: path.join(__dirname, '.env') }); } catch (_) {}
/**
* Orphan Product Audit — Finds active products not in any collection
* READ-ONLY: Does not modify any data
*/
const https = require('https');
const fs = require('fs');
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ORDERS_TOKEN;
const API_VERSION = '2024-01';
const DELAY_MS = 500;
if (!TOKEN) {
console.error('Missing SHOPIFY_ORDERS_TOKEN.');
process.exit(1);
}
function graphql(query, variables = {}) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ query, variables });
const options = {
hostname: SHOP,
path: `/admin/api/${API_VERSION}/graphql.json`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': TOKEN,
'Content-Length': Buffer.byteLength(body),
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`JSON parse error: ${data.substring(0, 200)}`));
}
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function fetchAllActiveProducts() {
const allProducts = [];
let cursor = null;
let page = 0;
while (true) {
page++;
const afterClause = cursor ? `, after: "${cursor}"` : '';
const query = `{
products(first: 250, query: "status:active"${afterClause}) {
edges {
node {
id
title
vendor
handle
collections(first: 10) {
edges {
node {
id
title
}
}
}
}
cursor
}
pageInfo {
hasNextPage
}
}
}`;
const result = await graphql(query);
if (result.errors) {
console.error(`Page ${page} errors:`, JSON.stringify(result.errors));
break;
}
const edges = result.data?.products?.edges || [];
if (edges.length === 0) break;
for (const edge of edges) {
const p = edge.node;
allProducts.push({
id: p.id,
title: p.title,
vendor: p.vendor,
handle: p.handle,
collectionCount: p.collections.edges.length,
collections: p.collections.edges.map((c) => c.node.title),
});
}
cursor = edges[edges.length - 1].cursor;
const hasNext = result.data.products.pageInfo.hasNextPage;
const cost = result.extensions?.cost;
console.log(
`Page ${page}: ${edges.length} products (total: ${allProducts.length}) | ` +
`Cost: ${cost?.actualQueryCost || '?'}/${cost?.throttleStatus?.currentlyAvailable || '?'} available`
);
if (!hasNext) break;
await sleep(DELAY_MS);
}
return allProducts;
}
async function run() {
console.log('=== Orphan Product Audit ===');
console.log(`Started: ${new Date().toISOString()}\n`);
// Fetch all active products with their collections
const products = await fetchAllActiveProducts();
console.log(`\nTotal active products fetched: ${products.length}`);
// Find orphans (products with no collections)
const orphans = products.filter((p) => p.collectionCount === 0);
console.log(`Orphaned products (no collection): ${orphans.length}`);
// Group orphans by vendor
const vendorMap = {};
for (const p of orphans) {
const vendor = p.vendor || '(no vendor)';
if (!vendorMap[vendor]) {
vendorMap[vendor] = { count: 0, examples: [] };
}
vendorMap[vendor].count++;
if (vendorMap[vendor].examples.length < 5) {
vendorMap[vendor].examples.push({
title: p.title,
handle: p.handle,
});
}
}
// Sort by count descending
const orphansByVendor = Object.entries(vendorMap)
.map(([vendor, data]) => ({
vendor,
count: data.count,
examples: data.examples,
}))
.sort((a, b) => b.count - a.count);
// Also compute vendor totals (all products, not just orphans)
const vendorTotals = {};
for (const p of products) {
const v = p.vendor || '(no vendor)';
vendorTotals[v] = (vendorTotals[v] || 0) + 1;
}
// Enrich orphansByVendor with total product count for that vendor
for (const entry of orphansByVendor) {
entry.totalActiveForVendor = vendorTotals[entry.vendor] || entry.count;
entry.percentOrphaned =
((entry.count / entry.totalActiveForVendor) * 100).toFixed(1) + '%';
}
const percentOrphaned = ((orphans.length / products.length) * 100).toFixed(2) + '%';
const report = {
timestamp: new Date().toISOString(),
totalActiveProducts: products.length,
totalOrphans: orphans.length,
percentOrphaned,
vendorsWithOrphans: orphansByVendor.length,
totalVendors: Object.keys(vendorTotals).length,
orphansByVendor,
};
// Save JSON report
const reportPath = path.join(__dirname, 'logs', 'orphan-product-report.json');
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`\nJSON report saved: ${reportPath}`);
// Generate markdown summary
const top20 = orphansByVendor.slice(0, 20);
let md = `# Orphan Product Audit Report\n\n`;
md += `**Date**: ${new Date().toISOString()}\n\n`;
md += `## Summary\n\n`;
md += `| Metric | Value |\n|--------|-------|\n`;
md += `| Total Active Products | ${products.length.toLocaleString()} |\n`;
md += `| Products with NO Collection | ${orphans.length.toLocaleString()} |\n`;
md += `| Percent Orphaned | ${percentOrphaned} |\n`;
md += `| Vendors with Orphans | ${orphansByVendor.length} |\n`;
md += `| Total Vendors | ${Object.keys(vendorTotals).length} |\n\n`;
md += `## Top ${top20.length} Vendors with Most Orphans\n\n`;
md += `| # | Vendor | Orphans | Total Active | % Orphaned |\n`;
md += `|---|--------|---------|--------------|------------|\n`;
top20.forEach((v, i) => {
md += `| ${i + 1} | ${v.vendor} | ${v.count} | ${v.totalActiveForVendor} | ${v.percentOrphaned} |\n`;
});
md += `\n## Example Orphaned Products (Top 5 Vendors)\n\n`;
top20.slice(0, 5).forEach((v) => {
md += `### ${v.vendor} (${v.count} orphans)\n`;
v.examples.forEach((ex) => {
md += `- ${ex.title} (handle: \`${ex.handle}\`)\n`;
});
md += `\n`;
});
// All vendors list
if (orphansByVendor.length > 20) {
md += `## All Vendors with Orphans\n\n`;
md += `| Vendor | Orphans | % of Vendor |\n|--------|---------|-------------|\n`;
orphansByVendor.forEach((v) => {
md += `| ${v.vendor} | ${v.count} | ${v.percentOrphaned} |\n`;
});
md += `\n`;
}
const summaryPath = path.join(__dirname, 'logs', 'orphan-product-summary.md');
fs.writeFileSync(summaryPath, md);
console.log(`Summary saved: ${summaryPath}`);
// Print quick summary
console.log(`\n=== RESULTS ===`);
console.log(`Active products: ${products.length}`);
console.log(`Orphans: ${orphans.length} (${percentOrphaned})`);
console.log(`Vendors with orphans: ${orphansByVendor.length}`);
console.log(`\nTop 10 vendors:`);
top20.slice(0, 10).forEach((v, i) => {
console.log(` ${i + 1}. ${v.vendor}: ${v.count} orphans (${v.percentOrphaned} of ${v.totalActiveForVendor})`);
});
// Return summary for Slack
return {
totalActive: products.length,
totalOrphans: orphans.length,
percentOrphaned,
vendorsWithOrphans: orphansByVendor.length,
top5: top20.slice(0, 5).map((v) => `${v.vendor}: ${v.count}`),
};
}
run()
.then((summary) => {
console.log('\n=== SLACK PAYLOAD ===');
console.log(JSON.stringify(summary));
})
.catch((err) => {
console.error('FATAL ERROR:', err);
process.exit(1);
});