← back to Hollywood Import

backfill-specs.mjs

144 lines

// backfill-specs.mjs — write the missing spec metafields + rebuilt body_html onto live
// Hollywood Wallcoverings products that the create drip shipped with ZERO metafields
// (376 active as of 2026-07-15; 366 matched to momentum_colorways in backfill-targets.json).
// Schema mirrors the healthy line (global.* specs + custom/dwc identity keys, Sidmouth
// reference). Body rule: keep any existing <p> description (April batch), else use the
// staged description; the spec <table> is always rebuilt from staging. Private-label
// scrub on every written string. DRY-RUN by default; --apply --limit=N to write.
// Audit JSONL, idempotent (skips DONE pids on resume). $0 — Admin API only.
import fs from 'node:fs';

const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const STORE = (env.match(/^SHOPIFY_STORE=(.+)$/m) || [])[1].trim();
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
const REST = `https://${STORE}/admin/api/2024-10`;
const GQL = `${REST}/graphql.json`;
const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const APPLY = process.argv.includes('--apply');
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
const AUDIT = new URL('backfill-specs-audit.jsonl', import.meta.url).pathname;

const sleep = ms => new Promise(r => setTimeout(r, ms));
const scrub = s => String(s || '').replace(/\b(momentum(\s+textiles( and walls)?)?|versa(’s)?(\s+designed\s+surfaces)?|innovations(\s+usa)?|muratto)\b/gi, '').replace(/\s{2,}/g, ' ').replace(/\s+\|/g, ' |').trim();
const LEAK = /\b(momentum|versa|innovations|muratto)\b/i; // word-bounded: "versatile" is not a leak
const unitWord = u => ({ SR: 'Roll', EA: 'Panel', BOX: 'Box', PANEL: 'Panel', SY: 'Sq Yard', YD: 'Yard' }[String(u || '').toUpperCase()] || 'Yard');

function buildMetafields(it) {
  const sl = 'single_line_text_field', ml = 'multi_line_text_field';
  const mf = [];
  const add = (namespace, key, value, type = sl) => {
    let v = scrub(value);
    if (type === sl) v = v.replace(/\s*[\r\n]+\s*/g, ' · ').trim(); // single-line fields reject embedded newlines
    if (v) mf.push({ namespace, key, type, value: v });
  };
  const widthVal = it.width ? (/^[\d.]+$/.test(String(it.width).trim()) ? `${String(it.width).trim()}"` : it.width) : '';
  add('global', 'width', widthVal);
  // v_prods_weight only when the staged weight is in lb — a bare "25" from "25 oz/ly" would mislead
  const wStr = String(it.weight || '');
  if (/lb\b/i.test(wStr)) add('global', 'v_prods_weight', (wStr.match(/[\d.]+/) || [])[0]);
  add('global', 'unit_of_measure', `Sold Per ${unitWord(it.uom)}`);
  add('global', 'repeat', it.repeat_info);
  add('global', 'fire_rating', it.fire_rating || (it.has_flame_cert ? 'Flame Certified' : ''));
  add('global', 'lead_time', 'Usually in Stock');
  // customer-facing PL pattern name comes from the live title (staging pattern_name is upstream):
  // "Back Bay Glitterati | Hollywood Wallcoverings" → "Back Bay" (strip trailing color word)
  const norm = s => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
  let base = String(it.title || '').split('|')[0]
    .replace(/\s*(commercial\s+wallcovering|acoustic\s+panel|wallcovering|fabric)\s*$/i, '').trim();
  const color = String(it.color_name || '').trim();
  let plPattern = '';
  if (color && base.toLowerCase().endsWith(color.toLowerCase())) {
    plPattern = base.slice(0, base.length - color.length).replace(/[\s\-–]+$/, '').trim();
  } else if (norm(base).includes(norm(it.pattern_name)) && norm(it.pattern_name)) {
    plPattern = scrub(it.pattern_name); // staging name survives in the title (color was PL-renamed)
  } // else: title-only naming we can't decompose — omit rather than write a wrong pattern
  add('custom', 'pattern_name', plPattern);
  add('custom', 'supplier_name', 'Hollywood Wallcoverings');
  add('custom', 'manufacturer_sku', it.momentum_sku);
  add('custom', 'material', it.content, ml);
  // real_vendor = the TRUE upstream vendor (internal purchasing field, never theme-rendered).
  // Hollywood Wallcoverings is the BRAND, not the vendor — Steve 2026-07-15. Pushed directly
  // because scrub() would erase "Momentum".
  mf.push({ namespace: 'dwc', key: 'real_vendor', type: sl, value: 'Momentum' });
  add('dwc', 'manufacturer_sku', it.momentum_sku);
  return mf;
}

