← back to Designer Wallcoverings
post_ere_eur_v2.js
123 lines
#!/usr/bin/env node
/* v2 — TRUE replace of ERE-/EUR- primary photo with the mfr image + enrichment + archive.
* Steve-approved 2026-06-26. Idempotent (safe to re-run over the 58 already touched).
* Per re-imaged product:
* 1. ensure mfr image media exists (alt == title); reuse if already added (no dup).
* 2. wait until that media is READY.
* 3. reorder it to position 0 (featured / primary).
* 4. delete the OLD primary catalog photo = first image that is NOT a room-* and NOT the mfr image.
* (room-* settings are preserved.)
* 5. productUpdate descriptionHtml + merged tags.
* Archive: productUpdate status=ARCHIVED. keep_current_image: skipped.
* Modes: (default) DRY-RUN | --pilot N | --commit
*/
const { Client } = require('pg');
const fs = require('fs');
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
const TOKEN = (()=>{ for (const f of [
'/Users/macstudio3/Projects/Designer-Wallcoverings/.env',
'/Users/macstudio3/Projects/Designer-Wallcoverings/shopify/scripts/cadence/.env']) {
try { const m=fs.readFileSync(f,'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m); if(m) return m[1].trim(); } catch{} }
throw new Error('no token'); })();
const argv = process.argv.slice(2);
const COMMIT = argv.includes('--commit');
const pilotIdx = argv.indexOf('--pilot');
const PILOT_N = pilotIdx>=0 ? parseInt(argv[pilotIdx+1]||'3',10) : 0;
const sleep = ms => new Promise(r=>setTimeout(r,ms));
async function gql(query,variables,tries=5){
for(let a=0;a<tries;a++){
try{
const res=await fetch(ENDPOINT,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query,variables})});
if(res.status===429||res.status>=500){await sleep(1500*(a+1));continue;}
const j=await res.json();
if(j.errors){ if(JSON.stringify(j.errors).includes('THROTTLED')){await sleep(2000*(a+1));continue;} throw new Error(JSON.stringify(j.errors).slice(0,200)); }
return j.data;
}catch(e){ if(a===tries-1) throw e; await sleep(1500*(a+1)); }
}
}
const Q_MEDIA = `query($id:ID!){ product(id:$id){ id media(first:50){edges{node{
... on MediaImage { id status image{ url altText } } }}} } }`;
const M_ADD = `mutation($productId:ID!,$media:[CreateMediaInput!]!){
productCreateMedia(productId:$productId,media:$media){ media{ id status } mediaUserErrors{ field message } } }`;
const M_REORDER = `mutation($id:ID!,$moves:[MoveInput!]!){
productReorderMedia(id:$id,moves:$moves){ job{ id } userErrors{ field message } } }`;
const M_DELMEDIA = `mutation($productId:ID!,$mediaIds:[ID!]!){
productDeleteMedia(productId:$productId,mediaIds:$mediaIds){ deletedMediaIds mediaUserErrors{ field message } } }`;
const M_UPDATE = `mutation($input:ProductInput!){
productUpdate(input:$input){ product{ id status } userErrors{ field message } } }`;
function tagList(...jsons){ const out=[]; for(const j of jsons){ if(!j)continue; let v=j; if(typeof j==='string'){try{v=JSON.parse(j)}catch{continue}} if(Array.isArray(v)) for(const x of v){ if(typeof x==='string')out.push(x); else if(x&&x.name)out.push(x.name);} } return out; }
const isRoom = u => /\/room-/i.test(u||'') || /room-[a-z]/i.test((u||'').split('/').pop()||'');
async function ensureFeatured(gid, imgUrl, title){
// current media
let d = await gql(Q_MEDIA,{id:gid});
let media = d.product.media.edges.map(e=>e.node).filter(n=>n&&n.id);
// find already-added mfr image (alt == title) — idempotent reuse
let mfr = media.find(m=>(m.image?.altText||'')===title);
if(!mfr){
const add = await gql(M_ADD,{productId:gid,media:[{originalSource:imgUrl,mediaContentType:'IMAGE',alt:title}]});
const me=add.productCreateMedia?.mediaUserErrors||[]; if(me.length) throw new Error('media:'+JSON.stringify(me));
mfr = add.productCreateMedia.media[0];
}
// wait until READY (max ~12s)
for(let i=0;i<12;i++){
d = await gql(Q_MEDIA,{id:gid});
media = d.product.media.edges.map(e=>e.node).filter(n=>n&&n.id);
mfr = media.find(m=>(m.image?.altText||'')===title) || mfr;
if(mfr && mfr.status==='READY') break;
await sleep(1000);
}
// reorder mfr to position 0
if(mfr){ await gql(M_REORDER,{id:gid,moves:[{id:mfr.id,newPosition:'0'}]}); /* job async, fine */ }
// delete OLD primary = non-room images that are NOT the mfr image
const stale = media.filter(m=>m.id!==(mfr&&mfr.id) && !isRoom(m.image?.url));
if(stale.length){ await gql(M_DELMEDIA,{productId:gid,mediaIds:stale.map(s=>s.id)}); }
return { added: !!mfr, deleted: stale.length };
}
async function main(){
const pg=new Client({host:'/tmp',database:'dw_unified'}); await pg.connect();
const ere=(await pg.query(`SELECT ere_sku sku, shopify_id gid, title, source, planned_status, new_image,
ai_description descr, ai_tags, ai_styles, ai_patterns, ai_colors FROM ere_dg_map`)).rows.map(r=>({...r,line:'ERE'}));
const eur=(await pg.query(`SELECT ere_sku sku, shopify_id gid, title, source, planned_status, new_image,
src_ai_desc descr, NULL ai_tags, src_ai_styles ai_styles, src_ai_patterns ai_patterns, src_ai_colors ai_colors FROM eur_map`)).rows.map(r=>({...r,line:'EUR'}));
const tagRows=(await pg.query(`SELECT dw_sku,tags FROM shopify_products WHERE dw_sku ILIKE 'ERE-%' OR dw_sku ILIKE 'EUR-%'`)).rows;
const curTags={}; for(const t of tagRows){ if(!curTags[t.dw_sku]) curTags[t.dw_sku]=t.tags||''; }
await pg.end();
const all=[...ere,...eur].filter(r=>r.gid);
const reimage=all.filter(r=>['in_db','in_db_cleaned','crawled'].includes(r.source)&&r.new_image);
const archive=all.filter(r=>r.planned_status==='ARCHIVED');
let queue=[...reimage.map(r=>({...r,act:'reimage'})),...archive.map(r=>({...r,act:'archive'}))];
if(PILOT_N) queue=[...reimage.slice(0,Math.max(1,PILOT_N-1)).map(r=>({...r,act:'reimage'})),...archive.slice(0,1).map(r=>({...r,act:'archive'}))];
const live=COMMIT||PILOT_N>0;
console.log(`reimage=${reimage.length} archive=${archive.length} | MODE=${COMMIT?'COMMIT':PILOT_N?`PILOT(${queue.length})`:'DRY'} live=${live}`);
let done=0,errs=0;
for(const r of queue){
try{
if(r.act==='archive'){
if(!live){console.log(` [dry] ARCHIVE ${r.sku}`);continue;}
const d=await gql(M_UPDATE,{input:{id:r.gid,status:'ARCHIVED'}});
const ue=d.productUpdate?.userErrors||[]; if(ue.length)throw new Error(JSON.stringify(ue));
console.log(` ✓ ARCHIVED ${r.sku}`); done++;
}else{
const mergedTags=Array.from(new Set([...(curTags[r.sku]||'').split(',').map(s=>s.trim()).filter(Boolean),
...tagList(r.ai_tags,r.ai_colors,r.ai_styles,r.ai_patterns)]));
if(!live){console.log(` [dry] REIMAGE ${r.sku} feat+del tags+${mergedTags.length} desc=${r.descr?'Y':'N'}`);continue;}
const fe=await ensureFeatured(r.gid,r.new_image,r.title);
const inp={id:r.gid,tags:mergedTags}; if(r.descr)inp.descriptionHtml=r.descr;
const upd=await gql(M_UPDATE,{input:inp});
const ue=upd.productUpdate?.userErrors||[]; if(ue.length)throw new Error('update:'+JSON.stringify(ue));
console.log(` ✓ REIMAGE ${r.sku} (feat✓ del=${fe.deleted} tags=${mergedTags.length} desc=${r.descr?'Y':'N'})`); done++;
}
await sleep(120);
}catch(e){ errs++; console.log(` ✗ ${r.sku} ${r.act} ERR ${String(e).slice(0,120)}`); }
}
console.log(`\nDONE: ${done} ok, ${errs} err (of ${queue.length})`);
}
main().catch(e=>{console.error(e);process.exit(1)});