← back to Tk10640 Redirect Prune

apply-prune-to-home.mjs

49 lines

// TK-10640 — GATED APPLY: prune the "redirect -> homepage" class to free cap space.
// Targets ONLY redirects whose target is "/" or empty (verified-safe class: 32,120 rows,
// of which 32,045 are /products/<handle> with source NEVER ACTIVE+published, +75 legacy junk).
// DRY-RUN by default. --apply performs urlRedirectDelete. Resumable (done log).
// BELT-AND-SUSPENDERS: before deleting a /products/<h> -> / redirect, re-check the source
// handle is NOT an ACTIVE+published product (onlineStoreUrl null). If it IS live, SKIP +
// flag (would be a hijack we must NOT silently delete without review).
// Uses SHOPIFY_FULL_ACCESS_TOKEN (only content-scoped token). NEVER run --apply without
// Steve's go — this file is drafted to pending-approval; the loop does not fire it.
import { readFileSync, appendFileSync, existsSync } from 'node:fs';
const txt=readFileSync(`${process.env.HOME}/Projects/secrets-manager/.env`,'utf8');
const env={}; for(const l of txt.split('\n')){const m=l.match(/^([A-Z0-9_]+)=(.*)$/); if(m) env[m[1]]=m[2].replace(/^["']|["']$/g,'');}
const STORE=env.SHOPIFY_STORE_DOMAIN, TOKEN=env.SHOPIFY_FULL_ACCESS_TOKEN, API='2024-10';
const APPLY=process.argv.includes('--apply');
const LIMIT=Number((process.argv.find(a=>a.startsWith('--limit='))||'').split('=')[1]||Infinity);
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function gql(q,v={}){for(let a=0;a<10;a++){try{const res=await fetch(`https://${STORE}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'Content-Type':'application/json','X-Shopify-Access-Token':TOKEN},body:JSON.stringify({query:q,variables:v})});const tx=await res.text();let j;try{j=JSON.parse(tx);}catch{await sleep(1500*(a+1));continue;}if(j.errors){if(/THROTTLED/i.test(JSON.stringify(j.errors))){await sleep(1500*(a+1));continue;}throw new Error(JSON.stringify(j.errors));}return j.data;}catch(e){if(a<9){await sleep(1500*(a+1));continue;}throw e;}}}
const DONE=`${process.env.HOME}/Projects/tk10640-redirect-prune/data/done-prune-to-home.jsonl`;
const done=new Set(); if(existsSync(DONE)) for(const l of readFileSync(DONE,'utf8').split('\n')) if(l.trim()) done.add(JSON.parse(l).id);
const DEL=`mutation($id:ID!){ urlRedirectDelete(id:$id){ deletedUrlRedirectId userErrors{ message } } }`;
const rows=readFileSync(`${process.env.HOME}/Projects/tk10640-redirect-prune/data/redirects-dump.jsonl`,'utf8').trim().split('\n').map(JSON.parse);
const norm=s=>(s||'').split('?')[0].split('#')[0];
const toHome=rows.filter(r=>{const t=norm(r.target);return !t||t==='/';});
let scanned=0,deleted=0,skipped_live=0,err=0;
for(const r of toHome){
  if(done.has(r.id)) continue;
  scanned++;
  // belt-and-suspenders live re-check for ANY source that maps to a live resource
  // (Cody fix: previously only /products/ was checked; /collections/ sources deleted blind).
  if(/^\/products\//.test(r.path)){
    const h=r.path.replace(/^\/products\//,'').split('?')[0].split('#')[0];
    const d=await gql(`query($h:String!){productByHandle(handle:$h){status onlineStoreUrl}}`,{h});
    const p=d.productByHandle;
    if(p && p.status==='ACTIVE' && p.onlineStoreUrl){ skipped_live++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,skipped:'LIVE_ACTIVE_PUBLISHED_PRODUCT'})+'\n'); continue; }
  } else if(/^\/collections\//.test(r.path)){
    const h=r.path.replace(/^\/collections\//,'').split('?')[0].split('#')[0];
    const d=await gql(`query($h:String!){collectionByHandle(handle:$h){id}}`,{h});
    if(d.collectionByHandle){ skipped_live++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,skipped:'LIVE_COLLECTION'})+'\n'); continue; }
  }
  if(APPLY){
    const del=await gql(DEL,{id:r.id});
    if(del.urlRedirectDelete.userErrors.length){ err++; }
    else { deleted++; appendFileSync(DONE,JSON.stringify({id:r.id,path:r.path,deleted:true})+'\n'); }
  }
  if(scanned%2500===0) process.stderr.write(`  scanned ${scanned}, deleted ${deleted}, skipped_live ${skipped_live}\n`);
  if(deleted>=LIMIT){ process.stderr.write(`hit --limit=${LIMIT}\n`); break; }
}
console.log(JSON.stringify({mode:APPLY?'APPLY':'DRY-RUN',to_home_total:toHome.length,scanned,deleted,skipped_live_active_published:skipped_live,errors:err},null,2));