← back to Sister Parish Onboarding
scripts/enrich_metafields.js
115 lines
#!/usr/bin/env node
/**
* Enrich live Sister Parish products with DW spec metafields.
*
* Source: output/pdp_specs.json (per-product PDP crawl) joined to
* output/normalized.json via sp_product_id to recover the title,
* then matched to the live store by title (minus " | Sister Parish").
*
* Writes these dwc.* single-line-text metafields (only when source has a value):
* Item Width -> dwc.item_width
* Sold By -> dwc.roll_size (DW "roll length")
* Horizontal Repeat -> dwc.repeat_horizontal
* Vertical Repeat -> dwc.repeat_vertical
* Match -> dwc.match
* Country of Origin -> dwc.country_of_origin
* Content -> dwc.content
*
* Flags: --dry-run / --plan (print, change nothing)
*/
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs = require('fs');
const path = require('path');
const SHOP = process.env.SHOPIFY_STORE;
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
if (!SHOP || !TOKEN) { console.error('Need SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
const BASE = `https://${SHOP}/admin/api/2026-01`;
const RATE_DELAY_MS = 350;
const DRY = process.argv.includes('--dry-run') || process.argv.includes('--plan');
const sleep = ms => new Promise(r => setTimeout(r, ms));
// source spec key -> { key, type } (dwc.content has a multi_line definition)
const FIELD_MAP = {
'Item Width': { key: 'item_width', type: 'single_line_text_field' },
'Sold By': { key: 'roll_size', type: 'single_line_text_field' },
'Horizontal Repeat': { key: 'repeat_horizontal', type: 'single_line_text_field' },
'Vertical Repeat': { key: 'repeat_vertical', type: 'single_line_text_field' },
'Match': { key: 'match', type: 'single_line_text_field' },
'Country of Origin': { key: 'country_of_origin', type: 'single_line_text_field' },
'Content': { key: 'content', type: 'multi_line_text_field' },
};
const norm = s => (s || '').replace(/\s*\|\s*Sister Parish\s*$/i, '').trim().toLowerCase();
async function shopify(method, url, body) {
const res = await fetch(url.startsWith('http') ? url : `${BASE}${url}`, {
method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
if (!res.ok) { const e = new Error(`${method} ${url} -> ${res.status}: ${text.slice(0,300)}`); e.status = res.status; throw e; }
return { json, link: res.headers.get('link') || '' };
}
const nextPage = link => {
const m = link.split(',').find(s => s.includes('rel="next"'));
return m ? m.match(/<([^>]+)>/)[1] : null;
};
(async () => {
// ---- build title -> specs map ----
const normalized = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'output', 'normalized.json'), 'utf8'));
const pdp = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'output', 'pdp_specs.json'), 'utf8'));
const idToTitle = new Map(normalized.map(p => [p.sp_product_id, p.title]));
const specsByTitle = new Map();
for (const entry of Object.values(pdp)) {
const title = idToTitle.get(entry.sp_product_id);
if (!title) continue;
specsByTitle.set(norm(title), entry.raw || {});
}
console.log(`Spec rows: ${specsByTitle.size}`);
// ---- fetch live SP products ----
const products = [];
let url = `${BASE}/products.json?vendor=${encodeURIComponent('Sister Parish')}&limit=250&fields=id,title`;
while (url) {
const { json, link } = await shopify('GET', url);
products.push(...json.products);
url = nextPage(link);
if (url) await sleep(RATE_DELAY_MS);
}
console.log(`Live SP products: ${products.length}\n`);
// ---- enrich ----
let ok = 0, fail = 0, skipped = 0, unmatched = 0;
for (let i = 0; i < products.length; i++) {
const p = products[i];
const raw = specsByTitle.get(norm(p.title));
const stamp = `[${String(i+1).padStart(3,'0')}/${products.length}]`;
if (!raw) { unmatched++; console.log(`${stamp} ? no spec match: ${p.title}`); continue; }
const metafields = [];
for (const [srcKey, mf] of Object.entries(FIELD_MAP)) {
const v = (raw[srcKey] || '').trim();
if (v) metafields.push({ namespace: 'dwc', key: mf.key, type: mf.type, value: v });
}
if (metafields.length === 0) { skipped++; console.log(`${stamp} - no values: ${p.title}`); continue; }
if (DRY) {
console.log(`${stamp} would set ${metafields.length} mf on ${p.title}: ${metafields.map(m => m.key+'='+m.value).join(' | ')}`);
ok++; continue;
}
try {
await shopify('PUT', `/products/${p.id}.json`, { product: { id: p.id, metafields } });
ok++; console.log(`${stamp} ✓ ${p.title} (${metafields.length} mf)`);
} catch (e) {
fail++; console.log(`${stamp} ✗ ${p.title}: ${e.message.slice(0,150)}`);
}
await sleep(RATE_DELAY_MS);
}
console.log(`\nEnriched: ${ok} Skipped(no values): ${skipped} Unmatched: ${unmatched} Failed: ${fail}`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });