← back to Dw Yolo Loop
scripts/dedup-bulk-all.js
69 lines
#!/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);});