← back to Astek Landing

kravet_fe_full.mjs

82 lines

// Kravet front-end full run — render each SKU, attach extra brandfolder gallery images to Shopify (base64).
// Resumable + crash-safe: only marks a SKU done after a completed render; relaunches browser on crash.
import { chromium } from 'playwright';
import fs from 'fs';
const DIR='/Users/macstudio3/kravet-feed-pull/fe-run';
const LEDGER=DIR+'/done.txt', LOG=DIR+'/progress.log';
const env=Object.fromEntries(fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8').split('\n').filter(l=>l.includes('=')).map(l=>{const i=l.indexOf('=');return[l.slice(0,i).trim(),l.slice(i+1).trim()];}));
const DOMAIN=(env.SHOPIFY_STORE_DOMAIN||env.SHOPIFY_STORE).replace(/^https?:\/\//,'').replace(/\/$/,'');
const API=`https://${DOMAIN}/admin/api/2024-10`;
const H={'X-Shopify-Access-Token':env.SHOPIFY_ADMIN_TOKEN,'Content-Type':'application/json'};
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const log=m=>{const s=`[${new Date().toISOString()}] ${m}`; console.log(s); try{fs.appendFileSync(LOG,s+'\n');}catch{}};

const done=new Set(fs.existsSync(LEDGER)?fs.readFileSync(LEDGER,'utf8').trim().split('\n').filter(Boolean):[]);
const rows=fs.readFileSync(DIR+'/skus.txt','utf8').trim().split('\n').map(l=>l.split('|')).filter(r=>!done.has(r[0]));
const urls=new Set(fs.readFileSync(DIR+'/urls.txt','utf8').trim().split('\n').map(u=>u.replace('https://www.kravet.com/','')));
function resolve(mfr){const base=mfr.toLowerCase().replace(/\.0$/,'');
  for(const c of [base.replace(/[./]/g,'-'), base.split(/[./]/).slice(0,2).join('-')]) if(urls.has(c)) return c;
  const pref=base.replace(/[./]/g,'-').slice(0,10); for(const u of urls) if(u.startsWith(pref)) return u; return null;}
const keyOf=mfr=>mfr.toLowerCase().replace(/\.0$/,'').replace(/[./]/g,'_');
function mine(f,key){f=f.toLowerCase(); if(!f.startsWith(key))return false; const nx=f[key.length]; if(nx!=='.'&&nx!=='_')return false; if(f.includes('_thumb'))return false; return true;}
const isExtra=(f,key)=>f.toLowerCase().startsWith(key+'_');
async function curImgs(pid){try{const r=await fetch(`${API}/products/${pid}/images.json?fields=id,src`,{headers:H});return r.ok?((await r.json()).images||[]):[];}catch{return [];}}
async function addB64(pid,src,fnameBase){
  let buf,ct; for(let t=0;t<3;t++){try{const ir=await fetch(src,{headers:{'User-Agent':'Mozilla/5.0'},signal:AbortSignal.timeout(25000)});if(!ir.ok)throw new Error('dl'+ir.status);ct=ir.headers.get('content-type')||'';buf=Buffer.from(await ir.arrayBuffer());break;}catch(e){if(t===2)return{ok:false};await sleep(600);}}
  if(!buf||buf.length<500)return{ok:false};
  const ext=/png/i.test(ct)?'png':/webp/i.test(ct)?'webp':'jpg';
  const filename=fnameBase.replace(/\.(auto|jpe?g|png|webp)$/i,'')+'.'+ext;
  for(let t=0;t<4;t++){try{const r=await fetch(`${API}/products/${pid}/images.json`,{method:'POST',headers:H,body:JSON.stringify({image:{attachment:buf.toString('base64'),filename}}),signal:AbortSignal.timeout(45000)});
    if(r.ok)return{ok:true}; if(r.status===429||r.status>=500){await sleep(1500*(t+1));continue;} return{ok:false};}catch(e){if(t===3)return{ok:false}; await sleep(1500*(t+1));}}
  return{ok:false};
}

const CONC=6; let n=0,withExtra=0,added=0,prods=0,fail=0,rerr=0; let idx=0; const t0=Date.now();
let browser=await chromium.launch({headless:true});
async function freshCtx(){
  if(!browser.isConnected()){ log('browser died — relaunching'); browser=await chromium.launch({headless:true}); }
  const ctx=await browser.newContext();
  await ctx.route('**/*',route=>{
    const req=route.request(); const u=req.url();
    if(/^https:\/\/cdn\.brandfolder\.io/.test(u)){const fn=u.split('?')[0].split('/').pop(); if(/\.(auto|jpe?g|png|webp)$/i.test(fn)&&ctx.__cur) ctx.__cur[fn]=u.split('?')[0]; return route.abort();}
    const rt=req.resourceType(); if(rt==='image'||rt==='media'||rt==='font'||rt==='stylesheet') return route.abort();
    return route.continue().catch(()=>{});
  });
  return ctx;
}
async function worker(){
  let ctx=await freshCtx();
  while(idx<rows.length){
    const i=idx++; const [dwsku,pid,mfr]=rows[i];
    const slug=resolve(mfr);
    if(!slug){ fs.appendFileSync(LEDGER,dwsku+'\n'); n++; tick(); continue; }
    let rendered=false, extras=[];
    try{
      const urlByFile={}; ctx.__cur=urlByFile;
      const p=await ctx.newPage();
      try{
        await p.goto('https://www.kravet.com/'+slug,{waitUntil:'domcontentloaded',timeout:20000}).catch(()=>{});
        await p.waitForTimeout(2800);
        rendered=true;
        const key=keyOf(mfr);
        extras=Object.keys(urlByFile).filter(f=>mine(f,key)&&isExtra(f,key));
      } finally { await p.close().catch(()=>{}); }
    }catch(e){ rerr++; if(rerr%50===1) log(`render err (${rerr}) ${mfr}: ${String(e.message).slice(0,60)}`);
      try{await ctx.close().catch(()=>{});}catch{} ctx=await freshCtx(); continue; /* do NOT mark done -> retried next run */ }
    if(rendered && extras.length){ withExtra++;
      const cur=await curImgs(pid); const have=new Set(cur.map(im=>(im.src||'').split('/').pop().split('?')[0].toLowerCase().replace(/(_[0-9a-f-]{8,})?\.(jpe?g|png|webp|auto)$/,'')));
      const todo=extras.filter(f=>!have.has(f.toLowerCase().replace(/\.(auto|jpe?g|png|webp)$/,'')));
      const results=await Promise.all(todo.map(f=>addB64(pid,ctx.__cur[f],f)));  // parallel per-product transfers
      const a=results.filter(r=>r.ok).length; added+=a; fail+=results.length-a;
      if(a>0)prods++;
    }
    fs.appendFileSync(LEDGER,dwsku+'\n'); n++; tick();
  }
  await ctx.close().catch(()=>{});
}
function tick(){ if(n%100===0){const rate=n/((Date.now()-t0)/1000); const rem=Math.round((rows.length-n)/Math.max(rate,0.01)/60); log(`${n}/${rows.length} | extras:${withExtra} prods:${prods} imgs:${added} fail:${fail} rerr:${rerr} | ~${rem}min left`);} }
log(`START full FE run: ${rows.length} to process (${done.size} already done)  CONC=${CONC}`);
await Promise.all(Array.from({length:CONC},()=>worker()));
await browser.close().catch(()=>{});
log(`FULL FE RUN DONE. processed:${n} with-extras:${withExtra} products-attached:${prods} images-added:${added} fail:${fail} render-errs:${rerr}`);