← back to Designer Wallcoverings
post_ere_eur.js
118 lines
#!/usr/bin/env node
/* Post the staged ERE-/EUR- re-image + enrichment + archive to the LIVE store.
* Steve-approved 2026-06-26. Reads ere_dg_map + eur_map from dw_unified.
* Actions per product:
* - re-imaged (in_db/in_db_cleaned/crawled): add mfr photo as featured image,
* set descriptionHtml = AI description, merge AI color/style/pattern tags.
* - discontinued: productUpdate status=ARCHIVED.
* - keep_current_image: skip (working image, no change).
* Modes: (default) DRY-RUN | --pilot N (write only first N) | --commit (all)
* Image/status/metafield updates do NOT consume the daily variant cap (no creates).
*/
const { Client } = require('pg');
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
const fs = require('fs');
function envToken() {
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 SHOPIFY_ADMIN_TOKEN');
}
const TOKEN = envToken();
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=4) {
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){ // throttle?
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 M_ADD = `mutation($productId:ID!,$media:[CreateMediaInput!]!){
productCreateMedia(productId:$productId,media:$media){ media{ id status } 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;
}
async function main(){
const pg = new Client({ host:'/tmp', database:'dw_unified' });
await pg.connect();
// Build unified work-list from both staging tables
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'}));
// current tags from mirror to merge (productUpdate replaces full tag set)
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||''; }
let work = [...ere,...eur].filter(r=>r.gid);
const reimage = work.filter(r=>['in_db','in_db_cleaned','crawled'].includes(r.source) && r.new_image);
const archive = work.filter(r=>r.planned_status==='ARCHIVED');
const skip = work.filter(r=>r.source==='keep_current_image');
console.log(`WORK: reimage=${reimage.length} archive=${archive.length} keep=${skip.length}`);
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(`MODE: ${COMMIT?'COMMIT (all)':PILOT_N?`PILOT (${queue.length})`:'DRY-RUN'} | live writes: ${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 {
// 1) add mfr image as featured
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} img=${(r.new_image||'').slice(-22)} tags+${mergedTags.length} desc=${r.descr?'Y':'N'}`); continue; }
const add = await gql(M_ADD,{productId:r.gid,media:[{originalSource:r.new_image,mediaContentType:'IMAGE',alt:r.title}]});
const me=add.productCreateMedia?.mediaUserErrors||[]; if(me.length) throw new Error('media:'+JSON.stringify(me));
// 2) update description + tags
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} (+img, tags=${mergedTags.length}, desc=${r.descr?'Y':'N'})`); done++;
}
await sleep(180); // ~5/s, well under Plus limits
}catch(e){ errs++; console.log(` ✗ ${r.sku} ${r.act} ERR ${String(e).slice(0,120)}`); }
}
console.log(`\nDONE: ${done} written, ${errs} errors (of ${queue.length})`);
await pg.end();
}
main().catch(e=>{console.error(e);process.exit(1)});