← back to Tk10640 Redirect Prune
replacement-matcher.mjs
50 lines
// TK-10653 — READ-ONLY replacement matcher (Cody FIX): for a random sample of DEAD redirect
// targets, find whether a live ACTIVE+published replacement exists by HANDLE-PREFIX match
// (strip the trailing SKU token -<alpha>-<digits>, e.g. aged-wood-walls-cor-23087 -> prefix
// "aged-wood-walls"; a live handle starting "aged-wood-walls-" = HIGH-confidence replacement).
// Reports true hit rate: HIGH / NONE per dead target. No writes.
import { readFileSync, writeFileSync } 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 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 norm=s=>(s||'').split('?')[0].split('#')[0];
// build the FULL dead-target candidate list: unique product targets whose product is not active+published
// (reuse: sample from unique-product-targets, but re-verify dead + match). We take a random sample.
const N=Number((process.argv.find(a=>a.startsWith('--n='))||'').split('=')[1]||300);
let targets=readFileSync('data/unique-product-targets.txt','utf8').trim().split('\n');
for(let i=targets.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[targets[i],targets[j]]=[targets[j],targets[i]];}
// strip trailing SKU token: -<letters/mixed>-<digits> OR -<digits> at end; keep pattern prefix
function prefixOf(handle){
let h=handle;
h=h.replace(/-[a-z]{2,6}-\d{3,6}$/i,''); // -cor-23087, -lec-5011, -str-54865
h=h.replace(/-\d{3,6}$/,''); // trailing -74621
h=h.replace(/-[a-z0-9]+-\1$/i,''); // duplicated tail like -rl-floral-066-rl-floral-066 (best effort)
return h;
}
let dead=0, hit=0, none=0, live=0; const matches=[], misses=[];
let probed=0;
for(const t of targets){
if(probed>=N) break;
const h=t.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){ live++; continue; } // not dead, skip (we only rate dead targets)
dead++; probed;
const pref=prefixOf(h);
if(pref.length<4){ none++; misses.push({target:t,prefix:pref,reason:'prefix-too-short'}); probed++; continue; }
// find a live ACTIVE published product whose handle starts with the same pattern prefix
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!==h&&n.handle.startsWith(pref));
if(cands.length){ hit++; if(matches.length<50) matches.push({dead_target:t,prefix:pref,replacement:cands[0].handle}); }
else { none++; if(misses.length<50) misses.push({target:t,prefix:pref}); }
probed++;
if(probed%75===0) process.stderr.write(` dead probed ${probed}: replacement HIT=${hit} NONE=${none}\n`);
}
writeFileSync('data/replacement-matches.json', JSON.stringify({matches,misses:misses.slice(0,30)},null,2));
console.log(JSON.stringify({dead_targets_probed:dead, replacement_HIT:hit, replacement_NONE:none,
hit_rate:dead?+(hit/dead).toFixed(3):0,
est_recoverable_of_46444:Math.round((dead?hit/dead:0)*46444),
note:'HIT = a live ACTIVE+published product shares the dead target handle prefix (same pattern/colorway family)'},null,2));