[object Object]

← back to Sister Parish Onboarding

sp: enrich_metafields.js — push DW spec metafields (item_width, roll_size, repeats, match, country) from PDP crawl, joined to live store by title

c283bfa3c40ef74584439f33b3caa1de9567ccd9 · 2026-05-18 11:01:18 -0700 · SteveStudio2

Files touched

Diff

commit c283bfa3c40ef74584439f33b3caa1de9567ccd9
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 18 11:01:18 2026 -0700

    sp: enrich_metafields.js — push DW spec metafields (item_width, roll_size, repeats, match, country) from PDP crawl, joined to live store by title
---
 scripts/enrich_metafields.js | 114 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 114 insertions(+)

diff --git a/scripts/enrich_metafields.js b/scripts/enrich_metafields.js
new file mode 100644
index 0000000..53e19e4
--- /dev/null
+++ b/scripts/enrich_metafields.js
@@ -0,0 +1,114 @@
+#!/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 -> metafield key
+const FIELD_MAP = {
+  'Item Width': 'item_width',
+  'Sold By': 'roll_size',
+  'Horizontal Repeat': 'repeat_horizontal',
+  'Vertical Repeat': 'repeat_vertical',
+  'Match': 'match',
+  'Country of Origin': 'country_of_origin',
+  'Content': 'content',
+};
+
+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, mfKey] of Object.entries(FIELD_MAP)) {
+      const v = (raw[srcKey] || '').trim();
+      if (v) metafields.push({ namespace: 'dwc', key: mfKey, type: 'single_line_text_field', 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); });

← 2c68b15 sp: private_label v2 — add display_variant tag + dwc.mfr_num  ·  back to Sister Parish Onboarding  ·  sp: ran private_label — deleted 52 dupes, renumbered 73 keep 6b8b0b8 →