← back to Dw Yolo Loop

scripts/orphan-collections/build-merge-map.mjs

43 lines

// build-merge-map (c42) — READ-ONLY. Turns c41's 43 empty live collections into a
// ready merge+301 map: for each empty collection, find its populated TWIN (same
// vendor/normalized stem) so Steve can 301 the dead handle to the live one and
// recover its SEO/link equity. Handle list from the feed (handles are reliable;
// only products_count is stale). $0.
import fs from 'node:fs';
const BASE='https://www.designerwallcoverings.com';
const c41=JSON.parse(fs.readFileSync(`${process.env.HOME}/.claude/yolo-queue/orphan-collections-LIVE-2026-06-16.json`,'utf8'));
const OUT=`${process.env.HOME}/.claude/yolo-queue/collection-merge-map-2026-06-16.json`;
const MD =`${process.env.HOME}/.claude/yolo-queue/collection-merge-map-2026-06-16.md`;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
// pull all handles
const handles=[];
for(let p=1;p<=20;p++){ const r=await fetch(`${BASE}/collections.json?limit=250&page=${p}`,{signal:AbortSignal.timeout(15000)}); const j=await r.json(); if(!(j.collections||[]).length)break; handles.push(...j.collections.map(c=>c.handle)); await sleep(150); }
const emptySet=new Set(c41.empty_list.map(c=>c.handle));
const nearSet=new Set(c41.near_list.map(c=>c.handle));
const populated=handles.filter(h=>!emptySet.has(h)&&!nearSet.has(h));
// normalize a handle to a comparable stem
const STOP=['wallcoverings','wallcovering','wallpaper','fabrics','fabric','designs','design','collection','co','and-company','company','all-products','all','products'];
function stem(h){ let s=h.toLowerCase(); for(const w of STOP) s=s.replace(new RegExp(`(^|-)${w}(-|$)`,'g'),'-'); s=s.replace(/-\d+$/,'').replace(/[^a-z0-9]/g,''); return s; }
const popByStem=new Map(); for(const h of populated){ const k=stem(h); if(!popByStem.has(k)) popByStem.set(k,[]); popByStem.get(k).push(h); }
const map=[];
for(const c of c41.empty_list){
  const k=stem(c.handle);
  let twins = popByStem.get(k) || [];
  if(!twins.length){ // fallback: populated handle whose stem contains/!is contained by this stem
    twins = populated.filter(h=>{ const ph=stem(h); return ph && k && (ph.includes(k)||k.includes(ph)) && Math.min(ph.length,k.length)>=4; }).slice(0,3);
  }
  map.push({ empty:c.handle, feed_count_stale:c.feed_count, stem:k, twin: twins[0]||null, twin_candidates:twins.slice(0,3) });
}
const withTwin=map.filter(m=>m.twin), noTwin=map.filter(m=>!m.twin);
fs.writeFileSync(OUT, JSON.stringify({generated_at:new Date().toISOString(), total_empty:map.length, with_twin:withTwin.length, no_twin:noTwin.length, map},null,2));
let md=`# Collection merge+301 map (c42) — ${new Date().toISOString().slice(0,16)}\n\n`;
md+=`**READ-ONLY, \$0.** For c41's ${map.length} empty live collections: proposed merge target (populated twin) so each dead handle can be 301'd. ${withTwin.length} have a twin, ${noTwin.length} don't (hide/repopulate instead). VERIFY each pair before merging — all edits GATED.\n\n`;
md+=`## ${withTwin.length} EMPTY → TWIN (301 candidates)\n| empty (dead) | → merge into (populated twin) | other candidates |\n|---|---|---|\n`;
for(const m of withTwin) md+=`| ${m.empty} | **${m.twin}** | ${m.twin_candidates.slice(1).join(', ')||'—'} |\n`;
md+=`\n## ${noTwin.length} EMPTY, no twin found (hide or repopulate)\n`;
for(const m of noTwin) md+=`- ${m.empty} (feed said ${m.feed_count_stale})\n`;
fs.writeFileSync(MD,md);
console.log(`[merge-map] empties=${map.length} with_twin=${withTwin.length} no_twin=${noTwin.length}`);
console.log('sample pairs:'); for(const m of withTwin.slice(0,10)) console.log(`  ${m.empty} -> ${m.twin}`);
console.log(`Report: ${MD}`);