← back to Letsbegin

schu-dup-image-audit.js

117 lines

#!/usr/bin/env node
/**
 * Read-only audit: latest Schumacher uploads on the sandbox Shopify store.
 * Detects (a) duplicate images WITHIN a product (same image URL repeated in the
 * gallery), and (b) the same image shared ACROSS multiple Schumacher products.
 * No writes. Sandbox store only.
 */
const fs = require('fs');
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');
const TOKEN = get('SHOPIFY_ADMIN_TOKEN');
if (!STORE || !TOKEN) { console.error('missing store/token'); process.exit(1); }

const VENDOR = process.argv[2] || 'Schumacher';
const LIMIT = parseInt(process.argv[3] || '120', 10); // how many recent products to scan

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;
}

// strip the shopify cdn transform suffix (?v=, _ {width}x, .progressive) so we
// compare the underlying asset, not a resized variant URL
function canon(url) {
  if (!url) return url;
  let u = url.split('?')[0];
  u = u.replace(/_\d+x\d+(?=\.)/,'').replace(/_\d+x(?=\.)/,'').replace(/_x\d+(?=\.)/,'');
  return u;
}

const Q = `
query($q:String!, $cursor:String){
  products(first:50, query:$q, sortKey:CREATED_AT, reverse:true, after:$cursor){
    edges{ cursor node{
      id legacyResourceId title handle status vendor createdAt
      media(first:50){ edges{ node{ ... on MediaImage { id image { url } } } } }
    }}
    pageInfo{ hasNextPage }
  }
}`;

(async () => {
  const qstr = `vendor:'${VENDOR}'`;
  let cursor = null, scanned = [], pages = 0;
  while (scanned.length < LIMIT) {
    const d = await gql(Q, { q: qstr, cursor });
    const edges = d.products.edges;
    for (const e of edges) scanned.push(e.node);
    pages++;
    if (!d.products.pageInfo.hasNextPage || edges.length === 0) break;
    cursor = edges[edges.length - 1].cursor;
    if (scanned.length >= LIMIT) break;
  }
  scanned = scanned.slice(0, LIMIT);

  const crossMap = new Map(); // canon url -> [{title, status, id}]
  const withinDupes = [];     // products with repeated image in own gallery
  let totalImgs = 0, noImg = 0;

  for (const p of scanned) {
    const imgs = (p.media?.edges || []).map(m => m.node?.image?.url).filter(Boolean);
    totalImgs += imgs.length;
    if (imgs.length === 0) noImg++;
    const seen = new Map();
    for (const u of imgs) {
      const c = canon(u);
      seen.set(c, (seen.get(c) || 0) + 1);
      if (!crossMap.has(c)) crossMap.set(c, []);
      crossMap.get(c).push({ title: p.title, status: p.status, lid: p.legacyResourceId, handle: p.handle });
    }
    const repeats = [...seen.entries()].filter(([, n]) => n > 1);
    if (repeats.length) {
      withinDupes.push({ title: p.title, status: p.status, lid: p.legacyResourceId, handle: p.handle,
        imgCount: imgs.length, repeats });
    }
  }

  // cross-product: same canon url on >1 DISTINCT product
  const crossDupes = [];
  for (const [c, arr] of crossMap.entries()) {
    const distinct = [...new Map(arr.map(a => [a.lid, a])).values()];
    if (distinct.length > 1) crossDupes.push({ url: c, products: distinct });
  }

  console.log(`\n=== ${VENDOR} image-duplication audit (sandbox, read-only) ===`);
  console.log(`Scanned ${scanned.length} most-recent ${VENDOR} products (${pages} page(s)).`);
  console.log(`Created range: ${scanned[scanned.length-1]?.createdAt?.slice(0,10)} … ${scanned[0]?.createdAt?.slice(0,10)}`);
  console.log(`Total images: ${totalImgs} · products w/ 0 images: ${noImg}`);

  console.log(`\n--- (A) DUPLICATE IMAGE WITHIN A SINGLE PRODUCT (same photo repeated in gallery): ${withinDupes.length} products ---`);
  for (const w of withinDupes.slice(0, 40)) {
    console.log(`  • [${w.status}] ${w.title}  (gid ${w.lid}, ${w.imgCount} imgs)`);
    for (const [u, n] of w.repeats) console.log(`        ×${n}  ${u.split('/').pop()}`);
  }
  if (withinDupes.length > 40) console.log(`  …and ${withinDupes.length - 40} more`);

  console.log(`\n--- (B) SAME IMAGE SHARED ACROSS MULTIPLE PRODUCTS: ${crossDupes.length} shared images ---`);
  crossDupes.sort((a,b)=>b.products.length-a.products.length);
  for (const c of crossDupes.slice(0, 30)) {
    console.log(`  • ${c.products.length} products share  ${c.url.split('/').pop()}`);
    for (const p of c.products.slice(0, 6)) console.log(`        [${p.status}] ${p.title} (${p.lid})`);
    if (c.products.length > 6) console.log(`        …and ${c.products.length - 6} more`);
  }
  if (crossDupes.length > 30) console.log(`  …and ${crossDupes.length - 30} more shared images`);

  // machine-readable dump for follow-up
  fs.writeFileSync('/tmp/schu-dup-image-audit.json', JSON.stringify({ vendor: VENDOR, scanned: scanned.length, withinDupes, crossDupes }, null, 2));
  console.log(`\nFull detail → /tmp/schu-dup-image-audit.json`);
})().catch(e => { console.error('ERR', e.message); process.exit(1); });