← back to Dw Image Shrink

purge-execute.js

62 lines

// Delete disposable images to free storage. SAFE categories only:
//   A = images on ARCHIVED products (non-gif)
//   B = spin-GIFs on ARCHIVED products
// NEVER touches active/draft products here. Every delete is appended to
// purge-deleted.jsonl (audit + resumability). Idempotent: skips already-logged
// image_ids. Throttled with 429 backoff.  Frees space only after Shopify async-GC.
//   node purge-execute.js --cat=B [--cat=A] [--limit=N] [--rate=330]
import fs from 'fs';
import https from 'https';
const DIR='/Users/macstudio3/Projects/dw-image-shrink';
const SHOP='designer-laboratory-sandbox.myshopify.com';
const ATOK=fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8').split('\n').find(l=>l.startsWith('SHOPIFY_ADMIN_TOKEN=')).split('=').slice(1).join('=').replace(/["' ]/g,'');
const arg=(k,d)=>{const a=process.argv.find(x=>x.startsWith('--'+k+'='));return a?a.split('=').slice(1).join('='):d;};
const CATS=(arg('cat','B')).split('').filter(c=>'ABC'.includes(c));
const LIMIT=+arg('limit','0')||1e9;
const RATE=+arg('rate','330');
const LOG=DIR+'/purge-deleted.jsonl';
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const isGif=s=>/\.gif(\?|$)/i.test(s||'');
function del(pid,iid){return new Promise((res,rej)=>{const req=https.request({method:'DELETE',host:SHOP,path:`/admin/api/2024-10/products/${pid}/images/${iid}.json`,headers:{'X-Shopify-Access-Token':ATOK}},r=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>res({status:r.statusCode,retryAfter:r.headers['retry-after'],limit:r.headers['x-shopify-shop-api-call-limit']||''}));});req.on('error',rej);req.setTimeout(30000,()=>req.destroy(new Error('timeout')));req.end();});}

// already-deleted set (resume)
const doneSet=new Set();
if(fs.existsSync(LOG)) for(const l of fs.readFileSync(LOG,'utf8').split('\n')) if(l){try{doneSet.add(String(JSON.parse(l).image_id))}catch(e){}}

const rows=fs.readFileSync(DIR+'/inventory.jsonl','utf8').split('\n').filter(Boolean).map(l=>JSON.parse(l));
let cands=[];
if(CATS.includes('B')) cands.push(...rows.filter(r=>r.status==='archived'&&isGif(r.src)).map(r=>({...r,cat:'B'})));
if(CATS.includes('A')) cands.push(...rows.filter(r=>r.status==='archived'&&!isGif(r.src)).map(r=>({...r,cat:'A'})));
if(CATS.includes('C')){
  // ACTIVE spin-GIFs — ONLY when the product keeps another image (never orphan an active product)
  const cnt={}; for(const r of rows) cnt[r.product_id]=(cnt[r.product_id]||0)+1;
  const cActive=rows.filter(r=>r.status==='active'&&isGif(r.src));
  const safeC=cActive.filter(r=>cnt[r.product_id]>1);
  const onlyImg=cActive.length-safeC.length;
  console.log(`  cat C: ${cActive.length} active GIFs, ${safeC.length} safe to delete, ${onlyImg} SKIPPED (only image on their product)`);
  cands.push(...safeC.map(r=>({...r,cat:'C'})));
}
cands=cands.filter(r=>!doneSet.has(String(r.image_id))).slice(0,LIMIT);
console.log(`purge cats[${CATS}]: ${cands.length} candidates to delete (already done: ${doneSet.size})`);

const out=fs.createWriteStream(LOG,{flags:'a'});
let ok=0,err=0;
for(let i=0;i<cands.length;i++){
  const r=cands[i];
  let resp;
  for(let a=0;a<8;a++){
    try{ resp=await del(r.product_id,r.image_id); }catch(e){ await sleep(1500); continue; }
    if(resp.status===429){ await sleep((parseFloat(resp.retryAfter)||3)*1000); continue; }
    break;
  }
  const okd = resp && resp.status<300;
  if(okd) ok++; else err++;
  out.write(JSON.stringify({image_id:r.image_id,product_id:r.product_id,cat:r.cat,status:r.status,src:r.src,http:resp?resp.status:0,ts:new Date().toISOString()})+'\n');
  // adaptive pace off leaky bucket
  const [u,m]=((resp&&resp.limit)||'0/40').split('/').map(Number);
  await sleep(u>(m*0.5)?800:RATE);
  if((i+1)%100===0) console.log(`  deleted ${ok} ok / ${err} err (${i+1}/${cands.length})`);
}
out.end();
console.log(`DONE cats[${CATS}]: ${ok} deleted, ${err} errors. (space frees after Shopify GC)`);