← back to Greenland Onboard
scripts/publish-samples.mjs
76 lines
#!/usr/bin/env node
// LIVE publish: each enriched SKU → ACTIVE Shopify product, single "Sample" variant @ $4.25,
// quote-only ("Call or Email for Quote" via the `quotes` tag), local image base64-attached.
// Per-SKU dup guard (GraphQL sku: query) so a re-run never double-creates. Updates
// greenland_full_catalog with shopify_product_id + status. Throttled + 429-retry.
// node scripts/publish-samples.mjs <limit> [startIndex] (limit=0 → all)
import { readFileSync, appendFileSync, existsSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const env = readFileSync(`${process.env.HOME}/Projects/secrets-manager/.env`, 'utf8');
const pick = (k) => (env.match(new RegExp(`^${k}=(.*)$`, 'm'))?.[1] || '').replace(/^['"]|['"]$/g, '').trim();
const STORE = pick('SHOPIFY_STORE') || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = pick('SHOPIFY_ADMIN_TOKEN');
const API = `https://${STORE}/admin/api/2024-10`;
const DB = 'postgresql:///dw_unified?host=/tmp&user=stevestudio2';
const LIMIT = Number(process.argv[2] || 0);
const START = Number(process.argv[3] || 0);
const LEDGER = join(ROOT, 'data', 'published.ndjson');
const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function sh(path, opts = {}, tries = 5) {
for (let i = 0; i < tries; i++) {
const r = await fetch(`${API}${path}`, { headers: H, ...opts });
if (r.status === 429) { await sleep(2000 * (i + 1)); continue; }
const t = await r.text();
if (!r.ok) throw new Error(`${r.status} ${path} :: ${t.slice(0, 300)}`);
return t ? JSON.parse(t) : {};
}
throw new Error(`429 exhausted ${path}`);
}
async function gql(query) {
const r = await fetch(`${API}/graphql.json`, { method: 'POST', headers: H, body: JSON.stringify({ query }) });
return (await r.json()).data;
}
async function skuExists(sku) {
const d = await gql(`{ productVariants(first:1, query:"sku:${sku}"){ edges{ node{ id product{ id legacyResourceId } } } } }`);
const e = d?.productVariants?.edges?.[0];
return e ? e.node.product.legacyResourceId : null;
}
const b64 = (fp) => existsSync(fp) ? execSync(`base64 -i "${fp}"`, { maxBuffer: 64e6 }).toString().replace(/\n/g, '') : null;
const rows = JSON.parse(readFileSync(join(ROOT, 'data', 'enriched.json'), 'utf8'));
const slice = rows.slice(START, LIMIT ? START + LIMIT : undefined);
console.log(`store=${STORE} publishing ${slice.length} of ${rows.length} (start=${START})`);
let created = 0, skipped = 0, failed = 0;
for (const r of slice) {
try {
const existing = await skuExists(r.dwSku);
if (existing) { skipped++; console.log(` skip ${r.dwSku} (exists ${existing})`);
execSync(`psql "${DB}" -tAc "update greenland_full_catalog set shopify_product_id=${existing}, status='ACTIVE' where dw_sku='${r.dwSku}'"`); continue; }
const img = b64(join(ROOT, 'public', 'img', `${r.dwSku}.jpg`));
const tags = [...new Set([...(r.tags || []), 'quotes'])].join(', ');
const payload = { product: {
title: r.title, body_html: r.body_html, vendor: 'Phillipe Romano', product_type: 'Wallcovering',
status: 'active', tags,
options: [{ name: 'Size' }],
variants: [{ option1: 'Sample', price: '4.25', sku: r.dwSku, taxable: true, requires_shipping: true, inventory_policy: 'continue', fulfillment_service: 'manual', inventory_management: null }],
metafields: [{ namespace: 'custom', key: 'mfr_number', type: 'single_line_text_field', value: String(r.mfrModel || '') }],
images: img ? [{ attachment: img, alt: r.alt }] : [],
} };
const res = await sh(`/products.json`, { method: 'POST', body: JSON.stringify(payload) });
const pid = res.product.id;
execSync(`psql "${DB}" -tAc "update greenland_full_catalog set shopify_product_id=${pid}, status='ACTIVE' where dw_sku='${r.dwSku}'"`);
appendFileSync(LEDGER, JSON.stringify({ dwSku: r.dwSku, pid, title: r.title }) + '\n');
created++;
if (created % 20 === 0) console.log(` ...created ${created}`);
await sleep(550);
} catch (e) { failed++; console.log(` FAIL ${r.dwSku}: ${String(e.message).slice(0, 160)}`); await sleep(800); }
}
console.log(`\nDONE created=${created} skipped=${skipped} failed=${failed}`);