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