← back to Designer Wallcoverings

audits/designtex-uom-fix/fill-specs-live-308.mjs

129 lines

#!/usr/bin/env node
// Designtex Step 3 — write structured spec metafields (global.* + specs.*, DTD 2/2 verdict C)
// to the LIVE DWDX Designtex products from canonical dw_unified, and fix "single roll" prose
// in body_html (body backed up FIRST). DWDX ONLY — never touches the 9 DWAH "Rocket" products.
// Source = /tmp/designtex-specs-export.json (real vendor crawl, NO AI). $0 (Shopify Admin API).
// Idempotent: a per-product ledger records done SKUs so a re-run skips them. ≥90s batch gaps.
import fs from 'fs'; import os from 'os'; import path from 'path';

const EXECUTE = process.argv.includes('--execute');
const HERE = path.dirname(new URL(import.meta.url).pathname);
const TOK = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8')
  .match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].trim();
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
async function gql(query, variables) {
  for (let a = 0; a < 6; a++) {
    const r = await fetch(`https://${DOMAIN}/admin/api/${VER}/graphql.json`, {
      method: 'POST', headers: { 'X-Shopify-Access-Token': TOK, 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, variables }),
    });
    const j = await r.json();
    if (j.errors && /throttl/i.test(JSON.stringify(j.errors))) { await new Promise(x => setTimeout(x, 3000)); continue; }
    return j;
  }
  return {};
}

// canonical spec source keyed by dw_sku
const specs = JSON.parse(fs.readFileSync('/tmp/designtex-specs-export.json', 'utf8'));
const byDwSku = {}; for (const s of specs) byDwSku[s.dw_sku] = s;

// idempotent ledger
const LEDGER = path.join(HERE, 'fill-specs-ledger.json');
const ledger = fs.existsSync(LEDGER) ? JSON.parse(fs.readFileSync(LEDGER, 'utf8')) : {};
// body_html backup file (append-only, keyed by product id)
const BODYBAK = path.join(HERE, 'body-html-backup.json');
const bodyBak = fs.existsSync(BODYBAK) ? JSON.parse(fs.readFileSync(BODYBAK, 'utf8')) : {};

// 1. collect every LIVE DWDX Designtex product
const pq = `query($c:String){
  products(first:50, after:$c, query:"vendor:Designtex"){
    pageInfo{ hasNextPage endCursor }
    edges{ node{
      id title status descriptionHtml
      variants(first:4){ edges{ node{ sku } } }
    } }
  }
}`;
const targets = []; let after = null;
while (true) {
  const j = await gql(pq, { c: after }); const pr = j.data && j.data.products;
  if (!pr) { console.error('fetch err', JSON.stringify(j).slice(0, 200)); break; }
  for (const e of pr.edges) {
    const p = e.node;
    const skus = (p.variants?.edges || []).map(v => v.node.sku).filter(Boolean);
    const dw = skus.map(s => s.replace(/-(Sample|Roll|Yard)$/i, '')).find(s => /^DWDX-/i.test(s));
    if (!dw) continue; // DWDX only — excludes DWAH Rocket
    targets.push({ id: p.id, dw, title: p.title, status: p.status, body: p.descriptionHtml || '' });
  }
  if (!pr.pageInfo.hasNextPage) break; after = pr.pageInfo.endCursor; await new Promise(x => setTimeout(x, 300));
}
const matched = targets.filter(t => byDwSku[t.dw]);
console.log(`Mode: ${EXECUTE ? 'EXECUTE' : 'DRY RUN'} | live DWDX=${targets.length} matched-to-canonical=${matched.length} unmatched=${targets.length - matched.length} already-done=${Object.keys(ledger).length}`);
if (targets.length - matched.length) console.log('  unmatched dw_skus:', targets.filter(t => !byDwSku[t.dw]).map(t => t.dw).slice(0, 20));
if (!EXECUTE) { console.log('Sample writes for first 2:'); matched.slice(0,2).forEach(t=>{ const s=byDwSku[t.dw]; console.log('  ',t.dw, {repeat:s.pattern_repeat, content:s.material, fire:s.fire_rating, care:s.maintenance, finish:s.finish, width:s.width}); }); console.log('Re-run with --execute.'); process.exit(0); }

