← back to Letsbegin
schu-visual-dup-audit.js
90 lines
#!/usr/bin/env node
/**
* Read-only VISUAL duplicate audit for latest Schumacher uploads (sandbox).
* For each product: fetch every gallery image, compute (1) md5 of raw bytes
* (exact-dup) and (2) a 16x16 grayscale average-hash (perceptual near-dup).
* Reports images that are visually identical within the same product page.
* No writes.
*/
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 VENDOR = process.argv[2] || 'Schumacher';
const LIMIT = parseInt(process.argv[3] || '40', 10);
async function gql(query, variables) {
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) throw new Error(JSON.stringify(j.errors)); return j.data;
}
const Q = `query($q:String!,$cursor:String){products(first:30,query:$q,sortKey:CREATED_AT,reverse:true,after:$cursor){
edges{cursor node{title legacyResourceId status media(first:50){edges{node{...on MediaImage{image{url}}}}}}} pageInfo{hasNextPage}}}`;
// 16x16 grayscale -> avg hash (256-bit). hamming distance <= 10 ~ visual dup.
async function phash(buf) {
const px = await sharp(buf).greyscale().resize(16,16,{fit:'fill'}).raw().toBuffer();
let sum=0; for (const v of px) sum+=v; const avg=sum/px.length;
let bits=''; for (const v of px) bits += v>avg?'1':'0';
return bits;
}
const ham = (a,b) => { let d=0; for (let i=0;i<a.length;i++) if(a[i]!==b[i]) d++; return d; };
(async () => {
const qstr = `vendor:'${VENDOR}'`;
let cursor=null, prods=[];
while (prods.length < LIMIT) {
const d = await gql(Q,{q:qstr,cursor});
for (const e of d.products.edges) prods.push(e.node);
if (!d.products.pageInfo.hasNextPage || !d.products.edges.length) break;
cursor = d.products.edges[d.products.edges.length-1].cursor;
}
prods = prods.slice(0, LIMIT);
let flagged = [], totalImgs=0, fetchErr=0;
for (const p of prods) {
const urls = (p.media?.edges||[]).map(m=>m.node?.image?.url).filter(Boolean);
const metas = [];
for (const u of urls) {
try {
const r = await fetch(u); const ab = await r.arrayBuffer(); const buf = Buffer.from(ab);
metas.push({ url:u, name:u.split('/').pop().split('?')[0], md5:crypto.createHash('md5').update(buf).digest('hex'), ph: await phash(buf) });
totalImgs++;
} catch(e){ fetchErr++; }
}
// exact byte dupes
const byMd5 = {}; metas.forEach(m=>{(byMd5[m.md5]=byMd5[m.md5]||[]).push(m.name);});
const exact = Object.values(byMd5).filter(a=>a.length>1);
// perceptual dupes (cluster by hamming<=10), excluding exact pairs already counted
const visual = [];
for (let i=0;i<metas.length;i++) for (let j=i+1;j<metas.length;j++){
if (metas[i].md5===metas[j].md5) continue;
const d = ham(metas[i].ph, metas[j].ph);
if (d<=10) visual.push([metas[i].name, metas[j].name, d]);
}
// cluster all images by visual identity (union exact + perceptual ham<=10) -> unique count
const parent = metas.map((_,i)=>i);
const find=x=>{while(parent[x]!==x){parent[x]=parent[parent[x]];x=parent[x];}return x;};
const uni=(a,b)=>{parent[find(a)]=find(b);};
for (let i=0;i<metas.length;i++) for (let j=i+1;j<metas.length;j++){
if (metas[i].md5===metas[j].md5 || ham(metas[i].ph,metas[j].ph)<=10) uni(i,j);
}
const uniqueCount = new Set(metas.map((_,i)=>find(i))).size;
p._n = metas.length; p._u = uniqueCount;
if (exact.length || visual.length) flagged.push({ title:p.title, status:p.status, lid:p.legacyResourceId, n:metas.length, unique:uniqueCount, exact, visual });
}
const tImg = prods.reduce((s,p)=>s+(p._n||0),0), tUni = prods.reduce((s,p)=>s+(p._u||0),0);
console.log(`\n=== ${VENDOR} VISUAL duplicate audit (sandbox, read-only) ===`);
console.log(`Scanned ${prods.length} latest products · ${totalImgs} images fetched · ${fetchErr} fetch errors`);
console.log(`Products with visual/exact dup images: ${flagged.length} / ${prods.length}`);
console.log(`TOTAL images uploaded: ${tImg} · TRULY UNIQUE: ${tUni} · DUPLICATE WASTE: ${tImg-tUni} (${((tImg-tUni)/tImg*100).toFixed(0)}%)\n`);
console.log('Per-product (images → unique):');
for (const f of flagged) console.log(` [${f.status}] ${f.title.replace(/ \| Wallcovering \| Schumacher/,'')}: ${f.n} → ${f.unique} unique`);
fs.writeFileSync('/tmp/schu-visual-dup-audit.json', JSON.stringify(flagged,null,2));
console.log(`\nDetail → /tmp/schu-visual-dup-audit.json`);
})().catch(e=>{console.error('ERR',e.message);process.exit(1);});