← back to Claude Webdev Accelerator

scripts/pull-shopify-catalog.js

75 lines

// READ-ONLY Shopify catalog puller. Reads creds from the master secrets .env internally
// (never prints the token), fetches active products + variant IDs from the LIVE store,
// and writes data/shopify-catalog.json for the preview apps to build cart permalinks.
// No writes to Shopify. Usage: node scripts/pull-shopify-catalog.js [limit]
const fs = require('fs'), path = require('path'), https = require('https');
const LIMIT = parseInt(process.argv[2] || '120', 10);
const ENV = path.join(process.env.HOME, 'Projects/secrets-manager/.env');

function envVal(k) {
  const line = fs.readFileSync(ENV, 'utf8').split('\n').find(l => l.startsWith(k + '='));
  return line ? line.slice(k.length + 1).replace(/^["']|["'\r]$/g, '') : '';
}
const TOKEN = envVal('SHOPIFY_ADMIN_TOKEN');
let STORE = (envVal('SHOPIFY_STORE_DOMAIN') || envVal('SHOPIFY_STORE') || 'designer-laboratory-sandbox').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
if (!STORE.includes('.')) STORE += '.myshopify.com';
function numericVariant(raw) {                    // cart permalinks require the NUMERIC id, not a gid/base64
  if (!raw) return null;
  let s = String(raw);
  if (/^[A-Za-z0-9+/]+=*$/.test(s) && !/^\d+$/.test(s)) {
    try { const dec = Buffer.from(s, 'base64').toString('utf8'); if (dec.includes('gid://')) s = dec; } catch {}
  }
  const m = s.match(/(\d+)\s*$/); return m ? m[1] : null;
}
const SAMPLE_VARIANT = numericVariant(envVal('SHOPIFY_SAMPLE_VARIANT_ID'));
const API = '2024-10';
if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN in secrets .env'); process.exit(1); }

function api(pathq) {
  return new Promise((res, rej) => {
    https.get({ host: STORE, path: `/admin/api/${API}${pathq}`, headers: { 'X-Shopify-Access-Token': TOKEN, 'Accept': 'application/json' } },
      r => { let b = ''; r.on('data', d => b += d); r.on('end', () => { try { res({ status: r.statusCode, json: JSON.parse(b || '{}') }); } catch (e) { rej(e); } }); }).on('error', rej);
  });
}

(async () => {
  const shop = await api('/shop.json');
  if (shop.status !== 200) { console.error('shop.json failed:', shop.status); process.exit(1); }
  const checkoutDomain = shop.json.shop.domain || STORE;              // primary online domain for cart permalinks
  const p = await api(`/products.json?status=active&limit=${Math.min(LIMIT, 250)}`);
  if (p.status !== 200) { console.error('products.json failed:', p.status); process.exit(1); }
  // Pick the REAL roll variant, not the $4.25 memo-sample variant (avoids the $4.25-price leak).
  function chooseVariant(variants) {
    const real = variants.filter(v => (parseFloat(v.price) || 0) > 4.25 && !/sample|memo/i.test(v.title || ''));
    const pool = real.length ? real : variants;
    return pool.reduce((a, b) => ((parseFloat(b.price) || 0) > (parseFloat(a.price) || 0) ? b : a));
  }
  const products = (p.json.products || [])
    .filter(pr => pr.variants && pr.variants.length && pr.published_at)   // web-published only (checkout works)
    .map(pr => {
      const v = chooseVariant(pr.variants);
      const img = (pr.image && pr.image.src) || (pr.images && pr.images[0] && pr.images[0].src) || '';
      const price = parseFloat(v.price) || 0;
      // sample-only: only the $4.25 memo sample is web-published; no real roll price on the Online Store.
      const sampleOnly = price <= 4.25;
      return {
        id: pr.id, handle: pr.handle, title: pr.title, vendor: pr.vendor, type: pr.product_type,
        image: img, sku: v.sku || '', variant_id: v.id,
        price: sampleOnly ? null : price,                 // null (not $4.25) so apps never show a misleading price
        sample_only: sampleOnly,
        buyable: !sampleOnly,                             // sample-only → no cart handoff; link to the product page instead
        price_display: sampleOnly ? 'Roll price on request' : ('$' + price.toFixed(2)),
        product_url: `https://${checkoutDomain}/products/${pr.handle}`,
      };
    });   // KEEP all products; sample-only ones are labeled 'by request' rather than dropped (Steve, 2026-07-16)
  const out = {
    _note: 'READ-ONLY pull from the LIVE DW Shopify store. Cart permalinks hand off to Shopify-hosted checkout.',
    pulled_at: new Date().toISOString(),
    shop_domain: STORE, checkout_domain: checkoutDomain, sample_variant_id: SAMPLE_VARIANT || null,
    count: products.length, products,
  };
  const dir = path.join(__dirname, '..', 'data'); fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(path.join(dir, 'shopify-catalog.json'), JSON.stringify(out, null, 2));
  console.log(`wrote data/shopify-catalog.json — ${products.length} web-published products from ${checkoutDomain} (sample_variant ${SAMPLE_VARIANT ? 'present' : 'MISSING'})`);
})();