const mfMut = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ field message } } }`;
const bodyMut = `mutation($id:ID!,$body:String!){ productUpdate(input:{id:$id, descriptionHtml:$body}){ userErrors{ message } } }`;
const T = 'single_line_text_field';

const fixProse = (html) => html
  .replace(/Priced\s+per\s+single\s+roll/gi, 'Priced per yard')
  .replace(/priced\s+per\s+single\s+roll/gi, 'priced per yard')
  .replace(/\bper\s+single\s+roll\b/gi, 'per yard')
  .replace(/\bsingle\s+roll\b/gi, 'yard');

const results = []; const BATCH = 50;
for (let i = 0; i < matched.length; i += BATCH) {
  for (const t of matched.slice(i, i + BATCH)) {
    if (ledger[t.dw]) { results.push({ dw: t.dw, ok: true, skipped: true }); continue; }
    const s = byDwSku[t.dw]; const errs = [];
    const mfs = [];
    const add = (ns, key, val) => { if (val != null && String(val).trim() !== '' && !/^N\/A$/i.test(String(val))) mfs.push({ ownerId: t.id, namespace: ns, key, type: T, value: String(val) }); };
    // global.* (primary PDP snippet: repeat, material, length, Contents, width)
    add('global', 'repeat', s.pattern_repeat);
    add('global', 'material', s.material);
    add('global', 'Contents', s.material);
    add('global', 'length', s.length || s.roll_length);
    add('global', 'width', s.width);
    add('global', 'Cleaning-Code', s.maintenance);
    add('global', 'FLAMMABILITY', s.fire_rating);
    add('global', 'Finish', s.finish);
    add('global', 'Country-of-Origin', 'USA');
    // specs.* (spec-sheet page snippet)
    add('specs', 'repeat', s.pattern_repeat);
    add('specs', 'material_type', s.material);
    add('specs', 'finish', s.finish);
    add('specs', 'fire_rating', s.fire_rating);
    add('specs', 'care_instructions', s.maintenance);
    add('specs', 'width', s.width);
    add('specs', 'color', s.color_name);
    add('specs', 'origin', 'USA');
    // chunk metafieldsSet at 25/call
    for (let k = 0; k < mfs.length; k += 25) {
      const m = await gql(mfMut, { mf: mfs.slice(k, k + 25) });
      (m.data?.metafieldsSet?.userErrors || []).forEach(e => errs.push('mf:' + e.message));
    }
    // body prose fix (backup first)
    if (/single\s+roll/i.test(t.body)) {
      if (!(t.id in bodyBak)) { bodyBak[t.id] = t.body; fs.writeFileSync(BODYBAK, JSON.stringify(bodyBak, null, 2)); }
      const fixed = fixProse(t.body);
      if (fixed !== t.body) {
        const b = await gql(bodyMut, { id: t.id, body: fixed });
        (b.data?.productUpdate?.userErrors || []).forEach(e => errs.push('body:' + e.message));
      }
    }
    const ok = errs.length === 0;
    if (ok) { ledger[t.dw] = { id: t.id, at: new Date().toISOString(), mfCount: mfs.length }; fs.writeFileSync(LEDGER, JSON.stringify(ledger, null, 2)); }
    else console.error('ERR', t.dw, errs.join('; '));
    results.push({ dw: t.dw, ok, errs });
    await new Promise(x => setTimeout(x, 350));
  }
  console.log(`Batch ${i / BATCH + 1} done (${Math.min(i + BATCH, matched.length)}/${matched.length}).`);
  if (i + BATCH < matched.length) { console.log('Sleeping 90s...'); await new Promise(x => setTimeout(x, 90000)); }
}
const failed = results.filter(r => !r.ok);
const skipped = results.filter(r => r.skipped).length;
console.log(`DONE. ${results.length - failed.length - skipped} written, ${skipped} skipped(done), ${failed.length} failed.`);
fs.writeFileSync(path.join(HERE, 'fill-specs-results.json'), JSON.stringify(results, null, 2));