← 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'})`);
})();