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