← back to Dw Yolo Loop

scripts/collection-orphan-sweep/collection-orphan-sweep.mjs

60 lines

// collection-orphan-sweep — READ-ONLY, $0. Cycle 86. Measures COLLECTION-membership integrity:
// active products in ZERO collections are orphaned from every browse/merchandising path =
// potentially invisible to shoppers despite being live. BUT zero-collection only equals
// "invisible" if browse is collection-driven; if the storefront is filter/search-driven (Boost)
// it may be NORMAL. So this measures the membership DISTRIBUTION across all active products and
// negative-controls the orphan set against the bulk — if the bulk is collection-rich, the orphans
// are a real anomaly; if the bulk is collection-sparse, we REFRAME (no harm). EXCLUDES PJ.
import fs from 'fs';
const ENV=fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8');
const TOK=((ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1]||'').replace(/['"\r]/g,'').trim();
const GQL='https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
const PJ=/phillip[- ]?jeffries/i;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function gql(q,v){for(let a=0;a<6;a++){const r=await fetch(GQL,{method:'POST',headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});if(r.status===429||r.status>=500){await sleep(2000*(a+1));continue;}const j=await r.json();if(j.errors&&JSON.stringify(j.errors).includes('Throttled')){await sleep(2500*(a+1));continue;}return j;}return null;}
// collections(first:11) -> count up to 11 (11 means >=11, truncated). Also capture whether the
// product is in ANY collection at all (the orphan signal). status:active only.
const Q=`query($cursor:String){ products(first:60, after:$cursor, query:"status:active"){ pageInfo{hasNextPage endCursor}
  edges{node{ title vendor productType totalInventory
    collections(first:11){ edges{ node{ handle } } } } } } }`;
let cursor=null,pages=0,scanned=0,complete=true;
// distribution buckets by collection count
const dist={0:0,1:0,2:0,'3-5':0,'6-10':0,'11+':0};
let zero=0, withColl=0;
const zeroByVendor={}, zeroEx=[];
// also track which collection handles are most common (to spot a catch-all "all products" collection)
const collFreq={};
while(true){ const j=await gql(Q,{cursor}); if(!j?.data){complete=false;break;} const c=j.data.products;
  for(const e of c.edges){ const n=e.node; if(PJ.test(n.vendor||''))continue; scanned++;
    const handles=(n.collections?.edges||[]).map(x=>x.node.handle);
    const k=handles.length;
    handles.forEach(h=>collFreq[h]=(collFreq[h]||0)+1);
    if(k===0){dist[0]++; zero++; zeroByVendor[n.vendor]=(zeroByVendor[n.vendor]||0)+1; if(zeroEx.length<25) zeroEx.push({t:(n.title||'').slice(0,46),vendor:n.vendor,type:n.productType,inv:n.totalInventory});}
    else { withColl++;
      if(k===1)dist[1]++; else if(k===2)dist[2]++; else if(k<=5)dist['3-5']++; else if(k<=10)dist['6-10']++; else dist['11+']++; }
  }
  pages++; if(!c.pageInfo.hasNextPage)break; cursor=c.pageInfo.endCursor;
  const cost=j.extensions?.cost?.throttleStatus; if(cost&&cost.currentlyAvailable<500) await sleep(1500); else await sleep(250);
  if(pages%100===0) console.log(`  ${pages}p scanned=${scanned} zero-collection=${zero}`);
}
const pct=x=>scanned?(100*x/scanned).toFixed(1):'0';
console.log(`\n=== collection-orphan-sweep (READ-ONLY, $0) ===`);
console.log(`active scanned: ${scanned} (complete=${complete}, ${pages}p) [PJ excluded]`);
console.log(`\nCOLLECTION-COUNT DISTRIBUTION (the negative control — is the BULK collection-rich?):`);
console.log(`  0 collections (ORPHAN): ${dist[0]} (${pct(dist[0])}%)`);
console.log(`  1:                      ${dist[1]} (${pct(dist[1])}%)`);
console.log(`  2:                      ${dist[2]} (${pct(dist[2])}%)`);
console.log(`  3-5:                    ${dist['3-5']} (${pct(dist['3-5'])}%)`);
console.log(`  6-10:                   ${dist['6-10']} (${pct(dist['6-10'])}%)`);
console.log(`  11+ (truncated):        ${dist['11+']} (${pct(dist['11+'])}%)`);
console.log(`\nINTERPRETATION: in-a-collection ${withColl} (${pct(withColl)}%) vs ORPHAN ${zero} (${pct(zero)}%).`);
console.log(`  If in-a-collection is HIGH (say >80%), the ${zero} orphans are a real anomaly (browse is collection-driven, they are dark).`);
console.log(`  If orphan share is HIGH, browse is likely filter/search-driven and zero-collection is NORMAL (reframe, no harm).`);
console.log(`\ntop-12 collections by membership (spot a catch-all 'all products' collection):`);
Object.entries(collFreq).sort((a,b)=>b[1]-a[1]).slice(0,12).forEach(([h,n])=>console.log(`  ${h}: ${n} (${pct(n)}%)`));
console.log(`\nORPHAN (zero-collection) by vendor (top 15):`);
Object.entries(zeroByVendor).sort((a,b)=>b[1]-a[1]).slice(0,15).forEach(([v,n])=>console.log(`  ${v}: ${n}`));
console.log(`\nORPHAN examples (inv=totalInventory):`); zeroEx.forEach(x=>console.log(`  "${x.t}" [${x.vendor}] type=${x.type} inv=${x.inv}`));
fs.writeFileSync('/tmp/collection-orphan-sweep.json',JSON.stringify({ts:new Date().toISOString(),scanned,complete,dist,zero,withColl,zeroByVendor,collFreqTop:Object.entries(collFreq).sort((a,b)=>b[1]-a[1]).slice(0,30),zeroEx},null,2));
console.log(`\nwrote /tmp/collection-orphan-sweep.json`);