← 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}`);