← back to Letsbegin
schu-gallery-dedup.js
116 lines
#!/usr/bin/env node
/**
* Dedup duplicate gallery images on Schumacher Shopify products (sandbox).
* For each product: fetch all media bytes -> cluster by md5 (exact) + 16x16
* average-hash (perceptual, hamming<=10) -> KEEP the first image of each unique
* cluster (preserves swatch-first order), DETACH the rest via productDeleteMedia.
*
* DRY-RUN by default. Pass --apply to actually delete. Every detached media id
* (+url) is snapshotted to a recovery JSON so it can be re-imported.
*
* node schu-gallery-dedup.js # dry-run, recent cohort
* node schu-gallery-dedup.js --apply # execute
* node schu-gallery-dedup.js --since 2026-06-01 --apply
* node schu-gallery-dedup.js --all --apply # whole Schumacher catalog
*/
const fs = require('fs');
const crypto = require('crypto');
const sharp = require('sharp');
const env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
const get = k => (env.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1];
const STORE = get('SHOPIFY_STORE'), TOKEN = get('SHOPIFY_ADMIN_TOKEN');
const APPLY = process.argv.includes('--apply');
const ALL = process.argv.includes('--all');
const sinceIdx = process.argv.indexOf('--since');
const SINCE = sinceIdx > -1 ? process.argv[sinceIdx + 1] : '2026-06-01';
const VENDOR = 'Schumacher';
const SNAP = `/tmp/schu-gallery-dedup-${APPLY ? 'APPLIED' : 'DRYRUN'}.json`;
const LOG = `/tmp/schu-gallery-dedup.log`;
function log(s){ const line = s+'\n'; process.stdout.write(line); fs.appendFileSync(LOG, line); }
async function gql(query, variables) {
for (let attempt=0; attempt<4; attempt++){
const r = await fetch(`https://${STORE}/admin/api/2024-10/graphql.json`, {
method:'POST', headers:{'Content-Type':'application/json','X-Shopify-Access-Token':TOKEN},
body: JSON.stringify({ query, variables }) });
const j = await r.json();
if (j.errors && JSON.stringify(j.errors).includes('Throttled')) { await sleep(2000*(attempt+1)); continue; }
if (j.errors) throw new Error(JSON.stringify(j.errors));
return j.data;
}
throw new Error('throttled out');
}
const sleep = ms => new Promise(r=>setTimeout(r, ms));
const LIST = `query($q:String!,$c:String){products(first:40,query:$q,sortKey:CREATED_AT,reverse:true,after:$c){
edges{cursor node{ id legacyResourceId title status createdAt
media(first:60){edges{node{ ... on MediaImage { id image{url} } }}} }} pageInfo{hasNextPage}}}`;
const DEL = `mutation($pid:ID!,$ids:[ID!]!){productDeleteMedia(productId:$pid,mediaIds:$ids){deletedMediaIds mediaUserErrors{field message}}}`;
async function phash(buf){
const px = await sharp(buf).greyscale().resize(16,16,{fit:'fill'}).raw().toBuffer();
let s=0; for (const v of px) s+=v; const avg=s/px.length;
let b=''; for (const v of px) b += v>avg?'1':'0'; return b;
}
const ham=(a,b)=>{let d=0;for(let i=0;i<a.length;i++)if(a[i]!==b[i])d++;return d;};
async function fetchMeta(m){
try{ const r=await fetch(m.url); const buf=Buffer.from(await r.arrayBuffer());
return {...m, ok:true, md5:crypto.createHash('md5').update(buf).digest('hex'), ph:await phash(buf), bytes:buf.length}; }
catch(e){ return {...m, ok:false}; }
}
(async () => {
fs.writeFileSync(LOG,'');
const q = ALL ? `vendor:'${VENDOR}'` : `vendor:'${VENDOR}' AND created_at:>=${SINCE}`;
log(`=== Schumacher gallery dedup — ${APPLY?'APPLY (writes)':'DRY-RUN (read-only)'} ===`);
log(`scope: ${ALL?'ALL Schumacher':`created_at >= ${SINCE}`}`);
let cursor=null, products=[];
while(true){ const d=await gql(LIST,{q,c:cursor}); const e=d.products.edges;
for(const x of e) products.push(x.node);
if(!d.products.pageInfo.hasNextPage||!e.length)break; cursor=e[e.length-1].cursor; }
log(`products in scope: ${products.length}`);
const snapshot=[]; let pAffected=0, mDetached=0, mTotal=0, idx=0;
let actAff=0, drAff=0;
for (const p of products){
idx++;
const media=(p.media?.edges||[]).map(x=>x.node).filter(n=>n?.image?.url).map(n=>({id:n.id,url:n.image.url}));
mTotal+=media.length;
// fetch+hash with small concurrency
const metas=[]; const CC=6;
for(let i=0;i<media.length;i+=CC){ const chunk=await Promise.all(media.slice(i,i+CC).map(fetchMeta)); metas.push(...chunk); }
const good=metas.filter(m=>m.ok);
// cluster keep-first
const kept=[]; const toDelete=[];
for(const m of good){
const dup=kept.find(k=>k.md5===m.md5 || ham(k.ph,m.ph)<=10);
if(dup) toDelete.push(m); else kept.push(m);
}
if(toDelete.length){
pAffected++; mDetached+=toDelete.length;
if(p.status==='ACTIVE')actAff++; else if(p.status==='DRAFT')drAff++;
snapshot.push({ productId:p.id, lid:p.legacyResourceId, title:p.title, status:p.status,
before:media.length, kept:kept.length, deleted:toDelete.map(d=>({id:d.id,url:d.url})) });
log(` [${p.status}] ${p.title.replace(/ \| Wallcovering \| Schumacher/,'')}: ${media.length} -> ${kept.length} (detach ${toDelete.length})`);
if(APPLY){
const d=await gql(DEL,{pid:p.id, ids:toDelete.map(t=>t.id)});
const errs=d.productDeleteMedia.mediaUserErrors;
if(errs && errs.length) log(` ⚠ userErrors: ${JSON.stringify(errs)}`);
await sleep(350); // gentle on the API
}
}
if(idx%25===0) log(` …${idx}/${products.length} scanned`);
}
fs.writeFileSync(SNAP, JSON.stringify({apply:APPLY, scope:ALL?'ALL':`>=${SINCE}`, products:products.length, snapshot}, null, 2));
log(`\n=== SUMMARY (${APPLY?'APPLIED':'DRY-RUN'}) ===`);
log(`products in scope: ${products.length}`);
log(`products with dup imgs: ${pAffected} (ACTIVE ${actAff} / DRAFT ${drAff})`);
log(`total media: ${mTotal}`);
log(`media ${APPLY?'DETACHED':'to detach'}: ${mDetached} (${mTotal?((mDetached/mTotal)*100).toFixed(0):0}% of gallery slots)`);
log(`recovery snapshot: ${SNAP}`);
if(!APPLY) log(`\nDry-run only. Re-run with --apply to detach.`);
})().catch(e=>{ log('ERR '+e.message); process.exit(1); });