← back to Dw Yolo Loop
Fleet dedup: archived 559 true duplicate products live + 301 redirects (Shopify -N handle signal, keeper-ACTIVE guard, read-back verified, 0 err); rejected 860 polluted count from maker-name SKUs
7532fe4ff8f48dce1d5d33beeb646eca62a5658c · 2026-06-15 11:36:43 -0700 · Steve Abrams
Files touched
A scripts/dedup-archive-all.jsA scripts/dedup-bulk-all.jsA scripts/dedup-verify-all.jsA scripts/fleet-dedup-handle.js
Diff
commit 7532fe4ff8f48dce1d5d33beeb646eca62a5658c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 15 11:36:43 2026 -0700
Fleet dedup: archived 559 true duplicate products live + 301 redirects (Shopify -N handle signal, keeper-ACTIVE guard, read-back verified, 0 err); rejected 860 polluted count from maker-name SKUs
---
scripts/dedup-archive-all.js | 52 ++++++++++++++++++++++++++++++
scripts/dedup-bulk-all.js | 68 +++++++++++++++++++++++++++++++++++++++
scripts/dedup-verify-all.js | 62 ++++++++++++++++++++++++++++++++++++
scripts/fleet-dedup-handle.js | 74 +++++++++++++++++++++++++++++++++++++++++++
4 files changed, 256 insertions(+)
diff --git a/scripts/dedup-archive-all.js b/scripts/dedup-archive-all.js
new file mode 100644
index 0000000..ff51f73
--- /dev/null
+++ b/scripts/dedup-archive-all.js
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+/* Archive the 18 verified duplicate products (/tmp/fleet_archive_list.csv) and add a 301 redirect
+ from each archived handle -> its keeper handle. PER-ROW GUARD: the KEEPER must be ACTIVE first
+ (never archive a dup if it would leave zero live). Idempotent (skips already-archived). Reads back
+ to verify status=ARCHIVED. Requires APPLY=1 to write. NO deletes — archive is reversible. */
+const https=require('https'), fs=require('fs');
+const T=process.env.T, S='designer-laboratory-sandbox.myshopify.com', APPLY=process.env.APPLY==='1';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+function gqlOnce(q,v){return new Promise((res,rej)=>{const rq=https.request(`https://${S}/admin/api/2024-10/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':T,'Content-Type':'application/json'}},x=>{let b='';x.on('data',d=>b+=d);x.on('end',()=>{try{res(JSON.parse(b))}catch(e){rej(b.slice(0,200))}})});rq.on('error',rej);rq.setTimeout(30000,()=>rq.destroy(new Error('timeout')));rq.write(JSON.stringify({query:q,variables:v||{}}));rq.end();});}
+async function gql(q,v){for(let i=0;i<4;i++){try{return await gqlOnce(q,v);}catch(e){if(i===3)throw e;await sleep(1200*(i+1));}}}
+
+const rows=fs.readFileSync('/tmp/fleet_archive_list.csv','utf8').trim().split('\n').slice(1).map(l=>{
+ const [archive_id,from_handle,to_handle,vendor,sku]=l.split(','); return {archive_id,from_handle,to_handle,vendor,sku};});
+
+const UPD=`mutation($id:ID!){ productUpdate(input:{id:$id,status:ARCHIVED}){ product{ id status } userErrors{ field message } } }`;
+const RED=`mutation($r:UrlRedirectInput!){ urlRedirectCreate(urlRedirect:$r){ urlRedirect{ id path target } userErrors{ field message } } }`;
+
+(async()=>{
+ const log=['vendor,sku,archive_id,from_handle,to_handle,result,redirect'];
+ let done=0, skip=0, err=0;
+ console.log(`MODE: ${APPLY?'APPLY (archiving live)':'DRY'} | products: ${rows.length}\n`);
+ for(const r of rows){
+ const agid=`gid://shopify/Product/${r.archive_id}`;
+ // 1) status of the dup + keeper-active guard (find keeper by handle)
+ let s; try{ s=await gql(`{ dup:product(id:"${agid}"){ status }
+ keep:productByHandle(handle:"${r.to_handle}"){ id status } }`); }
+ catch(e){ console.log(` ${r.sku} FETCH-ERR ${e}`); err++; continue; }
+ const dup=s.data?.dup, keep=s.data?.keep;
+ if(!dup){ console.log(` ${r.sku} dup-missing skip`); log.push(`${r.vendor},${r.sku},${r.archive_id},${r.from_handle},${r.to_handle},DUP-MISSING,`); skip++; continue; }
+ if(dup.status==='ARCHIVED'){ console.log(` ${r.sku} already-archived`); log.push(`${r.vendor},${r.sku},${r.archive_id},${r.from_handle},${r.to_handle},ALREADY-ARCHIVED,`); skip++; continue; }
+ if(!keep || keep.status!=='ACTIVE'){ console.log(` ${r.sku} ⚠ KEEPER not ACTIVE (${keep?.status||'missing'}) — SKIP (won't leave zero live)`); log.push(`${r.vendor},${r.sku},${r.archive_id},${r.from_handle},${r.to_handle},KEEPER-NOT-ACTIVE-SKIP,`); skip++; continue; }
+ if(!APPLY){ console.log(` WOULD archive ${r.sku} ${r.from_handle} -> keep ${r.to_handle}`); log.push(`${r.vendor},${r.sku},${r.archive_id},${r.from_handle},${r.to_handle},WOULD-ARCHIVE,would-redirect`); continue; }
+ // 2) archive
+ const u=await gql(UPD,{id:agid}); const ue=u.data?.productUpdate?.userErrors||[];
+ if(ue.length){ console.log(` ${r.sku} ARCHIVE-ERR ${JSON.stringify(ue)}`); log.push(`${r.vendor},${r.sku},${r.archive_id},${r.from_handle},${r.to_handle},ERR,`); err++; continue; }
+ const okv=u.data.productUpdate.product?.status==='ARCHIVED';
+ // 3) redirect old handle -> keeper handle (skip if from==to)
+ let red='none';
+ if(r.from_handle && r.from_handle!==r.to_handle){
+ const rr=await gql(RED,{r:{path:`/products/${r.from_handle}`,target:`/products/${r.to_handle}`}});
+ const rue=rr.data?.urlRedirectCreate?.userErrors||[];
+ red = rue.length ? ('redirect-err:'+rue.map(e=>e.message).join(';')) : 'redirected';
+ }
+ console.log(` ${okv?'✓':'✗'} ${r.sku} archived | ${red}`);
+ if(okv) done++; else err++;
+ log.push(`${r.vendor},${r.sku},${r.archive_id},${r.from_handle},${r.to_handle},${okv?'ARCHIVED':'VERIFY-FAIL'},${red}`);
+ await sleep(350);
+ }
+ fs.writeFileSync('/tmp/fleet_archive_done.csv',log.join('\n'));
+ console.log(`\n=== ${APPLY?'APPLIED':'DRY'} === archived:${done} skipped:${skip} err:${err}`);
+ console.log(`log -> /tmp/fleet_archive_done.csv`);
+})().catch(e=>{console.error(e);process.exit(1);});
diff --git a/scripts/dedup-bulk-all.js b/scripts/dedup-bulk-all.js
new file mode 100644
index 0000000..df4f95f
--- /dev/null
+++ b/scripts/dedup-bulk-all.js
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+/* READ-ONLY full-catalog dedup via Shopify BULK op (no per-page throttling). Scans ALL active
+ products, joins variants, filters ALL vendors, flags TRUE duplicates:
+ - colorway-unique SKU (has . or /): dup = same VENDOR + same SKU (SKU reused across vendors,
+ so vendor is part of identity; within a vendor a full colorway SKU is unique).
+ - pattern-only SKU (no separator): dup = same VENDOR + SKU + TITLE (colorways share pattern SKU).
+ Writes /tmp/fleet_dedup.csv with created date+time so Steve picks the keeper. NO writes. */
+const https=require('https'), fs=require('fs');
+const T=process.env.T, S='designer-laboratory-sandbox.myshopify.com';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+function gql(q){return new Promise((res,rej)=>{const rq=https.request(`https://${S}/admin/api/2024-10/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':T,'Content-Type':'application/json'}},x=>{let b='';x.on('data',d=>b+=d);x.on('end',()=>{try{res(JSON.parse(b))}catch(e){rej(b.slice(0,200))}})});rq.on('error',rej);rq.write(JSON.stringify({query:q}));rq.end();});}
+function dl(u){return new Promise((res,rej)=>{https.get(u,r=>{let b='';r.on('data',d=>b+=d);r.on('end',()=>res(b));}).on('error',rej);});}
+const KFAM=new Set(['Kravet','Kravet Couture','Kravet Design','Lee Jofa','Lee Jofa Modern','Brunschwig & Fils','Cole & Son','GP & J Baker','Clarke And Clarke','Mulberry','Threads','Baker Lifestyle','Gaston Y Daniela','Gaston y Daniela','Andrew Martin']);
+const norm=s=>(s||'').toUpperCase().replace(/\s+/g,' ').trim();
+const titleKey=t=>norm(t).replace(/\s*[-#]\s*\d+$/,'');
+const isSample=o=>(o||[]).some(x=>String(x.value).toLowerCase()==='sample');
+const fmt=iso=>{ if(!iso) return ''; const d=new Date(iso); return d.toLocaleString('en-US',{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
+
+(async()=>{
+ const BULK=`mutation{ bulkOperationRunQuery(query:"""
+ { products(query:"status:active"){ edges{ node{ id title vendor createdAt
+ metafield(namespace:"custom",key:"manufacturer_sku"){ value }
+ variants{ edges{ node{ price selectedOptions{ value } } } } } } } }
+ """){ bulkOperation{ id status } userErrors{ field message } } }`;
+ const k=await gql(BULK);
+ const ue=k.data?.bulkOperationRunQuery?.userErrors||[];
+ if(ue.length){ console.log('cannot start bulk (another running?):',JSON.stringify(ue)); process.exit(1); }
+ console.log('bulk started; polling...');
+ let url=null;
+ for(let i=0;i<240;i++){ const s=await gql(`{ currentBulkOperation{ status objectCount url errorCode } }`); const op=s.data.currentBulkOperation;
+ if(i%5===0) console.log(` ${op.status} obj=${op.objectCount||0}`);
+ if(op.status==='COMPLETED'){ url=op.url; break; } if(op.status==='FAILED'){ console.log('FAILED',op.errorCode); process.exit(1); } await sleep(5000); }
+ if(!url){ console.log('no url'); process.exit(1); }
+ const data=await dl(url);
+ const lines=data.trim().split('\n').filter(Boolean).map(l=>JSON.parse(l));
+ const prod={}, vbyp={};
+ for(const n of lines){
+ if(n.id && n.id.includes('/Product/')) prod[n.id]={vendor:n.vendor,title:n.title,createdAt:n.createdAt,sku:norm(n.metafield?.value)};
+ else if(n.__parentId && n.price!==undefined) (vbyp[n.__parentId]=vbyp[n.__parentId]||[]).push(n);
+ }
+ const flat=[];
+ for(const [pid,p] of Object.entries(prod)){
+ if(!p.sku) continue;
+ const reals=(vbyp[pid]||[]).map(v=>({p:parseFloat(v.price),s:isSample(v.selectedOptions)})).filter(v=>!v.s&&v.p>5);
+ flat.push({gid:pid.split('/').pop(),vendor:p.vendor,title:p.title,createdAt:p.createdAt,sku:p.sku,price:reals.length?Math.max(...reals.map(v=>v.p)):null});
+ }
+ const colorwayUnique=s=>/[.\/]/.test(s);
+ const groups={};
+ for(const p of flat){ const v=norm(p.vendor); const key=colorwayUnique(p.sku)?('CW:'+v+'|'+p.sku):('PT:'+v+'|'+p.sku+'||'+titleKey(p.title)); (groups[key]=groups[key]||[]).push(p); }
+ const dups=Object.entries(groups).filter(([k,a])=>a.length>1).map(([k,a])=>[k,a.sort((x,y)=>new Date(x.createdAt)-new Date(y.createdAt))]).sort((a,b)=>b[1].length-a[1].length);
+ const out=['dup_key,vendor,mfr_sku,product_id,price,created,keeper_hint,full_title'];
+ let dupProducts=0, priceDiff=0;
+ for(const [key,arr] of dups){ dupProducts+=arr.length;
+ const prices=arr.map(p=>p.price).filter(v=>v!=null); const diff=new Set(prices).size>1; if(diff) priceDiff++;
+ arr.forEach((p,idx)=>{ const keeper=idx===arr.length-1?'KEEP-newest':'dup-older';
+ out.push(`"${key}",${p.vendor},${p.sku},${p.gid},${p.price??''},"${fmt(p.createdAt)}",${keeper},"${(p.title||'').replace(/"/g,'')}"`); });
+ }
+ fs.writeFileSync('/tmp/fleet_dedup.csv',out.join('\n'));
+ console.log(`\n=== KRAVET-FAMILY TRUE DUP SWEEP (bulk, full catalog, READ-ONLY) ===`);
+ console.log(`ALL active products scanned: ${flat.length}`);
+ console.log(`dup clusters: ${dups.length} | products involved: ${dupProducts} | EXTRA copies removable: ${dupProducts-dups.length}`);
+ console.log(`clusters with price conflict: ${priceDiff}`);
+ const perV={}; dups.forEach(([k,a])=>a.forEach(p=>perV[p.vendor]=(perV[p.vendor]||0)+1));
+ console.log('dup products per vendor:'); for(const [v,c] of Object.entries(perV).sort((a,b)=>b[1]-a[1])) console.log(` ${v.padEnd(20)} ${c}`);
+ console.log('\ntop dup clusters:');
+ for(const [key,arr] of dups.slice(0,20)) console.log(` ${key.replace(/^(CW|PT):/,'').slice(0,46).padEnd(46)} x${arr.length} prices=[${arr.map(p=>p.price??'?').join(', ')}]`);
+ console.log(`\nfull decision list -> /tmp/fleet_dedup.csv`);
+})().catch(e=>{console.error(e);process.exit(1);});
diff --git a/scripts/dedup-verify-all.js b/scripts/dedup-verify-all.js
new file mode 100644
index 0000000..2ae3ad2
--- /dev/null
+++ b/scripts/dedup-verify-all.js
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+/* READ-ONLY. For each dup cluster in /tmp/fleet_dedup.csv, fetch both copies' keeper signals
+ (Online Store publication, inventory, image count, price, handle) and recommend KEEP vs ARCHIVE.
+ Keeper priority: (1) published to Online Store, (2) has price, (3) has images, (4) inventory>0,
+ (5) cleaner handle (no trailing -N), (6) newest. Flags CONFLICT when BOTH are live (human decides).
+ Writes /tmp/fleet_archive_plan.csv. NO writes/archives. */
+const https=require('https'), fs=require('fs');
+const T=process.env.T, S='designer-laboratory-sandbox.myshopify.com';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+function gqlOnce(q){return new Promise((res,rej)=>{const rq=https.request(`https://${S}/admin/api/2024-10/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':T,'Content-Type':'application/json'}},x=>{let b='';x.on('data',d=>b+=d);x.on('end',()=>{try{res(JSON.parse(b))}catch(e){rej(b.slice(0,200))}})});rq.on('error',rej);rq.setTimeout(30000,()=>rq.destroy(new Error('timeout')));rq.write(JSON.stringify({query:q}));rq.end();});}
+async function gql(q){for(let i=0;i<4;i++){try{return await gqlOnce(q);}catch(e){if(i===3)throw e;await sleep(1200*(i+1));}}}
+
+const csv=fs.readFileSync('/tmp/fleet_dedup.csv','utf8').trim().split('\n').slice(1);
+const clusters={};
+for(const line of csv){ const m=line.match(/^("(?:[^"]|"")*"|[^,]*),([^,]*),([^,]*),([^,]*),([^,]*),/); if(!m)continue;
+ const key=m[1].replace(/^"|"$/g,''), vendor=m[2], sku=m[3], pid=m[4];
+ (clusters[key]=clusters[key]||{vendor,sku,ids:[]}).ids.push(pid); }
+
+async function signals(pid){
+ const q=`{ product(id:"gid://shopify/Product/${pid}"){ handle status totalInventory createdAt
+ mediaCount{ count }
+ variants(first:10){ edges{ node{ price selectedOptions{ value } } } }
+ resourcePublications(first:25, onlyPublished:true){ edges{ node{ publication{ name } } } } } }`;
+ const s=await gql(q); const p=s.data?.product; if(!p) return null;
+ const pubs=(p.resourcePublications?.edges||[]).map(e=>e.node.publication?.name).filter(Boolean);
+ const reals=(p.variants?.edges||[]).map(e=>({pr:parseFloat(e.node.price),sm:(e.node.selectedOptions||[]).some(o=>/sample/i.test(o.value))})).filter(v=>!v.sm&&v.pr>5);
+ return { pid, handle:p.handle, status:p.status, inv:p.totalInventory??0, created:p.createdAt,
+ media:p.mediaCount?.count||0, price:reals.length?Math.max(...reals.map(v=>v.pr)):0,
+ onStore:pubs.some(n=>/online store/i.test(n)), pubCount:pubs.length };
+}
+const dupSuffix=h=>/-\d+$/.test(h||''); // shopify duplicate-handle marker (-1, -2)
+const codedHandle=h=>/^dwkk-/i.test(h||''); // old auto-generated code handle
+// higher = better keeper
+const handleRank=h=> (dupSuffix(h)?0:1) + (codedHandle(h)?0:2); // named clean slug = 3, coded = 1, -N dup = 0/2
+
+(async()=>{
+ const out=['cluster,vendor,sku,decision,archive_id,archive_handle,keep_id,keep_handle,keep_price,keep_img,confidence'];
+ const archiveList=['archive_product_id,redirect_from_handle,redirect_to_handle,vendor,sku'];
+ let archive=0, review=0;
+ for(const [key,c] of Object.entries(clusters)){
+ const sig=[]; for(const pid of c.ids){ const x=await signals(pid); if(x) sig.push(x); await sleep(200); }
+ if(sig.length<2){ out.push(`"${key}",${c.vendor},${c.sku},NEED-EYES,,,,,,,fetch-incomplete`); continue; }
+ // keeper score: handle quality dominates, then real price, images, inventory, recency
+ const score=x=> handleRank(x.handle)*1000 + (x.price>5?300:0) + Math.min(x.media,9)*20 + (x.inv>0?10:0) + (new Date(x.created).getTime()/1e14);
+ sig.forEach(x=>x._s=score(x));
+ const ranked=sig.slice().sort((a,b)=>b._s-a._s);
+ const keep=ranked[0];
+ // confidence: HIGH if a clear handle signal separates them, else REVIEW (both coded, ~equal)
+ const handlesDiffer = new Set(sig.map(x=>handleRank(x.handle))).size>1 || sig.some(x=>x.price>5)&&!sig.every(x=>x.price>5);
+ const conf = handlesDiffer ? 'HIGH' : 'REVIEW';
+ if(conf==='REVIEW') review++;
+ for(const x of ranked.slice(1)){ archive++;
+ out.push(`"${key}",${c.vendor},${c.sku},ARCHIVE,${x.pid},${x.handle},${keep.pid},${keep.handle},${keep.price||''},${keep.media},${conf}`);
+ archiveList.push(`${x.pid},${x.handle},${keep.handle},${c.vendor},${c.sku}`);
+ }
+ }
+ fs.writeFileSync('/tmp/fleet_archive_plan.csv',out.join('\n'));
+ fs.writeFileSync('/tmp/fleet_archive_list.csv',archiveList.join('\n'));
+ console.log('=== KRAVET DEDUP — VERIFIED ARCHIVE PLAN (read-only) ===');
+ console.log(`clusters: ${Object.keys(clusters).length} | products to ARCHIVE: ${archive} | HIGH-confidence: ${archive-review} | REVIEW (both coded, ~equal): ${review}`);
+ console.log(`plan -> /tmp/fleet_archive_plan.csv | archive list (+redirects) -> /tmp/fleet_archive_list.csv`);
+})().catch(e=>{console.error(e);process.exit(1);});
diff --git a/scripts/fleet-dedup-handle.js b/scripts/fleet-dedup-handle.js
new file mode 100644
index 0000000..6ade337
--- /dev/null
+++ b/scripts/fleet-dedup-handle.js
@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+/* READ-ONLY bulletproof fleet dup detector. Bulk-scans ALL active products and flags TRUE dups by
+ two high-confidence signals only (no SKU-garbage false positives):
+ (1) HANDLE-SUFFIX: handle `base-N` exists alongside active `base`, SAME vendor + SAME title-key.
+ Shopify only mints `-N` handles on collision, so this is a definitive duplicate.
+ (2) COLORWAY-SKU: same vendor + same manufacturer_sku + same title-key, where the SKU is a real
+ article number (contains BOTH a separator . or / AND a digit) — excludes maker-name skus
+ like SANCAR/TRUE/DONGHIA that pollute the naive grouping.
+ Writes /tmp/fleet_dedup.csv (keeper = base / non-suffixed / cleaner handle). NO writes. */
+const https=require('https'), fs=require('fs');
+const T=process.env.T, S='designer-laboratory-sandbox.myshopify.com';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+function gql(q){return new Promise((res,rej)=>{const rq=https.request(`https://${S}/admin/api/2024-10/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':T,'Content-Type':'application/json'}},x=>{let b='';x.on('data',d=>b+=d);x.on('end',()=>{try{res(JSON.parse(b))}catch(e){rej(b.slice(0,200))}})});rq.on('error',rej);rq.write(JSON.stringify({query:q}));rq.end();});}
+function dl(u){return new Promise((res,rej)=>{https.get(u,r=>{let b='';r.on('data',d=>b+=d);r.on('end',()=>res(b));}).on('error',rej);});}
+const norm=s=>(s||'').toUpperCase().replace(/\s+/g,' ').trim();
+const titleKey=t=>norm(t).replace(/\s*[-#]\s*\d+$/,'');
+const isSample=o=>(o||[]).some(x=>String(x.value).toLowerCase()==='sample');
+const fmt=iso=>{ if(!iso) return ''; return new Date(iso).toLocaleString('en-US',{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
+const realSku=s=>/[.\/]/.test(s)&&/\d/.test(s); // article-number-shaped, not a maker word
+
+(async()=>{
+ const BULK=`mutation{ bulkOperationRunQuery(query:"""
+ { products(query:"status:active"){ edges{ node{ id handle title vendor createdAt
+ metafield(namespace:"custom",key:"manufacturer_sku"){ value }
+ variants{ edges{ node{ price selectedOptions{ value } } } } } } } }
+ """){ bulkOperation{ id } userErrors{ field message } } }`;
+ const k=await gql(BULK); const ue=k.data?.bulkOperationRunQuery?.userErrors||[];
+ if(ue.length){ console.log('cannot start bulk:',JSON.stringify(ue)); process.exit(1); }
+ console.log('bulk started; polling...'); let url=null;
+ for(let i=0;i<240;i++){ const s=await gql(`{ currentBulkOperation{ status objectCount url errorCode } }`); const op=s.data.currentBulkOperation;
+ if(i%6===0) console.log(` ${op.status} obj=${op.objectCount||0}`);
+ if(op.status==='COMPLETED'){ url=op.url; break; } if(op.status==='FAILED'){ console.log('FAILED',op.errorCode); process.exit(1); } await sleep(5000); }
+ if(!url){ console.log('no url'); process.exit(1); }
+ const data=await dl(url);
+ const prod={}, vbyp={};
+ for(const n of data.trim().split('\n').filter(Boolean).map(l=>JSON.parse(l))){
+ if(n.id&&n.id.includes('/Product/')) prod[n.id]={gid:n.id.split('/').pop(),handle:n.handle,title:n.title,vendor:n.vendor,createdAt:n.createdAt,sku:norm(n.metafield?.value)};
+ else if(n.__parentId&&n.price!==undefined) (vbyp[n.__parentId]=vbyp[n.__parentId]||[]).push(n);
+ }
+ const all=Object.entries(prod).map(([pid,p])=>{ const reals=(vbyp[pid]||[]).map(v=>({pr:parseFloat(v.price),sm:isSample(v.selectedOptions)})).filter(v=>!v.sm&&v.pr>5);
+ return {...p, price:reals.length?Math.max(...reals.map(v=>v.pr)):null}; });
+ const byHandle={}; all.forEach(p=>byHandle[p.handle]=p);
+ const clusters={}; // keyed -> {keeper, dups:[]}
+ const claimed=new Set();
+
+ // (1) HANDLE-SUFFIX dups
+ for(const p of all){ const m=p.handle.match(/^(.+)-(\d+)$/); if(!m) continue;
+ const base=byHandle[m[1]]; if(!base) continue;
+ if(norm(base.vendor)!==norm(p.vendor)) continue;
+ if(titleKey(base.title)!==titleKey(p.title)) continue;
+ const key='H:'+base.handle; (clusters[key]=clusters[key]||{keeper:base,dups:[]});
+ clusters[key].dups.push(p); claimed.add(p.gid); claimed.add(base.gid);
+ }
+ // (2) COLORWAY-SKU dups (real article-number skus only), excluding anything already handle-claimed
+ const bySku={}; for(const p of all){ if(!realSku(p.sku)) continue; const key=norm(p.vendor)+'|'+p.sku+'|'+titleKey(p.title); (bySku[key]=bySku[key]||[]).push(p); }
+ for(const [key,arr] of Object.entries(bySku)){ if(arr.length<2) continue;
+ if(arr.every(p=>claimed.has(p.gid))) continue;
+ const ranked=arr.slice().sort((a,b)=> ( /-\d+$/.test(a.handle)?0:1)-(/-\d+$/.test(b.handle)?0:1) || (b.price||0)-(a.price||0) || new Date(b.createdAt)-new Date(a.createdAt));
+ const keeper=ranked[0];
+ clusters['S:'+key]={keeper,dups:ranked.slice(1).filter(p=>p.gid!==keeper.gid)};
+ }
+
+ const out=['signal,vendor,sku,dup_id,dup_handle,keep_id,keep_handle,keep_price,created'];
+ let n=0; const perV={};
+ for(const [key,c] of Object.entries(clusters)){ if(!c.dups.length) continue;
+ for(const d of c.dups){ n++; perV[d.vendor]=(perV[d.vendor]||0)+1;
+ out.push(`${key[0]},${d.vendor},${d.sku},${d.gid},${d.handle},${c.keeper.gid},${c.keeper.handle},${c.keeper.price??''},"${fmt(d.createdAt)}"`); }
+ }
+ fs.writeFileSync('/tmp/fleet_dedup.csv',out.join('\n'));
+ console.log(`\n=== FLEET TRUE-DUP SWEEP (bulletproof: handle-suffix + real-sku, READ-ONLY) ===`);
+ console.log(`active scanned: ${all.length} | dup products to remove: ${n}`);
+ console.log('per vendor:'); for(const [v,c] of Object.entries(perV).sort((a,b)=>b[1]-a[1])) console.log(` ${v.padEnd(24)} ${c}`);
+ console.log(`\nlist -> /tmp/fleet_dedup.csv`);
+})().catch(e=>{console.error(e);process.exit(1);});
← e36e0e2 Resolve 4 Schumacher oddballs via sku-swap (bare->Roll, exis
·
back to Dw Yolo Loop
·
Move DWLK-829420 (probe-renamed to Size:Sample) from edge→cl c1e6e70 →