← back to Carnegie Reprice

fix-stragglers.mjs

83 lines

// fix-stragglers.mjs — upgrade the EARLY inferior Carnegie split products (pilot-exec.cjs Siltech
// pilots + phase2-poc-acapella POC products) to FULL parity with the rebuild-line.mjs output:
// title, description body, detail tags, image, and the ~50 metafields — all from carnegie_catalog.
// Logic is a faithful copy of rebuild-line.mjs buildPayload (importing it is unsafe — it runs its
// main loop on import). Reversible: records old title/tags/body; metafields are additive upserts.
//   node fix-stragglers.mjs        # dry-run: list stragglers + what's missing
//   node fix-stragglers.mjs apply  # backfill title+body+tags+image(if none)+metafields
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
const DIR=new URL('.',import.meta.url).pathname;
const ENV=`${process.env.HOME}/Projects/secrets-manager/.env`;
const env=k=>{const m=fs.readFileSync(ENV,'utf8').split('\n').find(l=>l.startsWith(k+'='));return m?m.slice(k.length+1).trim().replace(/^["']|["']$/g,''):'';};
const TOKEN=env('SHOPIFY_ADMIN_TOKEN');let SHOP=env('SHOPIFY_STORE_DOMAIN')||env('SHOPIFY_STORE');if(SHOP&&!SHOP.includes('.'))SHOP+='.myshopify.com';
const API=`https://${SHOP}/admin/api/2024-10`;const H={'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'};
const MODE=process.argv[2]||'dry';const sleep=ms=>new Promise(r=>setTimeout(r,ms));const TAG='split-batch:TK-10686';
const PSQL=['/opt/homebrew/opt/postgresql@14/bin/psql','/usr/local/opt/postgresql@14/bin/psql','psql'].find(p=>{try{execFileSync(p,['--version'],{stdio:'ignore'});return 1}catch{return 0}})||'psql';
const q1=sql=>execFileSync(PSQL,['postgresql:///dw_unified?host=/tmp','-At','-c',sql],{encoding:'utf8',maxBuffer:1<<30}).trim();
async function shop(p,o={}){for(let a=0;a<6;a++){const r=await fetch(API+p,{headers:H,...o});if(r.status===429){const ra=+(r.headers.get('retry-after')||2);await sleep(Math.max(ra*1000,1000*(a+1)));continue;}const t=await r.text();if(!r.ok)throw new Error(`HTTP ${r.status} ${p} :: ${t.slice(0,200)}`);return t?JSON.parse(t):{};}throw new Error('429 exhausted '+p);}
async function allCarnegie(){const out=[];let url=`/products.json?vendor=Carnegie&limit=250`;while(url){const r=await fetch(API+url,{headers:H});if(r.status===429){await sleep(2000);continue;}const j=await r.json();out.push(...(j.products||[]));const link=r.headers.get('link')||'';const m=link.match(/<[^>]*[?&]page_info=([^>&]+)[^>]*>;\s*rel="next"/);url=m?`/products.json?limit=250&page_info=${m[1]}`:null;await sleep(500);}return out;}

// ==== faithful copies from rebuild-line.mjs ====
const bump=u=>u?u.replace(/height=\d*&width=\d*/,'height=1400&width=1400').replace(/height=&width=/,'height=1400&width=1400'):null;
const IMG_PRIORITY=[/_puf\./i,/_pud\./i,/_pdp\./i,/_pd[a-z]\./i,/_detail\./i,/_repeat\./i,/_iii\./i,/_web\./i,/_out\./i,/lifestyle/i,/_uhr\./i,/_neu\./i,/_swatch/i];
function selectImages(all){const clean=(all||[]).filter(Boolean);if(!clean.length)return[];const scored=clean.map(u=>{let s=IMG_PRIORITY.findIndex(re=>re.test(u));if(s<0)s=IMG_PRIORITY.length;return{u,s};});scored.sort((a,b)=>a.s-b.s);const seen=new Set();const out=[];for(const{u}of scored){const b=bump(u);if(!seen.has(b)){seen.add(b);out.push(b);if(out.length>=4)break;}}return out;}
const SPEC_ALLOW=['Type','Width','Content','Contents','Backing','Repeat','Warranty','Weight per Linear Yard','Cleaning Code','Finish/es (as stocked)','Flammability','Manufactured In','Durability','Acoustics','Lightfastness','Hydrolysis','Performance','Uses','ACT Symbols','Available Backing(s)','Available Finish(es)','Standards and Certifications','Free of','Additional Details','IMO Certification Type'];
function buildFull(row){
  const specs=row.specs||{};const ptype=row.product_type||'Fabric';
  const colorName=(row.color_tags&&row.color_tags[0])||row.color_name||row.color_bucket||row.mfr_sku||('Color '+row.color_number);
  const title=`Carnegie ${row.pattern_name} — ${colorName}`;
  const imgs=selectImages(row.all_images);
  let body=(row.description_text||'').trim()||(row.body_html||'').trim();
  if(!body){const bits=[`<p>${row.pattern_name} in ${colorName} from Carnegie${row.mfr_sku?` (${row.mfr_sku})`:''}.`];const facts=[];if(row.content)facts.push(`${row.content} content`);if(row.width)facts.push(`${row.width} wide`);if(row.durability_wyzenbeek)facts.push(`${row.durability_wyzenbeek} durability`);if(specs.Type)facts.push(String(specs.Type).toLowerCase());bits.push(facts.length?` A ${ptype.toLowerCase()} textile featuring ${facts.join(', ')}.`:` A ${ptype.toLowerCase()} textile.`);bits.push(`</p>`);body=bits.join('');}
  const tags=[...(row.color_tags||[]),row.color_bucket,...(row.style_tags||[]),'Carnegie','carnegie-textile','Fabric',ptype,'Carnegie Textiles','display_variant'].filter(Boolean);
  const tagStr=[...new Set(tags.map(t=>String(t).trim()).filter(Boolean))].join(', ');
  const spec={Width:row.width,Content:row.content,Contents:row.content,Durability:row.durability_wyzenbeek,'Cleaning Code':row.cleaning_code,Finish:row.finish,Backing:row.backing,Flammability:row.flammability,Origin:row.origin,'Country of Origin':row.origin,Repeat:(row.repeat_v||row.repeat_h||'')||undefined,Type:specs.Type,Warranty:specs.Warranty,Weight:specs['Weight per Linear Yard'],Use:ptype};
  for(const k of SPEC_ALLOW){const v=specs[k];if(v&&!spec[k]&&String(v).length<600)spec[k]=v;}
  const ident={dw_sku:row.dw_sku,manufacturer_sku:row.mfr_sku,brand:'Carnegie',vendor:'Carnegie',pattern_name:row.pattern_name,color:colorName,color_number:String(row.color_number||''),product_class:'Fabric',collection_name:'Carnegie Textiles'};
  return {title,body,tagStr,imgs,spec,ident,primary_hex:row.primary_hex};
}
async function setMetafields(pid,{spec,ident,primary_hex}){
  const mfs=[];
  for(const ns of ['global','custom','specifications','specs'])for(const[k,v]of Object.entries(spec))if(v)mfs.push({namespace:ns,key:k,type:'single_line_text_field',value:String(v).slice(0,900)});
  for(const ns of ['global','custom'])for(const[k,v]of Object.entries(ident))if(v)mfs.push({namespace:ns,key:k,type:'single_line_text_field',value:String(v)});
  if(primary_hex)mfs.push({namespace:'custom',key:'color_hex',type:'single_line_text_field',value:primary_hex});
  let ok=0,err=0;for(const m of mfs){try{await shop(`/products/${pid}/metafields.json`,{method:'POST',body:JSON.stringify({metafield:m})});ok++;}catch{err++;}await sleep(50);}
  return {ok,err,tried:mfs.length};
}

// ==== scan for stragglers ====
const products=(await allCarnegie()).filter(p=>(p.tags||'').includes(TAG));
console.log(`[scan] ${products.length} split-batch products; checking metafield coverage…`);
const stragglers=[];
for(const p of products){
  const j=await shop(`/products/${p.id}/metafields.json`);const mc=(j.metafields||[]).length;
  if(mc<10){const fabSku=(p.variants||[]).map(v=>v.sku).find(s=>s&&!/sample/i.test(s));
    stragglers.push({id:p.id,handle:p.handle,title:p.title,tags:p.tags||'',bodyLen:(p.body_html||'').length,imgCount:(p.images||[]).length,mc,fabSku});}
  await sleep(120);
}
console.log(`[scan] stragglers (<10 metafields): ${stragglers.length}`);
for(const s of stragglers.slice(0,12))console.log(`   ${s.handle}  mf=${s.mc} imgs=${s.imgCount} bodyLen=${s.bodyLen} sku=${s.fabSku}  "${s.title}"`);
if(MODE!=='apply'){console.log(`\n[dry-run] nothing written. Re-run 'apply' to backfill title+body+tags+image+metafields on ${stragglers.length} products.`);process.exit(0);}

// ==== apply ====
const undo=[];let fixed=0,skipped=0;
for(const s of stragglers){
  if(!s.fabSku){skipped++;continue;}
  const rj=q1(`select row_to_json(t) from (select * from carnegie_catalog where dw_sku='${s.fabSku.replace(/'/g,"''")}') t`);
  if(!rj){console.log(`   SKIP ${s.handle}: no carnegie_catalog row for ${s.fabSku}`);skipped++;continue;}
  const f=buildFull(JSON.parse(rj));
  const r=await setMetafields(s.id,f);
  const patch={id:s.id};
  const titleFixed=/( - Color |—?\s*Color \d)/i.test(s.title)&&f.title&&f.title!==s.title; if(titleFixed)patch.title=f.title;
  const tagsFixed=f.tagStr&&f.tagStr!==s.tags; if(tagsFixed)patch.tags=f.tagStr;
  const bodyFixed=s.bodyLen<20&&f.body; if(bodyFixed)patch.body_html=f.body;
  const imgFixed=s.imgCount===0&&f.imgs.length; if(imgFixed)patch.images=f.imgs.map((src,i)=>({src,position:i+1}));
  if(Object.keys(patch).length>1)await shop(`/products/${s.id}.json`,{method:'PUT',body:JSON.stringify({product:patch})});
  undo.push({id:s.id,handle:s.handle,old_title:s.title,old_tags:s.tags,old_bodyLen:s.bodyLen,old_imgCount:s.imgCount,mf_added:r.ok,changed:{title:titleFixed,tags:tagsFixed,body:bodyFixed,image:imgFixed}});
  console.log(`   FIXED ${s.handle}: +${r.ok}/${r.tried}mf${titleFixed?' +title':''}${tagsFixed?` +tags(${f.tagStr.split(', ').length})`:''}${bodyFixed?' +body':''}${imgFixed?` +img(${f.imgs.length})`:''}`);
  fixed++;
}
fs.writeFileSync(DIR+'straggler-fix-undo.json',JSON.stringify({ts:new Date().toISOString(),undo},null,2));
console.log(`\n[apply] fixed ${fixed}, skipped ${skipped}. Undo map -> straggler-fix-undo.json`);