[object Object]

← back to Designer Wallcoverings

Add --reslug mode (34 clean handles) + read-only PR dedup audit

d6339904f08e0e9f8cc2127890a3391ec9d4d602 · 2026-07-06 13:48:29 -0700 · Steve

Files touched

Diff

commit d6339904f08e0e9f8cc2127890a3391ec9d4d602
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 6 13:48:29 2026 -0700

    Add --reslug mode (34 clean handles) + read-only PR dedup audit
---
 scripts/audit_pr_dupes.js    | 81 ++++++++++++++++++++++++++++++++++++++++++++
 scripts/push_wallquest_pr.js | 32 +++++++++++++++++
 2 files changed, 113 insertions(+)

diff --git a/scripts/audit_pr_dupes.js b/scripts/audit_pr_dupes.js
new file mode 100644
index 00000000..9f58d929
--- /dev/null
+++ b/scripts/audit_pr_dupes.js
@@ -0,0 +1,81 @@
+#!/usr/bin/env node
+/**
+ * READ-ONLY audit of Phillipe Romano duplicate PRODUCTS on live Shopify.
+ * True-dup signal = same primary SKU on >1 product (title dups can be legit distinct SKUs).
+ * Groups by SKU, applies keep-rule = OLDEST product survives, lists the rest as deletable.
+ * Writes output/pr-dupe-audit.json + a CSV. Makes NO writes to Shopify.
+ */
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+
+const SHOP = process.env.SHOPIFY_STORE_DOMAIN;
+const TOKEN = process.env.SHOPIFY_ADMIN_ACCESS_TOKEN || process.env.SHOPIFY_ADMIN_TOKEN;
+const VER = process.env.SHOPIFY_ADMIN_API_VERSION || '2024-01';
+const BASE = `https://${SHOP}/admin/api/${VER}`;
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+async function getPage(url) {
+  const res = await fetch(url, { headers: { 'X-Shopify-Access-Token': TOKEN } });
+  if (!res.ok) throw new Error(`${res.status}: ${(await res.text()).slice(0, 200)}`);
+  const link = res.headers.get('Link') || '';
+  let next = null;
+  if (link.includes('rel="next"')) for (const part of link.split(','))
+    if (part.includes('rel="next"')) next = part.split(';')[0].trim().replace(/[<>]/g, '');
+  return { products: (await res.json()).products, next };
+}
+
+(async () => {
+  console.log('Scanning all Phillipe Romano products (read-only)…');
+  let url = `${BASE}/products.json?vendor=Phillipe%20Romano&limit=250&fields=id,title,handle,status,created_at,variants`;
+  const all = [];
+  while (url) { const { products, next } = await getPage(url); all.push(...products); url = next; if (all.length % 2500 === 0) console.log(`  …${all.length}`); await sleep(120); }
+  console.log(`Total PR products: ${all.length}`);
+
+  // primary SKU = first non-"-Sample" variant sku, else first variant sku
+  const primarySku = (p) => {
+    const vs = p.variants || [];
+    const main = vs.find(v => v.sku && !/-sample$/i.test(v.sku)) || vs[0];
+    return (main && main.sku || '').trim();
+  };
+
+  const bySku = new Map();
+  let noSku = 0;
+  for (const p of all) {
+    const sku = primarySku(p);
+    if (!sku) { noSku++; continue; }
+    if (!bySku.has(sku)) bySku.set(sku, []);
+    bySku.get(sku).push({ id: p.id, title: p.title, handle: p.handle, status: p.status, created_at: p.created_at });
+  }
+
+  const dupGroups = [];
+  let deletable = 0;
+  for (const [sku, prods] of bySku) {
+    if (prods.length < 2) continue;
+    prods.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); // oldest first
+    const keep = prods[0], drop = prods.slice(1);
+    deletable += drop.length;
+    dupGroups.push({ sku, count: prods.length, keep_id: keep.id, keep_title: keep.title, drop_ids: drop.map(d => d.id), products: prods });
+  }
+  dupGroups.sort((a, b) => b.count - a.count);
+
+  const summary = {
+    total_products: all.length,
+    distinct_skus: bySku.size,
+    products_without_sku: noSku,
+    sku_dup_groups: dupGroups.length,
+    deletable_excess: deletable,
+    status_breakdown: all.reduce((m, p) => (m[p.status] = (m[p.status] || 0) + 1, m), {}),
+    top20: dupGroups.slice(0, 20).map(g => ({ sku: g.sku, count: g.count, title: g.keep_title })),
+  };
+  const outDir = path.join(__dirname, '..', 'output');
+  fs.mkdirSync(outDir, { recursive: true });
+  fs.writeFileSync(path.join(outDir, 'pr-dupe-audit.json'), JSON.stringify({ summary, dupGroups }, null, 2));
+  const csv = ['sku,count,keep_id,keep_title,drop_ids',
+    ...dupGroups.map(g => `"${g.sku}",${g.count},${g.keep_id},"${(g.keep_title || '').replace(/"/g, '""')}","${g.drop_ids.join(' ')}"`)].join('\n');
+  fs.writeFileSync(path.join(outDir, 'pr-dupe-audit.csv'), csv);
+
+  console.log('\n=== PR DUPLICATE AUDIT (read-only) ===');
+  console.log(JSON.stringify(summary, null, 2));
+  console.log(`\nDetail written: output/pr-dupe-audit.json  +  output/pr-dupe-audit.csv`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/push_wallquest_pr.js b/scripts/push_wallquest_pr.js
index 84b9f1d1..59c1e044 100644
--- a/scripts/push_wallquest_pr.js
+++ b/scripts/push_wallquest_pr.js
@@ -29,6 +29,8 @@ const argv = process.argv.slice(2);
 const DRY = argv.includes('--dry-run');
 const ACTIVATE = argv.includes('--activate');
 const RETITLE = argv.includes('--retitle');
+const RESLUG = argv.includes('--reslug');
+const slugify = (s) => s.toLowerCase().replace(/&/g, ' ').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/-+/g, '-');
 const LIMIT = (() => { const i = argv.indexOf('--limit'); return i > -1 ? parseInt(argv[i + 1], 10) : null; })();
 const ONLY = (() => { const i = argv.indexOf('--only'); return i > -1 ? new Set(argv[i + 1].split(',')) : null; })();
 
@@ -98,6 +100,36 @@ async function setInv(variantId, locationId, qty) {
   const db = new Client({ connectionString: DBURL });
   await db.connect();
 
+  if (RESLUG) {
+    // Re-slug the 34 fixed products to clean handles + 301 the old handle → new.
+    const { rows } = await db.query(
+      `SELECT dw_sku, shopify_product_id, pattern_name, color_name
+       FROM phillipe_romano_catalog
+       WHERE created_at::date='2026-07-06' AND shopify_product_id IS NOT NULL
+         AND (color_name LIKE 'Sisal %' OR pattern_name='Navy Grey & White')
+       ORDER BY dw_sku`);
+    console.log(`${DRY ? '[DRY] ' : ''}Re-slugging ${rows.length} products (+301 redirects)…`);
+    let ok = 0;
+    for (const r of rows) {
+      const newHandle = slugify(`${r.pattern_name} ${r.color_name}`);
+      let oldHandle = null;
+      try { oldHandle = (await shopify('GET', `/products/${r.shopify_product_id}.json?fields=handle`)).product.handle; } catch {}
+      if (oldHandle === newHandle) { console.log(`  = ${r.dw_sku} already ${newHandle}`); ok++; continue; }
+      if (DRY) { console.log(`  ${r.dw_sku}: ${oldHandle} → ${newHandle}`); continue; }
+      try {
+        await shopify('PUT', `/products/${r.shopify_product_id}.json`, { product: { id: Number(r.shopify_product_id), handle: newHandle } });
+        if (oldHandle) {
+          try { await shopify('POST', '/redirects.json', { redirect: { path: `/products/${oldHandle}`, target: `/products/${newHandle}` } }); }
+          catch (e) { console.log(`     redirect skip: ${e.message.slice(0,100)}`); }
+        }
+        ok++; console.log(`  ✓ ${r.dw_sku}: ${oldHandle} → ${newHandle} (+301)`);
+      } catch (e) { console.log(`  ✗ ${r.dw_sku}: ${e.message.slice(0, 150)}`); }
+      await sleep(RATE_MS);
+    }
+    console.log(`Re-slugged ${ok}/${rows.length}`);
+    await db.end(); return;
+  }
+
   if (RETITLE) {
     // Fix the 34 collapsed-title live products (Sisal x28 + Navy Grey & White x6)
     // → title = "<pattern> - <color> | Phillipe Romano"

← c3082e42 WallQuest→PR grasscloth pusher + retitle mode; fix 34 collap  ·  back to Designer Wallcoverings  ·  Daisy Bennett: reconcile SKUs to GRS- (no DW); retitle all 6 ece3218f →