← back to Wolfgordon Crawl
Add clean catalog-driven Wolf Gordon Shopify pusher (dry-run default, --canary/--apply)
a9497fc8e8c759c74aec4607bfc5fb644aa62f7e · 2026-06-19 10:13:15 -0700 · Steve
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A deleted-audit.jsA push-shopify.js
Diff
commit a9497fc8e8c759c74aec4607bfc5fb644aa62f7e
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 19 10:13:15 2026 -0700
Add clean catalog-driven Wolf Gordon Shopify pusher (dry-run default, --canary/--apply)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
deleted-audit.js | 29 ++++
push-shopify.js | 478 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 507 insertions(+)
diff --git a/deleted-audit.js b/deleted-audit.js
new file mode 100644
index 0000000..dd4f39f
--- /dev/null
+++ b/deleted-audit.js
@@ -0,0 +1,29 @@
+const https=require('https');
+const {pool,closePool}=require('./scraper-utils');
+const UA='Mozilla/5.0 Chrome/120';
+function probe(handle){return new Promise(res=>{
+ const u=`https://www.designerwallcoverings.com/products/${handle}.js`;
+ https.get(u,{timeout:15000,headers:{'User-Agent':UA}},r=>{
+ let d='';r.on('data',c=>d+=c);r.on('end',()=>{
+ if(r.statusCode!==200) return res({live:false,code:r.statusCode});
+ try{const j=JSON.parse(d);res({live:true,avail:j.available,price:(j.variants&&j.variants[0]?j.variants[0].price/100:null)});}
+ catch{res({live:false,code:'nojson'});}
+ });
+ }).on('error',()=>res({live:false,code:'err'})).on('timeout',function(){this.destroy();res({live:false,code:'timeout'});});
+});}
+(async()=>{
+ // random sample of deleted rows that have a handle
+ const {rows}=await pool.query("SELECT dw_sku, handle FROM shopify_products WHERE status='DELETED_FROM_SHOPIFY' AND handle IS NOT NULL AND handle<>'' ORDER BY random() LIMIT 80");
+ console.log(`Probing ${rows.length} sampled 'deleted' rows against live Shopify...`);
+ let live=0,dead=0,i=0; const liveSamples=[];
+ const conc=6;
+ async function worker(){ while(i<rows.length){ const r=rows[i++]; const res=await probe(r.handle);
+ if(res.live){live++; if(liveSamples.length<10) liveSamples.push(`${r.dw_sku} $${res.price} avail=${res.avail}`);} else dead++;
+ }}
+ await Promise.all(Array.from({length:conc},worker));
+ console.log(`\n=== RESULT (sample of ${rows.length}) ===`);
+ console.log(`ACTUALLY LIVE (mirror wrong): ${live} (${(live/rows.length*100).toFixed(0)}%)`);
+ console.log(`Confirmed gone: ${dead}`);
+ console.log('live examples:'); liveSamples.forEach(s=>console.log(' '+s));
+ await closePool();
+})().catch(e=>{console.error(e);closePool();});
diff --git a/push-shopify.js b/push-shopify.js
new file mode 100644
index 0000000..e905a94
--- /dev/null
+++ b/push-shopify.js
@@ -0,0 +1,478 @@
+#!/usr/bin/env node
+// ============================================================================
+// Wolf Gordon -> Shopify pusher (LIVE store: designer-laboratory-sandbox)
+//
+// Catalog-driven, idempotent-by-SKU, image-ADD-only (never deletes), throttled,
+// resumable. DEFAULTS TO DRY-RUN — writes nothing unless --apply is passed.
+//
+// node push-shopify.js # dry-run, whole catalog plan
+// node push-shopify.js --canary # dry-run, the 10-item canary set
+// node push-shopify.js --canary --apply # LIVE writes, 10-item canary
+// node push-shopify.js --limit 50 # dry-run, first 50 actionable
+//
+// Source of truth = local dw_unified.wolf_gordon_catalog (PostgreSQL BEFORE
+// Shopify). Records the returned shopify_product_id back into the catalog.
+//
+// DW standing rules enforced:
+// - word "Wallpaper" BANNED -> "Wallcovering(s)".
+// - title format: "Pattern Real-Color | Wolf Gordon Wallcoverings", Title Case.
+// - NEVER "Unknown" in a title; fall back mfr_sku -> color -> skip.
+// - every product gets a Yard variant + a {DW_SKU}-Sample variant ($4.25, no
+// inventory tracking).
+// - NEVER ACTIVE without image AND width metafield (else draft + Needs-* tag).
+// - Discontinued = ARCHIVE (never delete).
+// - price = price_retail (already = round(cost/0.65/0.85, 2)).
+// - throttle ~2 req/s; >=90s gap between bulk batches (canary is one batch).
+// ============================================================================
+
+const { pool } = require('./scraper-utils');
+
+// ---------- config ----------
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const GQL = `https://${SHOP}/admin/api/${API}/graphql.json`;
+const REST = `https://${SHOP}/admin/api/${API}`;
+const VENDOR = 'Wolf Gordon';
+const SAMPLE_PRICE = '4.25';
+const THROTTLE_MS = 500; // ~2 req/s
+
+// ---------- args ----------
+const argv = process.argv.slice(2);
+const APPLY = argv.includes('--apply');
+const CANARY = argv.includes('--canary');
+const LIMIT = (() => {
+ const i = argv.indexOf('--limit');
+ return i !== -1 && argv[i + 1] ? parseInt(argv[i + 1], 10) : null;
+})();
+
+// ---------- token (master .env via secrets manager) ----------
+function loadToken() {
+ if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+ const fs = require('fs');
+ const p = require('os').homedir() + '/Projects/secrets-manager/.env';
+ const line = fs.readFileSync(p, 'utf8').split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
+ if (!line) throw new Error('SHOPIFY_ADMIN_TOKEN not found in secrets .env');
+ return line.slice('SHOPIFY_ADMIN_TOKEN='.length).replace(/^["']|["']$/g, '').trim();
+}
+const TOKEN = loadToken();
+
+const wait = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables) {
+ await wait(THROTTLE_MS);
+ const res = await fetch(GQL, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+ const j = await res.json();
+ if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors));
+ return j.data;
+}
+
+async function rest(method, path, body) {
+ await wait(THROTTLE_MS);
+ const res = await fetch(REST + path, {
+ method,
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const txt = await res.text();
+ let j; try { j = JSON.parse(txt); } catch { j = { _raw: txt }; }
+ if (!res.ok) throw new Error(`REST ${method} ${path} -> ${res.status}: ${txt.slice(0, 300)}`);
+ return j;
+}
+
+// ---------- normalization helpers (shared w/ reconcile.js) ----------
+function norm(s) {
+ if (!s) return '';
+ return String(s).toLowerCase().replace(/[®™©]/g, '').replace(/&/g, '&')
+ .replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
+}
+function nsku(s) {
+ if (!s) return '';
+ return String(s).toLowerCase().replace(/_8$/, '').replace(/[^a-z0-9]+/g, '');
+}
+function legacyColorFromTitle(title) {
+ if (!title) return null;
+ const head = (title || '').split('|')[0].trim();
+ const idx = head.indexOf(' - ');
+ return idx === -1 ? null : head.slice(idx + 3).trim();
+}
+function titleCase(s) {
+ return String(s || '').toLowerCase().replace(/\b([a-z])/g, (m, c) => c.toUpperCase());
+}
+function banWallpaper(s) {
+ return String(s || '').replace(/wallpapers/gi, 'Wallcoverings').replace(/wallpaper/gi, 'Wallcovering');
+}
+
+// Build the DW title: "Pattern Real-Color | Wolf Gordon Wallcoverings".
+// Color from catalog ONLY; never "Unknown"; fall back mfr_sku -> color -> skip.
+function buildTitle(row) {
+ const pat = titleCase((row.pattern_name || '').trim());
+ let color = (row.color_name || '').trim();
+ if (!color || /^unknown$/i.test(color)) color = (row.mfr_sku || '').trim();
+ if (!color || /^unknown$/i.test(color)) return null; // skip — no usable color
+ const left = `${pat} ${titleCase(color)}`.trim();
+ if (!left || /unknown/i.test(left)) return null;
+ return banWallpaper(`${left} | Wolf Gordon Wallcoverings`);
+}
+
+// ---------- store-side index (idempotent-by-SKU) ----------
+// Map: variant SKU (lower) -> { productId, handle, status, variantId, price }.
+// Also a nsku index of live WG products for pattern/sku reconciliation.
+async function loadStoreIndex() {
+ const bySku = new Map();
+ let cursor = null, pages = 0;
+ do {
+ const data = await gql(`
+ query($cursor:String){
+ products(first:200, query:"vendor:'Wolf Gordon'", after:$cursor){
+ pageInfo{ hasNextPage endCursor }
+ edges{ node{
+ id handle title status
+ variants(first:5){ edges{ node{ id sku price title
+ inventoryItem{ id } } } }
+ media(first:50){ edges{ node{ ... on MediaImage { image{ url } } } } }
+ } }
+ }
+ }`, { cursor });
+ const conn = data.products;
+ for (const e of conn.edges) {
+ const n = e.node;
+ const imgs = (n.media.edges || []).map(m => m.node?.image?.url).filter(Boolean);
+ for (const ve of n.variants.edges) {
+ const v = ve.node;
+ if (!v.sku) continue;
+ bySku.set(v.sku.toLowerCase(), {
+ productId: n.id, handle: n.handle, status: n.status,
+ variantId: v.id, price: v.price, vTitle: v.title,
+ invItemId: v.inventoryItem?.id, images: imgs,
+ });
+ }
+ }
+ cursor = conn.pageInfo.hasNextPage ? conn.pageInfo.endCursor : null;
+ pages++;
+ } while (cursor && pages < 60);
+ return bySku;
+}
+
+// ---------- plan builder ----------
+// Decide per catalog row + per live WG product what action applies.
+async function buildPlan(storeBySku) {
+ const cat = (await pool.query(`
+ SELECT id, mfr_sku, dw_sku, pattern_name, color_name, collection, product_type,
+ width, width_inches, material, price_retail, price_trade, image_url,
+ all_images, product_url, discontinued, shopify_product_id
+ FROM wolf_gordon_catalog`)).rows;
+
+ // Live WG legacy products (the reconcile source) for UPDATE/ARCHIVE matching.
+ const leg = (await pool.query(`
+ SELECT sku, mfr_sku, pattern_name, title, status, handle
+ FROM shopify_products WHERE vendor = 'Wolf Gordon'`)).rows;
+
+ // Catalog indexes for legacy->catalog join.
+ const catByNsku = new Map(), catByPC = new Map();
+ for (const r of cat) {
+ const k = nsku(r.mfr_sku); if (k && !catByNsku.has(k)) catByNsku.set(k, r);
+ const pc = norm(r.pattern_name) + '|' + norm(r.color_name);
+ if (!catByPC.has(pc)) catByPC.set(pc, []); catByPC.get(pc).push(r);
+ }
+ // Which catalog rows are covered by a live legacy peer (so NEW = uncovered).
+ const coveredNsku = new Set(), coveredPC = new Set();
+ for (const r of leg) {
+ coveredNsku.add(nsku(r.mfr_sku));
+ coveredPC.add(norm(r.pattern_name) + '|' + norm(legacyColorFromTitle(r.title)));
+ }
+
+ const plan = { new: [], update: [], draft: [], archive: [] };
+
+ // NEW + DRAFT: catalog rows with no live peer AND no existing SKU on store.
+ for (const r of cat) {
+ const skuLower = (r.dw_sku || '').toLowerCase();
+ const onStore = skuLower && storeBySku.has(skuLower);
+ const hasPeer = coveredNsku.has(nsku(r.mfr_sku)) ||
+ coveredPC.has(norm(r.pattern_name) + '|' + norm(r.color_name));
+ if (onStore || hasPeer) continue; // idempotent-by-SKU + has a legacy peer => not a fresh NEW
+ if (r.price_retail == null) plan.draft.push(r);
+ else plan.new.push(r);
+ }
+
+ // UPDATE: live WG products currently at $4.25 that match a catalog row (by
+ // normalized mfr_sku, else unique pattern+color) WITH a real price.
+ for (const r of leg) {
+ const liveSku = (r.sku || '').toLowerCase();
+ const live = storeBySku.get(liveSku);
+ if (!live) continue;
+ if (parseFloat(live.price) !== 4.25) continue; // only fix the $4.25 placeholders
+ const sh = catByNsku.get(nsku(r.mfr_sku));
+ const pc = catByPC.get(norm(r.pattern_name) + '|' + norm(legacyColorFromTitle(r.title)));
+ const tgt = sh || (pc && pc.length === 1 ? pc[0] : null);
+ if (tgt && tgt.price_retail != null) {
+ plan.update.push({ live, legacy: r, target: tgt });
+ }
+ }
+
+ // ARCHIVE: live WG products with NO catalog match at all (discontinued).
+ for (const r of leg) {
+ const live = storeBySku.get((r.sku || '').toLowerCase());
+ if (!live) continue;
+ if (live.status === 'ARCHIVED') continue;
+ const sh = catByNsku.get(nsku(r.mfr_sku));
+ const pc = catByPC.get(norm(r.pattern_name) + '|' + norm(legacyColorFromTitle(r.title)));
+ if (!sh && !(pc && pc.length)) plan.archive.push({ live, legacy: r });
+ }
+
+ return plan;
+}
+
+// ---------- writers ----------
+async function recordPid(mfrSku, pid) {
+ try {
+ await pool.query(
+ `UPDATE wolf_gordon_catalog SET shopify_product_id=$1, on_shopify=true, updated_at=NOW() WHERE mfr_sku=$2`,
+ [pid, mfrSku]);
+ } catch (e) { console.error(' recordPid fail:', e.message); }
+}
+
+function imageList(row) {
+ const imgs = [];
+ if (row.image_url) imgs.push(row.image_url);
+ if (row.all_images) {
+ try {
+ const arr = typeof row.all_images === 'string' ? JSON.parse(row.all_images) : row.all_images;
+ if (Array.isArray(arr)) for (const u of arr) if (u && !imgs.includes(u)) imgs.push(u);
+ } catch {/* all_images may be a comma string */
+ String(row.all_images).split(',').map(s => s.trim()).filter(Boolean)
+ .forEach(u => { if (!imgs.includes(u)) imgs.push(u); });
+ }
+ }
+ return imgs;
+}
+
+function widthInches(row) {
+ if (row.width_inches != null) return String(row.width_inches);
+ const m = String(row.width || '').match(/([\d.]+)\s*("|in|inch)/i);
+ return m ? m[1] : null;
+}
+
+// Create a NEW product (active+published if image+width present, else draft+tag).
+async function createProduct(row, { draft } = {}) {
+ const title = buildTitle(row);
+ if (!title) { console.log(` SKIP (no usable color) mfr=${row.mfr_sku}`); return null; }
+ const imgs = imageList(row);
+ const width = widthInches(row);
+ const isDraft = draft || imgs.length === 0 || !width;
+ const tags = [VENDOR, row.collection].filter(Boolean);
+ if (imgs.length === 0) tags.push('Needs-Image');
+ if (!width) tags.push('Needs-Width');
+ if (draft) tags.push('Quote-Only');
+
+ const productSet = {
+ title,
+ vendor: VENDOR,
+ productType: row.product_type || 'Wallcovering',
+ status: isDraft ? 'DRAFT' : 'ACTIVE',
+ tags,
+ productOptions: [{ name: 'Variant', values: [{ name: 'Yard' }, { name: 'Sample' }] }],
+ variants: [
+ {
+ optionValues: [{ optionName: 'Variant', name: 'Yard' }],
+ price: draft ? '0.00' : String(row.price_retail),
+ inventoryItem: { sku: row.dw_sku, tracked: false },
+ inventoryPolicy: 'CONTINUE',
+ },
+ {
+ optionValues: [{ optionName: 'Variant', name: 'Sample' }],
+ price: SAMPLE_PRICE,
+ inventoryItem: { sku: `${row.dw_sku}-Sample`, tracked: false },
+ inventoryPolicy: 'CONTINUE',
+ },
+ ],
+ metafields: width ? [{ namespace: 'custom', key: 'width', type: 'single_line_text_field', value: `${width} in` }] : [],
+ files: imgs.map(u => ({ originalSource: u, contentType: 'IMAGE' })),
+ };
+
+ const data = await gql(`
+ mutation($p:ProductSetInput!){
+ productSet(input:$p, synchronous:true){
+ product{ id handle status }
+ userErrors{ field message }
+ }
+ }`, { p: productSet });
+ const ue = data.productSet.userErrors;
+ if (ue && ue.length) throw new Error('productSet: ' + JSON.stringify(ue));
+ const prod = data.productSet.product;
+ if (!isDraft) await publish(prod.id);
+ await recordPid(row.mfr_sku, prod.id.split('/').pop());
+ return prod;
+}
+
+// Publish a product to the Online Store sales channel.
+async function publish(productId) {
+ try {
+ const pubs = await gql(`{ publications(first:10){ edges{ node{ id name } } } }`);
+ const online = pubs.publications.edges.find(e => /online store/i.test(e.node.name));
+ if (!online) return;
+ await gql(`
+ mutation($id:ID!,$pid:ID!){
+ publishablePublish(id:$id, input:{ publicationId:$pid }){ userErrors{ message } }
+ }`, { id: productId, pid: online.node.id });
+ } catch (e) { console.error(' publish warn:', e.message); }
+}
+
+// UPDATE: fix the $4.25 placeholder -> catalog retail price + replace SKU with
+// the sequential DW SKU. Images ADD-only (never delete).
+async function updateProduct(item) {
+ const { live, target } = item;
+ // 1. price + SKU on the existing variant.
+ const data = await gql(`
+ mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
+ productVariantsBulkUpdate(productId:$pid, variants:$variants){
+ productVariants{ id sku price }
+ userErrors{ field message }
+ }
+ }`, {
+ pid: live.productId,
+ variants: [{ id: live.variantId, price: String(target.price_retail),
+ inventoryItem: { sku: target.dw_sku } }],
+ });
+ const ue = data.productVariantsBulkUpdate.userErrors;
+ if (ue && ue.length) throw new Error('variantsBulkUpdate: ' + JSON.stringify(ue));
+
+ // 2. ensure a Sample variant exists ({DW_SKU}-Sample @ $4.25, untracked).
+ const sampleSku = `${target.dw_sku}-Sample`;
+ // (canary: existing legacy products are single-variant; add the sample.)
+ await gql(`
+ mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
+ productVariantsBulkCreate(productId:$pid, variants:$variants){
+ productVariants{ id sku } userErrors{ field message }
+ }
+ }`, {
+ pid: live.productId,
+ variants: [{ price: SAMPLE_PRICE, inventoryItem: { sku: sampleSku, tracked: false },
+ inventoryPolicy: 'CONTINUE',
+ optionValues: [{ optionName: 'Title', name: 'Sample' }] }],
+ }).catch(e => console.error(' sample-variant warn:', e.message));
+
+ // 3. images ADD/diff — add any catalog image not already on the product.
+ const have = new Set((live.images || []).map(u => u.split('?')[0]));
+ const want = imageList(target).filter(u => u && !have.has(u.split('?')[0]));
+ if (want.length) {
+ await gql(`
+ mutation($pid:ID!,$media:[CreateMediaInput!]!){
+ productCreateMedia(productId:$pid, media:$media){
+ media{ ... on MediaImage { id } } mediaUserErrors{ message }
+ }
+ }`, { pid: live.productId,
+ media: want.map(u => ({ originalSource: u, mediaContentType: 'IMAGE' })) })
+ .catch(e => console.error(' image-add warn:', e.message));
+ }
+
+ await recordPid(target.mfr_sku, live.productId.split('/').pop());
+ return { handle: live.handle, sku: target.dw_sku, price: String(target.price_retail) };
+}
+
+// ARCHIVE: set status archived (discontinued — never delete).
+async function archiveProduct(item) {
+ const data = await gql(`
+ mutation($id:ID!){
+ productUpdate(input:{ id:$id, status:ARCHIVED }){
+ product{ id handle status } userErrors{ message }
+ }
+ }`, { id: item.live.productId });
+ const ue = data.productUpdate.userErrors;
+ if (ue && ue.length) throw new Error('archive: ' + JSON.stringify(ue));
+ return { handle: item.live.handle, sku: item.legacy.sku };
+}
+
+// ---------- canary subset ----------
+// Deterministic 3 new / 3 update / 2 draft / 1 archive picked from the plan.
+function pickCanary(plan) {
+ const NEW_IDS = [5, 7, 8]; // Macao Melon/Pumpkin/Ginger
+ const ARCHIVE_SKU = 'DWWG-KRN 5611_8'; // Keren Maize (gone from catalog)
+ const newAdds = plan.new.filter(r => NEW_IDS.includes(r.id)).slice(0, 3);
+ const updates = plan.update
+ .filter(u => /^(GSG|DVTS|CCT)/i.test(u.target.mfr_sku)).slice(0, 3);
+ const DRAFT_SKUS = ['DWWG-530101', 'DWWG-530113']; // Marker Strokes / Wonder World (real names)
+ let drafts = plan.draft.filter(r => DRAFT_SKUS.includes(r.dw_sku)).slice(0, 2);
+ if (drafts.length < 2) drafts = plan.draft.slice(0, 2); // fallback
+ const archive = plan.archive.filter(a => a.legacy.sku === ARCHIVE_SKU).slice(0, 1);
+ const fallbackArch = archive.length ? archive : plan.archive.slice(0, 1);
+ return { new: newAdds, update: updates, draft: drafts, archive: fallbackArch };
+}
+
+// ---------- main ----------
+async function main() {
+ console.log('='.repeat(74));
+ console.log(` WOLF GORDON -> SHOPIFY PUSHER mode=${APPLY ? 'APPLY (LIVE WRITES)' : 'DRY-RUN'}` +
+ `${CANARY ? ' [CANARY]' : ''}${LIMIT ? ` [limit ${LIMIT}]` : ''}`);
+ console.log(` store=${SHOP} api=${API} token=...${TOKEN.slice(-4)}`);
+ console.log('='.repeat(74));
+
+ // token sanity
+ const shop = await rest('GET', '/shop.json');
+ console.log(` shop: ${shop.shop.name} (${shop.shop.myshopify_domain})`);
+
+ console.log(' loading live WG store index...');
+ const storeBySku = await loadStoreIndex();
+ console.log(` live WG variants indexed: ${storeBySku.size}`);
+
+ let plan = await buildPlan(storeBySku);
+ console.log(` plan: new=${plan.new.length} update=${plan.update.length} draft=${plan.draft.length} archive=${plan.archive.length}`);
+
+ let work;
+ if (CANARY) {
+ work = pickCanary(plan);
+ } else if (LIMIT) {
+ work = { new: plan.new.slice(0, LIMIT), update: [], draft: [], archive: [] };
+ } else {
+ work = plan;
+ }
+
+ const total = work.new.length + work.update.length + work.draft.length + work.archive.length;
+ console.log(` ACTIONING: new=${work.new.length} update=${work.update.length} draft=${work.draft.length} archive=${work.archive.length} (total ${total})`);
+ console.log('-'.repeat(74));
+
+ const results = { new: [], update: [], draft: [], archive: [] };
+
+ // NEW
+ for (const r of work.new) {
+ const t = buildTitle(r);
+ console.log(` [NEW] ${r.dw_sku} ${t} $${r.price_retail}`);
+ if (APPLY) {
+ const p = await createProduct(r);
+ if (p) results.new.push({ handle: p.handle, sku: r.dw_sku, price: String(r.price_retail), status: p.status });
+ }
+ }
+ // DRAFT (quote-only, null price)
+ for (const r of work.draft) {
+ const t = buildTitle(r);
+ console.log(` [DRAFT] ${r.dw_sku} ${t} (quote-only)`);
+ if (APPLY) {
+ const p = await createProduct(r, { draft: true });
+ if (p) results.draft.push({ handle: p.handle, sku: r.dw_sku, price: 'quote-only', status: p.status });
+ }
+ }
+ // UPDATE
+ for (const u of work.update) {
+ console.log(` [UPD] ${u.live.handle} ${u.live.price}->$${u.target.price_retail} sku->${u.target.dw_sku}`);
+ if (APPLY) results.update.push(await updateProduct(u));
+ }
+ // ARCHIVE
+ for (const a of work.archive) {
+ console.log(` [ARCH] ${a.live.handle} (${a.legacy.sku})`);
+ if (APPLY) results.archive.push(await archiveProduct(a));
+ }
+
+ console.log('-'.repeat(74));
+ if (!APPLY) {
+ console.log(' DRY-RUN — nothing written. Re-run with --apply to execute.');
+ } else {
+ console.log(' APPLIED. Results:');
+ console.log(JSON.stringify(results, null, 2));
+ }
+ await pool.end();
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
← 48267d6 Add reconciliation dry-run report + pricing-status finding
·
back to Wolfgordon Crawl
·
Harden pusher: graceful defer on Shopify daily variant-creat e28d26a →