[object Object]

← 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

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(/&amp;/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 →