function buildBody(it) {
  // keep existing <p>…</p> paragraphs (April batch has real descriptions); else staged description
  const existingPs = (String(it.body_html || '').match(/<p>[\s\S]*?<\/p>/gi) || []).join('');
  const para = existingPs || (it.description ? `<p>${scrub(it.description)}</p>` : '');
  const row = (label, val) => {
    const v = scrub(val);
    return v ? `<tr><td>${label}</td><td>${v}</td></tr>` : '';
  };
  const widthVal = it.width ? (/^[\d.]+$/.test(String(it.width).trim()) ? `${String(it.width).trim()}"` : it.width) : '';
  const table = '<table>' +
    row('Width', widthVal) +
    row('Weight', it.weight) +
    row('Content', it.content) +
    row('Finish', it.finish) +
    row('Repeat', it.repeat_info) +
    row('Fire Rating', it.fire_rating || (it.has_flame_cert ? 'Flame Certified' : '')) +
    row('Cleaning', it.cleaning) +
    row('Sold Per', unitWord(it.uom)) +
    '</table>';
  return para + table;
}

const M_SET = `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{field message}}}`;

async function gql(query, variables) {
  for (let a = 0; a < 4; a++) {
    const r = await fetch(GQL, { method: 'POST', headers: H, body: JSON.stringify({ query, variables }) });
    const j = await r.json();
    if (j.errors) { if (a === 3) throw new Error(JSON.stringify(j.errors).slice(0, 200)); await sleep(900 * (a + 1)); continue; }
    return j.data;
  }
}

const items = JSON.parse(fs.readFileSync(new URL('backfill-targets.json', import.meta.url).pathname, 'utf8'));
const doneSet = new Set();
if (fs.existsSync(AUDIT)) for (const l of fs.readFileSync(AUDIT, 'utf8').trim().split('\n').filter(Boolean)) {
  try { const r = JSON.parse(l); if (r.action === 'DONE') doneSet.add(r.pid); } catch {}
}
const pidOf = it => Number(String(it.shopify_id).match(/\d+$/)[0]); // mirror stores full GIDs
const remaining = items.filter(it => !doneSet.has(pidOf(it)));
const todo = LIMIT > 0 ? remaining.slice(0, LIMIT) : remaining;
if (doneSet.size) console.log(`resume: ${doneSet.size} already done, ${remaining.length} remain`);
console.log(`backfill-specs: ${todo.length} items · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
const out = APPLY ? fs.createWriteStream(AUDIT, { flags: 'a' }) : null;
let done = 0, err = 0;
for (const it of todo) {
  const pid = pidOf(it);
  const mf = buildMetafields(it);
  const body = buildBody(it);
  // dwc.real_vendor legitimately holds the upstream vendor name — exempt from the leak gate
  const leak = LEAK.test(mf.filter(m => !(m.namespace === 'dwc' && m.key === 'real_vendor')).map(m => m.value).join(' ') + ' ' + body);
  if (!APPLY) {
    console.log(`\n${it.base_sku}  ${it.handle}  ${leak ? '⚠LEAK' : 'clean'}  mf=${mf.length}`);
    for (const m of mf) console.log(`  ${m.namespace}.${m.key} = ${m.value.slice(0, 70)}`);
    console.log(`  body: ${body.replace(/</g, '‹').slice(0, 220)}`);
    continue;
  }
  if (leak) { console.error(`  SKIP LEAK ${it.base_sku}`); out.write(JSON.stringify({ pid, dw: it.base_sku, action: 'SKIP_LEAK' }) + '\n'); err++; continue; }
  try {
    const gid = `gid://shopify/Product/${pid}`;
    const d = await gql(M_SET, { mf: mf.map(m => ({ ...m, ownerId: gid })) });
    const ue = d.metafieldsSet.userErrors;
    if (ue.length) throw new Error('metafieldsSet: ' + JSON.stringify(ue).slice(0, 150));
    const r = await fetch(`${REST}/products/${pid}.json`, { method: 'PUT', headers: H, body: JSON.stringify({ product: { id: pid, body_html: body } }) });
    if (!r.ok) throw new Error(`body PUT ${r.status}`);
    out.write(JSON.stringify({ pid, dw: it.base_sku, action: 'DONE', mf: mf.length }) + '\n');
    done++;
    if (done % 25 === 0) console.log(`  … ${done}/${todo.length}`);
    await sleep(600);
  } catch (e) {
    console.error(`  ERR ${it.base_sku}: ${e.message.slice(0, 140)}`);
    out.write(JSON.stringify({ pid, dw: it.base_sku, action: 'ERR', msg: e.message.slice(0, 140) }) + '\n');
    err++; await sleep(900);
  }
}
if (out) out.end();
console.log(`\nDONE: updated=${done} err=${err} of ${todo.length}` + (APPLY ? ` · audit ${AUDIT}` : ' · DRY-RUN, no writes'));