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