← back to Designer Wallcoverings
Add dedup-active.js: generalize the Kravet dedup tool to any DW prefix (DRY default, skips ambiguous, reversible)
4e43276619eb08592cc3c1cbb30f068c32f4e78d · 2026-06-12 10:05:11 -0700 · SteveStudio2
Files touched
A shopify/scripts/dedup-active.js
Diff
commit 4e43276619eb08592cc3c1cbb30f068c32f4e78d
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Fri Jun 12 10:05:11 2026 -0700
Add dedup-active.js: generalize the Kravet dedup tool to any DW prefix (DRY default, skips ambiguous, reversible)
---
shopify/scripts/dedup-active.js | 82 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 82 insertions(+)
diff --git a/shopify/scripts/dedup-active.js b/shopify/scripts/dedup-active.js
new file mode 100644
index 00000000..a6b3aad1
--- /dev/null
+++ b/shopify/scripts/dedup-active.js
@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+/**
+ * Generalized both-active duplicate cleaner (from dedup-kravet-active.js). For a DW SKU prefix,
+ * find base-SKUs with 2+ ACTIVE products on Shopify and archive the BROKEN duplicate copy,
+ * keeping the complete/priced one.
+ *
+ * node dedup-active.js DWGB # DRY — report keep/archive/skip decisions
+ * node dedup-active.js DWGB --commit # archive the clearly-broken copies (reversible)
+ *
+ * SAFETY (unchanged from the Kravet original):
+ * - DRY by default. --commit to write. Sets status=ARCHIVED (REVERSIBLE) — never deletes.
+ * - Archives ONLY a copy that is unambiguously broken (no roll price > sample) AND scores
+ * strictly worse than the kept twin. If both look complete, both look broken, or it can't
+ * tell which to keep → SKIPS + logs (never guesses).
+ * - Never touches a base-SKU without >=2 ACTIVE copies.
+ */
+const https = require('https'); const fs = require('fs'); const os = require('os');
+const ARGS = process.argv.slice(2);
+const COMMIT = ARGS.includes('--commit');
+const PREFIX = (ARGS.find(a => !a.startsWith('--')) || '').toUpperCase();
+const SAMPLE = 4.25;
+const TOKEN = (fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function gql(q, v) {
+ return new Promise(res => { const d = JSON.stringify({ query: q, variables: v });
+ const rq = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(d) } },
+ r => { let b = ''; r.on('data', c => b += c); r.on('end', () => { try { res(JSON.parse(b)); } catch { res({}); } }); });
+ rq.on('error', () => res({})); rq.write(d); rq.end(); });
+}
+async function gr(q, v) { for (let a = 0; a < 7; a++) { const r = await gql(q, v); if (r.errors && JSON.stringify(r.errors).includes('THROTTLED')) { await sleep(2500 * (a + 1)); continue; } await sleep(200); return r; } return {}; }
+const base = s => (s || '').replace(/-(sample|roll|yard)$/i, '').trim().toUpperCase();
+
+// score how "complete" a copy is — higher = keep. (priced roll dominates; then variant count; then has-image)
+function score(c) { let s = 0; if (c.roll != null && c.roll > SAMPLE + 0.5) s += 1000; s += c.nv * 10; if (c.img) s += 1; return s; }
+
+(async () => {
+ if (!TOKEN) { console.error('no token'); process.exit(1); }
+ if (!/^DW[A-Z]+$/.test(PREFIX)) { console.error('usage: dedup-active.js <PREFIX e.g. DWGB> [--commit]'); process.exit(1); }
+ const Q = `query($q:String,$after:String){products(first:100,query:$q,after:$after){pageInfo{hasNextPage endCursor} edges{node{id title createdAt status featuredImage{url} variants(first:10){edges{node{sku price}}}}}}}`;
+ const map = {}; let after = null;
+ while (true) {
+ const r = await gr(Q, { q: `sku:${PREFIX}-*`, after }); const p = r?.data?.products; if (!p) break;
+ for (const e of p.edges) {
+ const n = e.node; if (n.status !== 'ACTIVE') continue;
+ const vs = n.variants.edges.map(v => v.node); const skus = vs.map(v => v.sku).filter(Boolean);
+ let b = null; for (const s of skus) { if (!/-sample$/i.test(s)) { b = base(s); break; } } if (!b && skus.length) b = base(skus[0]);
+ if (!b) continue;
+ const roll = vs.find(v => !/-sample$/i.test(v.sku || ''));
+ (map[b] = map[b] || []).push({ id: n.id, num: n.id.replace(/.*\//, ''), title: n.title.slice(0, 40), c: n.createdAt.slice(0, 10), img: !!n.featuredImage, nv: vs.length, roll: roll ? parseFloat(roll.price) : null });
+ }
+ if (!p.pageInfo.hasNextPage) break; after = p.pageInfo.endCursor;
+ }
+ const pairs = Object.entries(map).filter(([, v]) => v.length >= 2);
+ console.log(`${PREFIX} base-SKUs with >=2 ACTIVE copies: ${pairs.length}`);
+
+ const toArchive = []; const skipped = [];
+ for (const [b, copies] of pairs) {
+ const ranked = copies.slice().sort((x, y) => score(y) - score(x));
+ const keep = ranked[0], losers = ranked.slice(1);
+ for (const l of losers) {
+ const broken = !(l.roll != null && l.roll > SAMPLE + 0.5);
+ if (broken && score(keep) > score(l)) toArchive.push({ b, keep, loser: l });
+ else skipped.push({ b, keep, loser: l, why: broken ? 'keep-not-clearly-better' : 'loser-is-priced (two complete copies — needs human)' });
+ }
+ }
+ console.log(`\nWOULD ARCHIVE ${toArchive.length} broken copies · KEEP their priced twin · SKIP ${skipped.length} ambiguous`);
+ toArchive.slice(0, 100).forEach(t => console.log(` ${t.b}: keep ${t.keep.num}[${t.keep.c},$${t.keep.roll},nv=${t.keep.nv}] → ARCHIVE ${t.loser.num}[${t.loser.c},$${t.loser.roll},nv=${t.loser.nv}]`));
+ skipped.forEach(s => console.log(` ⚠ SKIP ${s.b} (${s.why}): keep?${s.keep.num}[$${s.keep.roll}] vs ${s.loser.num}[${s.loser.c},$${s.loser.roll},nv=${s.loser.nv}]`));
+
+ if (!COMMIT) { console.log(`\nDRY — --commit would archive the ${toArchive.length} broken copies (reversible).`); return; }
+
+ const M = `mutation($input:ProductInput!){productUpdate(input:$input){product{id status} userErrors{field message}}}`;
+ let ok = 0, fail = 0;
+ for (const t of toArchive) {
+ const r = await gr(M, { input: { id: t.loser.id, status: 'ARCHIVED' } });
+ const e = r?.data?.productUpdate?.userErrors;
+ if (e && e.length) { fail++; console.log(` ✗ ${t.b} ${t.loser.num} ${JSON.stringify(e).slice(0, 90)}`); }
+ else { ok++; if (ok % 10 === 0) process.stdout.write(`\r archived ${ok}/${toArchive.length}…`); }
+ }
+ console.log(`\nDONE: archived ${ok}, failed ${fail}. Kept the complete twin for each.`);
+})();
← ea94af32 Tier 2 audit result: 521 Kravet-family fabrics recoverable v
·
back to Designer Wallcoverings
·
Tier 3 cost-sourcing matrix + complete campaign roadmap: big 00a91125 →