← back to Dw Unbuyable Recovery Pilot

tk11252-hollywood/enumerate-unbuyable.mjs

93 lines

#!/usr/bin/env node
// Complete enumeration of ACTIVE Hollywood Wallcoverings products and their FULL variant lists.
// Keys "unbuyable" on "has no NON-sample variant" — NOT on option1==='Sample',
// because 3 known members carry option value 'Default Title' and would be invisible to that test.
import fs from 'node:fs';

const env = Object.fromEntries(
  fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
    .split('\n').filter(l => l.includes('=') && !l.trim().startsWith('#'))
    .map(l => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; })
);
const SHOP = env.SHOPIFY_STORE_DOMAIN || env.SHOPIFY_STORE;
const TOKEN = env.SHOPIFY_ADMIN_TOKEN;
const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;

async function gql(query, variables) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const r = await fetch(ENDPOINT, {
      method: 'POST',
      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables })
    });
    if (r.status === 429) { await new Promise(s => setTimeout(s, 2000 * (attempt + 1))); continue; }
    const j = await r.json();
    if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors));
    return j.data;
  }
  throw new Error('rate limited out');
}

const Q = `query($cursor: String) {
  products(first: 50, after: $cursor, query: "vendor:'Hollywood Wallcoverings' AND status:active") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id title handle status vendor productType createdAt
      options { name values }
      variants(first: 50) {
        nodes { id title sku price inventoryPolicy
                inventoryItem { tracked }
                selectedOptions { name value } }
      }
    }
  }
}`;

const isSampleVariant = v => {
  const sku = (v.sku || '').toLowerCase();
  const title = (v.title || '').toLowerCase();
  return sku.endsWith('-sample') || sku.endsWith('_sample') || title.includes('sample');
};

let cursor = null, pages = 0, all = [];
do {
  const d = await gql(Q, { cursor });
  all.push(...d.products.nodes);
  cursor = d.products.pageInfo.hasNextPage ? d.products.pageInfo.endCursor : null;
  pages++;
  process.stderr.write(`page ${pages} · scanned ${all.length}\r`);
} while (cursor);
process.stderr.write('\n');

// Independent total, so a truncated walk cannot masquerade as a complete one.
const countRes = await fetch(
  `https://${SHOP}/admin/api/2024-10/products/count.json?vendor=${encodeURIComponent('Hollywood Wallcoverings')}&status=active`,
  { headers: { 'X-Shopify-Access-Token': TOKEN } }
).then(r => r.json());

const unbuyable = all.filter(p => !p.variants.nodes.some(v => !isSampleVariant(v)));

const buyable = all.filter(p => p.variants.nodes.some(v => !isSampleVariant(v)));
fs.writeFileSync('./all-live-slim.json', JSON.stringify(all.map(p=>({id:p.id,title:p.title,productType:p.productType,options:p.options,variants:p.variants.nodes.map(v=>({sku:v.sku,title:v.title,price:v.price,inventoryPolicy:v.inventoryPolicy,tracked:v.inventoryItem?.tracked,selectedOptions:v.selectedOptions}))})), null, 0));
const out = {
  measured_at: new Date().toISOString(),
  pages,
  scanned: all.length,
  rest_count_json: countRes.count,
  complete: all.length === countRes.count,
  unbuyable_n: unbuyable.length,
  unbuyable: unbuyable.map(p => ({
    id: p.id, title: p.title, handle: p.handle, productType: p.productType,
    createdAt: p.createdAt,
    options: p.options.map(o => ({ name: o.name, values: o.values })),
    variants: p.variants.nodes.map(v => ({
      id: v.id, title: v.title, sku: v.sku, price: v.price,
      inventoryPolicy: v.inventoryPolicy, tracked: v.inventoryItem?.tracked,
      selectedOptions: v.selectedOptions
    }))
  }))
};
fs.writeFileSync('./unbuyable-live.json', JSON.stringify(out, null, 2));
console.log(`scanned=${all.length} rest_count=${countRes.count} complete=${out.complete} unbuyable=${unbuyable.length}`);
if (!out.complete) console.log('WARNING: walk INCOMPLETE — treat unbuyable count as a floor, not a fact.');