← back to Dwjs Consolidation 2026 04 23
phase3_execute.js
190 lines
#!/usr/bin/env node
/**
* PHASE 3 EXECUTE — Jeffrey Stevens rebrand writer.
*
* For each row in phase3_rename_v2.json:
* - Fix any residual duplicate titles with A/B/C suffix
* - productUpdate: new title + merged tags (existing + tags_to_add)
* - Leave SKU, vendor, status, images untouched
* - Log each result to phase3_execution.ndjson
* - Resumable via phase3_state.json
*
* Throttle: concurrency 3, with back-off if Shopify returns throttle errors.
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const STORE='designer-laboratory-sandbox.myshopify.com';
const TOKEN=(process.env.SHOPIFY_ADMIN_TOKEN || '');
const API='/admin/api/2024-10/graphql.json';
const OUT=__dirname;
const LOG = path.join(OUT, 'phase3_run.log');
const EXEC = path.join(OUT, 'phase3_execution.ndjson');
const FAILS = path.join(OUT, 'phase3_failures.ndjson');
const STATE = path.join(OUT, 'phase3_state.json');
function ts(){ return new Date().toISOString().replace('T',' ').slice(0,19); }
function log(m){ const l=`[${ts()}] ${m}\n`; process.stdout.write(l); fs.appendFileSync(LOG,l); }
function gql(body, retry=0) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = https.request({ hostname:STORE, path:API, method:'POST',
headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)} },
res => { let c=''; res.on('data',d=>c+=d); res.on('end', async ()=>{
try {
const j = JSON.parse(c);
const tri = j?.extensions?.cost?.throttleStatus;
const isThrottle = j.errors && /throttle/i.test(JSON.stringify(j.errors));
if (isThrottle && retry < 6) {
await new Promise(r=>setTimeout(r, 2000*(retry+1)));
return resolve(gql(body, retry+1));
}
if (tri && tri.currentlyAvailable < 300) await new Promise(r=>setTimeout(r, 800));
resolve(j);
} catch (e) {
if (retry < 4) setTimeout(()=>resolve(gql(body, retry+1)), 2000*(retry+1));
else resolve({ error: c.slice(0,500) });
}
}); }
);
req.on('error', err => { if (retry<4) setTimeout(()=>resolve(gql(body,retry+1)), 2000*(retry+1)); else reject(err); });
req.setTimeout(60000, ()=>{ req.destroy(); if (retry<4) resolve(gql(body,retry+1)); else reject(new Error('t')); });
req.write(data); req.end();
});
}
// Load prepared rename rows + raw product data (for existing tags)
const rename = JSON.parse(fs.readFileSync(path.join(OUT,'phase3_rename_v2.json'),'utf8'));
const raw = {};
for (const line of fs.readFileSync(path.join(OUT,'phase3_v2_raw.jsonl'),'utf8').split('\n').filter(Boolean)) {
const o = JSON.parse(line);
if (o.id?.includes('/Product/')) raw[o.id.replace('gid://shopify/Product/','')] = o;
}
log(`loaded ${rename.length} rename rows, ${Object.keys(raw).length} raw product records`);
// Fix any residual duplicate titles: scan, group by city+newTitle, apply A/B/C suffix
const titleCount = {};
for (const r of rename) titleCount[r.new_title] = (titleCount[r.new_title]||0)+1;
const dups = Object.entries(titleCount).filter(([,c])=>c>1);
if (dups.length) {
log(`fixing ${dups.length} residual duplicate titles...`);
const AtoZ = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const seenByTitle = {};
for (const r of rename) {
const t = r.new_title;
if (titleCount[t] <= 1) continue;
const n = (seenByTitle[t] || 0);
seenByTitle[t] = n + 1;
// Insert suffix before " Wallcovering"
r.new_title = r.new_title.replace(/ Wallcovering \| Jeffrey Stevens$/, ` ${AtoZ[n]} Wallcovering | Jeffrey Stevens`);
if (n === 0) {
// first instance stays without suffix (n=0 → 'A' for 2nd, 'B' for 3rd, etc.)
// Actually let me redo: first instance gets no suffix, 2nd gets 'A', etc.
}
}
// simpler re-do: first stays, rest get incremental suffix
const countByBase = {};
for (const r of rename) {
// strip any A-Z suffix we just added
const base = r.new_title.replace(/ [A-Z] Wallcovering \| Jeffrey Stevens$/, ' Wallcovering | Jeffrey Stevens');
const total = titleCount[base] || 1;
if (total <= 1) { r.new_title = base; continue; }
const idx = (countByBase[base] || 0);
countByBase[base] = idx + 1;
if (idx === 0) { r.new_title = base; }
else { r.new_title = base.replace(/ Wallcovering \| Jeffrey Stevens$/, ` ${AtoZ[idx-1]} Wallcovering | Jeffrey Stevens`); }
}
// re-verify
const c2 = {};
for (const r of rename) c2[r.new_title] = (c2[r.new_title]||0)+1;
const d2 = Object.entries(c2).filter(([,c])=>c>1);
log(`after fix: ${d2.length} residual dups`);
}
// Resume state
let done = new Set();
if (fs.existsSync(STATE)) {
try { done = new Set(JSON.parse(fs.readFileSync(STATE,'utf8'))); log(`resuming: ${done.size} already done`); } catch {}
}
async function applyOne(row) {
const pidNum = row.product_id;
const gid = `gid://shopify/Product/${pidNum}`;
const existing = raw[pidNum]?.tags || [];
const addTags = (row.tags_to_add || '').split('|').filter(Boolean);
const mergedTags = [...new Set([...existing, ...addTags])];
const m = `mutation UpdateProduct($input: ProductInput!) {
productUpdate(input: $input) {
product { id title }
userErrors { field message }
}
}`;
const r = await gql({
query: m,
variables: {
input: {
id: gid,
title: row.new_title,
tags: mergedTags,
}
}
});
const errs = r?.data?.productUpdate?.userErrors || [];
const topErrs = r?.errors || [];
const ok = errs.length === 0 && topErrs.length === 0 && r?.data?.productUpdate?.product?.id;
if (topErrs.length) errs.push(...topErrs.map(e => ({ field: ['top'], message: e.message })));
const record = { ts: new Date().toISOString(), pid: pidNum, ok, old_title: row.current_title, new_title: row.new_title, tags_added: addTags, errors: errs };
if (ok) fs.appendFileSync(EXEC, JSON.stringify(record) + '\n');
else fs.appendFileSync(FAILS, JSON.stringify(record) + '\n');
return ok;
}
async function run() {
log(`=== PHASE 3 EXECUTE — ${rename.length} products ===`);
const toDo = rename.filter(r => !done.has(r.product_id));
log(`to process: ${toDo.length}`);
const concurrency = 3;
let ok = 0, fail = 0;
let queue = [...toDo];
let inFlight = 0;
let progress = 0;
return new Promise((resolve) => {
const next = () => {
while (inFlight < concurrency && queue.length) {
const row = queue.shift();
inFlight++;
applyOne(row).then(success => {
if (success) ok++; else fail++;
progress++;
done.add(row.product_id);
if (progress % 25 === 0) {
fs.writeFileSync(STATE, JSON.stringify([...done]));
log(`progress ${progress}/${toDo.length} ok=${ok} fail=${fail}`);
}
}).catch(e => {
fail++;
fs.appendFileSync(FAILS, JSON.stringify({ pid: row.product_id, error: String(e) }) + '\n');
}).finally(() => {
inFlight--;
if (queue.length || inFlight) next();
else {
fs.writeFileSync(STATE, JSON.stringify([...done]));
log(`DONE — total=${progress} ok=${ok} fail=${fail}`);
resolve();
}
});
}
};
next();
});
}
run().catch(e => { log('FATAL ' + (e.stack||e.message||e)); process.exit(1); });