← back to Tk10640 Redirect Prune

build-remediation-map.mjs

72 lines

// TK-10653 — Build the deterministic REMEDIATION + RESTORE map for every BROKEN redirect
// (from data/full-scan/broken-redirects.jsonl). NO Shopify writes here — pure planning.
//
// For each broken redirect {id,path,target} it decides ONE action:
//   REPOINT  -> a live ACTIVE+published product shares the dead target's handle PREFIX
//              (same pattern / adjacent colorway). new target = /products/<liveHandle>.
//   PRUNE    -> no live replacement. Delete the redirect so 301->404 becomes a clean 404
//              (better SEO + frees the 100k cap).
// Uses the handle-liveness cache from the full scan for target-death, and a bounded
// products(query:"handle:<prefix>* AND status:active") lookup for replacements (cached).
//
// OUTPUTS (all under data/full-scan/):
//   remediation-map.jsonl  — one row per broken redirect: {id,path,old_target,action,new_target}
//   restore-map.jsonl      — the EXACT undo per row: for REPOINT {id,restore_target:old_target};
//                            for PRUNE {path,recreate_target:old_target} (recreate by path)
//   remediation-summary.json
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
const HOME=process.env.HOME;
const txt=readFileSync(`${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||'designer-laboratory-sandbox.myshopify.com', TOKEN=env.SHOPIFY_FULL_ACCESS_TOKEN, API='2024-10';
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function gql(q,v={}){for(let a=0;a<12;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(1200*(a+1));continue;}
  if(j.errors){if(/THROTTLED/i.test(JSON.stringify(j.errors))){await sleep(1200*(a+1));continue;}throw new Error(JSON.stringify(j.errors));}
  return j.data;
}catch(e){if(a<11){await sleep(1200*(a+1));continue;}throw e;}}}
const norm=s=>(s||'').split('?')[0].split('#')[0];
function prefixOf(handle){
  let h=handle;
  h=h.replace(/-[a-z]{2,6}-\d{3,6}$/i,'');   // -cor-23087, -str-54865
  h=h.replace(/-\d{3,6}$/,'');                 // trailing -74621
  return h;
}
const broken=readFileSync('data/full-scan/broken-redirects.jsonl','utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
// replacement cache: prefix -> liveHandle|null
const RC='data/full-scan/replacement-cache.json';
const rcache= existsSync(RC)? JSON.parse(readFileSync(RC,'utf8')) : {};
async function findReplacement(deadHandle){
  const pref=prefixOf(deadHandle);
  if(pref.length<4) return null;
  if(pref in rcache) return rcache[pref];
  const r=await gql(`query($q:String!){products(first:5, query:$q){ nodes{ handle status } } }`,{q:`handle:${pref}* AND status:active`});
  const cands=(r.products?.nodes||[]).filter(n=>n.status==='ACTIVE'&&n.handle!==deadHandle&&n.handle.startsWith(pref+'-'));
  const pick=cands.length? cands[0].handle : null;
  rcache[pref]=pick;
  return pick;
}
const remOut=[], restOut=[]; let repoint=0, prune=0, i=0;
for(const b of broken){
  const dead=norm(b.target).replace('/products/','');
  const rep= await findReplacement(dead);
  if(rep){
    const newTarget=`/products/${rep}`;
    remOut.push({id:b.id,path:b.path,old_target:b.target,action:'REPOINT',new_target:newTarget});
    restOut.push({id:b.id,path:b.path,action:'REPOINT',restore_target:b.target});
    repoint++;
  } else {
    remOut.push({id:b.id,path:b.path,old_target:b.target,action:'PRUNE',new_target:null});
    restOut.push({path:b.path,action:'PRUNE',recreate_target:b.target});
    prune++;
  }
  if(++i%1000===0){ writeFileSync(RC,JSON.stringify(rcache)); process.stderr.write(`  ${i}/${broken.length} repoint=${repoint} prune=${prune}\n`); }
}
writeFileSync(RC,JSON.stringify(rcache));
writeFileSync('data/full-scan/remediation-map.jsonl', remOut.map(x=>JSON.stringify(x)).join('\n')+'\n');
writeFileSync('data/full-scan/restore-map.jsonl', restOut.map(x=>JSON.stringify(x)).join('\n')+'\n');
const summary={ broken_total:broken.length, REPOINT:repoint, PRUNE:prune, repoint_rate:+(repoint/broken.length).toFixed(4) };
writeFileSync('data/full-scan/remediation-summary.json', JSON.stringify(summary,null,2));
console.log(JSON.stringify(summary,null,2));