← back to Dw Yolo Loop

scripts/redirect-health/redirect-health.mjs

105 lines

// redirect-health — READ-ONLY, $0. Audits the customer-facing redirect surface
// the handle-freshness canary does NOT cover:
//   1. canonicalization: http→https, www↔apex, trailing-slash — must be ONE clean 301.
//   2. redirect chains/loops: a hop count >2 or a cycle is an SEO penalty.
//   3. archived/discontinued product URLs: do they 301 (link equity preserved)
//      or 404 (equity lost)? Shopify auto-creates redirects for some; we measure.
// No Shopify writes; no token needed for the canonical/chain probes. The archived
// sample is read from the dw_unified mirror (status lags canonical — VERIFY live,
// which is exactly what this does: it hits the LIVE URL, not the mirror status).
import { execSync } from 'child_process';
const PSQL='/opt/homebrew/opt/postgresql@14/bin/psql';
const DB='postgresql:///dw_unified?host=/tmp';
const sleep=ms=>new Promise(r=>setTimeout(r,ms));

// follow redirects manually, capping the chain, detecting loops
async function trace(url,{max=6}={}){
  const chain=[]; const seen=new Set(); let cur=url;
  for(let i=0;i<max;i++){
    let r;
    try{ r=await fetch(cur,{method:'HEAD',redirect:'manual'}); }
    catch(e){ chain.push({url:cur,status:'ERR:'+e.message.slice(0,40)}); return {chain,final:cur,loop:false,err:true}; }
    const status=r.status; const loc=r.headers.get('location');
    chain.push({url:cur,status,loc:loc||null});
    if(status>=300&&status<400&&loc){
      let next; try{ next=new URL(loc,cur).toString(); }catch{ next=loc; }
      if(seen.has(next)) return {chain,final:next,loop:true,err:false};
      seen.add(cur); cur=next; continue;
    }
    return {chain,final:cur,loop:false,err:false};
  }
  return {chain,final:cur,loop:false,truncated:true,err:false};
}

const APEX='designerwallcoverings.com';
const WWW='www.designerwallcoverings.com'; // canonical host (per canon probe) — probe products here to avoid the apex→www hop confound
console.log('=== redirect-health (READ-ONLY, $0) ===\n');

// 1. canonicalization probes — each SHOULD land on https://<apex>/... in ≤1-2 hops
const canon=[
  ['http→https apex',   `http://${APEX}/`],
  ['http→https www',    `http://www.${APEX}/`],
  ['https www→apex',    `https://www.${APEX}/`],
  ['https apex (base)', `https://${APEX}/`],
];
const canonRes=[];
for(const [label,u] of canon){
  const t=await trace(u);
  const hops=t.chain.filter(c=>c.status>=300&&c.status<400).length;
  const finalOk=/^https:\/\//.test(t.final);
  canonRes.push({label,u,hops,final:t.final,loop:t.loop,finalOk,statuses:t.chain.map(c=>c.status).join('→')});
  console.log(`[canon] ${label.padEnd(18)} ${t.chain.map(c=>c.status).join('→').padEnd(14)} hops=${hops} ${t.loop?'LOOP!':''} → ${t.final}`);
  await sleep(200);
}

// 2. archived-product URL link-equity sample: pull recently-archived handles from mirror,
//    probe live to see if they 301 (equity kept) or 404 (equity lost).
let archived=[];
try{
  const out=execSync(`${PSQL} "${DB}" -At -F'|' -c "select handle, vendor from shopify_products where status='ARCHIVED' and handle is not null and handle<>'' order by updated_at_shopify desc nulls last limit 40"`,{encoding:'utf8'});
  archived=out.trim().split('\n').filter(Boolean).map(l=>{const[h,v]=l.split('|');return{h,v};});
}catch(e){ console.log('\n[archived] mirror read failed: '+e.message.slice(0,80)); }

// Honest classification (corrected after skeptical verify): a redirect-to-200 is
// NOT automatically "equity kept". A dead-product redirect to the HOMEPAGE is a
// Google-recognized SOFT-404 — dropped from index, ~0 link-equity passed, wastes
// crawl budget. And a 302 (temporary) tells Google "this URL will come back" — the
// wrong signal for a permanently archived product (should be 301 or honest 404/410).
let kept=0,soft404=0,hard404=0,still200=0,aLoop=0,temp302=0;
const sl=[];
const baseRe=new RegExp(`^https?:\\/\\/(www\\.)?${APEX.replace('.','\\.')}\\/?$`,'i');
console.log(`\n[archived] probing ${archived.length} recently-archived product URLs live...`);
for(const {h,v} of archived){
  const t=await trace(`https://${WWW}/products/${h}`);
  const first=t.chain[0]?.status;
  const last=t.chain[t.chain.length-1]?.status;
  const redirected=t.chain.some(c=>c.status>=300&&c.status<400);
  const usedTemp=t.chain.some(c=>c.status===302||c.status===307);
  if(t.loop){aLoop++;continue;}
  if([404,410].includes(last)||[404,410].includes(first)){ hard404++; }
  else if(redirected && baseRe.test(t.final)){ soft404++; if(usedTemp)temp302++; if(sl.length<12)sl.push({h,v,via:t.chain.map(c=>c.status).join('→')}); } // redirect to homepage = soft-404
  else if(redirected && last===200 && t.final.match(/\/(products|collections)\//)){ kept++; if(usedTemp)temp302++; } // redirect to a real product/collection = equity kept
  else if(!redirected && first===200){ still200++; } // PDP still live (mirror status lag — archived in mirror, live on store)
  else { soft404++; if(sl.length<12)sl.push({h,v,via:t.chain.map(c=>c.status).join('→')}); }
  await sleep(150);
}
console.log(`  redirect→real product/collection (equity KEPT): ${kept}`);
console.log(`  redirect→HOMEPAGE (SOFT-404, ~0 equity):        ${soft404}`);
console.log(`  honest 404/410:                                 ${hard404}`);
console.log(`  still 200 (mirror status lag, PDP live):        ${still200}`);
console.log(`  loops:                                          ${aLoop}`);
console.log(`  of redirects, using 302/307 (temp, wrong sig):  ${temp302}`);
if(sl.length){ console.log('  sample soft-404 handles:'); sl.forEach(x=>console.log(`    - ${x.h} (${x.v}) [${x.via}]`)); }

const out={ ts:new Date().toISOString(), canon:canonRes, archived:{sampled:archived.length,equity_kept:kept,soft404,hard404,still200,loops:aLoop,temp302,sampleSoft404:sl} };
import fs from 'fs';
fs.writeFileSync('/tmp/redirect-health.json',JSON.stringify(out,null,2));
console.log('\nwrote /tmp/redirect-health.json');

// verdict tells
const canonBad=canonRes.filter(c=>!c.finalOk||c.loop||c.hops>2);
console.log('\n=== TELLS ===');
console.log(`canonicalization issues: ${canonBad.length} (`+canonBad.map(c=>c.label).join(', ')+')');
const softPct=archived.length?(100*soft404/archived.length).toFixed(0):0;
console.log(`archived soft-404 to homepage (equity lost): ${soft404}/${archived.length} = ${softPct}%`);