[object Object]

← back to Wolfgordon Crawl

WG data-quality: add live-state audits + enrichment backfill (metafields/desc/images + activation gate, dry-run default)

02b49062e73c35598c63fc100e22ee9f96833eb7 · 2026-06-19 15:57:33 -0700 · Steve

Files touched

Diff

commit 02b49062e73c35598c63fc100e22ee9f96833eb7
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 19 15:57:33 2026 -0700

    WG data-quality: add live-state audits + enrichment backfill (metafields/desc/images + activation gate, dry-run default)
---
 audit-live-state.js     |  64 ++++++++++++
 audit-pushed-cohort.js  |  59 +++++++++++
 backfill-enrichment.js  | 257 ++++++++++++++++++++++++++++++++++++++++++++++++
 inspect-rich-product.js |  24 +++++
 4 files changed, 404 insertions(+)

diff --git a/audit-live-state.js b/audit-live-state.js
new file mode 100644
index 0000000..5edfa1f
--- /dev/null
+++ b/audit-live-state.js
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+// Read-only audit of LIVE Wolf Gordon products on Shopify: status, image count,
+// description presence, and the set of metafields present. No writes.
+const fs = require('fs');
+const os = require('os');
+
+function loadToken() {
+  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+  const p = os.homedir() + '/Projects/secrets-manager/.env';
+  const line = fs.readFileSync(p, 'utf8').split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
+  return line.slice('SHOPIFY_ADMIN_TOKEN='.length).replace(/^["']|["']$/g, '').trim();
+}
+const TOKEN = loadToken();
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const GQL = `https://${SHOP}/admin/api/${API}/graphql.json`;
+const wait = ms => new Promise(r => setTimeout(r, ms));
+
+async function gql(q, v) {
+  await wait(300);
+  const res = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+  const j = await res.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors));
+  return j.data;
+}
+
+const Q = `query($c:String){
+  products(first:80, query:"vendor:'Wolf Gordon'", after:$c){
+    pageInfo{ hasNextPage endCursor }
+    edges{ node{
+      id handle status descriptionHtml createdAt
+      mediaCount{ count }
+      metafields(first:30){ edges{ node{ namespace key } } }
+      variants(first:3){ edges{ node{ sku price } } }
+    } }
+  }
+}`;
+
+(async () => {
+  let cursor = null, pages = 0;
+  const stat = {}; let total = 0, zeroImg = 0, oneImg = 0, multiImg = 0, noDesc = 0, hasDesc = 0;
+  const mfCount = {}; // namespace.key -> count present
+  const zeroImgSkus = []; const activeZeroImg = [];
+  do {
+    const d = await gql(Q, { c: cursor });
+    for (const e of d.products.edges) {
+      const n = e.node; total++;
+      stat[n.status] = (stat[n.status] || 0) + 1;
+      const mc = n.mediaCount?.count || 0;
+      if (mc === 0) zeroImg++; else if (mc === 1) oneImg++; else multiImg++;
+      const dh = (n.descriptionHtml || '').replace(/<[^>]+>/g, '').trim();
+      if (dh.length < 20) noDesc++; else hasDesc++;
+      const mfs = n.metafields.edges.map(m => `${m.node.namespace}.${m.node.key}`);
+      for (const k of mfs) mfCount[k] = (mfCount[k] || 0) + 1;
+      const sku = n.variants.edges[0]?.node?.sku || '';
+      if (mc === 0) { zeroImgSkus.push({ sku, handle: n.handle, status: n.status }); if (n.status === 'ACTIVE') activeZeroImg.push(sku); }
+    }
+    cursor = d.products.pageInfo.hasNextPage ? d.products.pageInfo.endCursor : null;
+    pages++;
+  } while (cursor && pages < 80);
+  console.log(JSON.stringify({ total, status: stat, images: { zeroImg, oneImg, multiImg }, desc: { noDesc, hasDesc }, metafieldsPresent: mfCount, activeZeroImgCount: activeZeroImg.length, pages }, null, 2));
+  fs.writeFileSync('/tmp/wg-zero-img-skus.json', JSON.stringify(zeroImgSkus, null, 2));
+  console.log('zero-image SKUs written to /tmp/wg-zero-img-skus.json (' + zeroImgSkus.length + ')');
+})().catch(e => { console.error('ERR', e.message); process.exit(1); });
diff --git a/audit-pushed-cohort.js b/audit-pushed-cohort.js
new file mode 100644
index 0000000..da18de4
--- /dev/null
+++ b/audit-pushed-cohort.js
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+// Read-only: for the catalog rows that have a recorded shopify_product_id,
+// fetch the LIVE product and report image count, description presence, and
+// metafield richness. Identifies the bare-shell cohort created by today's push.
+const fs = require('fs'); const os = require('os');
+const { pool } = require('./scraper-utils');
+
+function loadToken() {
+  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+  const p = os.homedir() + '/Projects/secrets-manager/.env';
+  const line = fs.readFileSync(p, 'utf8').split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
+  return line.slice('SHOPIFY_ADMIN_TOKEN='.length).replace(/^["']|["']$/g, '').trim();
+}
+const TOKEN = loadToken();
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const GQL = `https://${SHOP}/admin/api/${API}/graphql.json`;
+const wait = ms => new Promise(r => setTimeout(r, ms));
+async function gql(q, v) {
+  await wait(280);
+  const res = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+  const j = await res.json(); if (j.errors) throw new Error(JSON.stringify(j.errors)); return j.data;
+}
+
+(async () => {
+  const rows = (await pool.query(
+    `SELECT dw_sku, mfr_sku, shopify_product_id FROM wolf_gordon_catalog
+     WHERE shopify_product_id IS NOT NULL AND shopify_product_id<>'' ORDER BY updated_at DESC`)).rows;
+  console.log('catalog rows w/ pid:', rows.length);
+  let active = 0, draft = 0, archived = 0, missing = 0;
+  let zeroImg = 0, oneImg = 0, multiImg = 0, noDesc = 0, hasDesc = 0, fewMf = 0;
+  const bareShells = []; // active, <=1 img OR no desc OR <=1 metafield
+  // batch by nodes()
+  for (let i = 0; i < rows.length; i += 50) {
+    const batch = rows.slice(i, i + 50);
+    const ids = batch.map(r => `"gid://shopify/Product/${r.shopify_product_id}"`).join(',');
+    const d = await gql(`{ nodes(ids:[${ids}]){ ... on Product { id status descriptionHtml mediaCount{count} metafields(first:40){edges{node{namespace key}}} variants(first:2){edges{node{sku}}} } } }`);
+    for (let k = 0; k < d.nodes.length; k++) {
+      const n = d.nodes[k]; const r = batch[k];
+      if (!n) { missing++; continue; }
+      if (n.status === 'ACTIVE') active++; else if (n.status === 'DRAFT') draft++; else archived++;
+      const mc = n.mediaCount?.count || 0;
+      if (mc === 0) zeroImg++; else if (mc === 1) oneImg++; else multiImg++;
+      const dh = (n.descriptionHtml || '').replace(/<[^>]+>/g, '').trim();
+      const dOk = dh.length >= 20; if (dOk) hasDesc++; else noDesc++;
+      const nmf = n.metafields.edges.length; if (nmf <= 2) fewMf++;
+      const sku = n.variants.edges[0]?.node?.sku || r.dw_sku;
+      if (n.status === 'ACTIVE' && (mc <= 1 || !dOk || nmf <= 2)) {
+        bareShells.push({ dw_sku: r.dw_sku, mfr_sku: r.mfr_sku, pid: r.shopify_product_id, mc, descLen: dh.length, nmf });
+      }
+    }
+    process.stderr.write(`\r  fetched ${Math.min(i + 50, rows.length)}/${rows.length}`);
+  }
+  process.stderr.write('\n');
+  console.log(JSON.stringify({ active, draft, archived, missing, zeroImg, oneImg, multiImg, noDesc, hasDesc, fewMf, bareShellCount: bareShells.length }, null, 2));
+  fs.writeFileSync('/tmp/wg-bare-shells.json', JSON.stringify(bareShells, null, 2));
+  console.log('bare-shell cohort written to /tmp/wg-bare-shells.json');
+  await pool.end();
+})().catch(e => { console.error('ERR', e.message); process.exit(1); });
diff --git a/backfill-enrichment.js b/backfill-enrichment.js
new file mode 100644
index 0000000..3d195a6
--- /dev/null
+++ b/backfill-enrichment.js
@@ -0,0 +1,257 @@
+#!/usr/bin/env node
+// ============================================================================
+// Wolf Gordon ENRICHMENT BACKFILL  (LIVE store: designer-laboratory-sandbox)
+//
+// Fixes the bare-shell cohort that today's push-shopify.js created: ACTIVE WG
+// products with NO description and only a single `custom.width` metafield.
+//
+// Source of truth = local dw_unified.wolf_gordon_catalog (PostgreSQL BEFORE
+// Shopify). This writes ONLY metafields + descriptionHtml + (optionally) images
+// + the activation gate. It NEVER creates products, NEVER creates variants,
+// NEVER touches price. CAP-FREE (no new variants).
+//
+// Idempotent: skips a metafield it can already see on the product; skips desc if
+// the product already has a non-trivial description; image-ADD-only (diff).
+//
+// Activation gate (Steve's hard rule): a product stays/goes ACTIVE only with an
+// image AND a width metafield; otherwise -> DRAFT + Needs-Image / Needs-Width.
+//
+// Usage:
+//   node backfill-enrichment.js                 # DRY-RUN, whole bare-shell cohort
+//   node backfill-enrichment.js --canary        # DRY-RUN, the 3 canary SKUs
+//   node backfill-enrichment.js --canary --apply
+//   node backfill-enrichment.js --apply         # LIVE, whole cohort
+//   node backfill-enrichment.js --skus A,B,C [--apply]
+//   node backfill-enrichment.js --limit 50 [--apply]
+// ============================================================================
+const fs = require('fs'); const os = require('os');
+const { pool } = require('./scraper-utils');
+
+function loadToken() {
+  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+  const p = os.homedir() + '/Projects/secrets-manager/.env';
+  const line = fs.readFileSync(p, 'utf8').split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
+  return line.slice('SHOPIFY_ADMIN_TOKEN='.length).replace(/^["']|["']$/g, '').trim();
+}
+const TOKEN = loadToken();
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const GQL = `https://${SHOP}/admin/api/${API}/graphql.json`;
+const THROTTLE_MS = 450;
+
+const argv = process.argv.slice(2);
+const APPLY = argv.includes('--apply');
+const CANARY = argv.includes('--canary');
+const SKUS_ARG = (() => { const i = argv.indexOf('--skus'); return i !== -1 && argv[i + 1] ? argv[i + 1].split(',').map(s => s.trim()) : null; })();
+const LIMIT = (() => { const i = argv.indexOf('--limit'); return i !== -1 && argv[i + 1] ? parseInt(argv[i + 1], 10) : null; })();
+const CANARY_SKUS = ['DWWG-533181', 'DWWG-533204', 'DWWG-533203'];
+
+const wait = ms => new Promise(r => setTimeout(r, ms));
+async function gql(q, v) {
+  await wait(THROTTLE_MS);
+  const res = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+  const j = await res.json();
+  if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors));
+  return j.data;
+}
+
+function banWallpaper(s) { return String(s || '').replace(/wallpapers/gi, 'Wallcoverings').replace(/wallpaper/gi, 'Wallcovering'); }
+function titleCase(s) { return String(s || '').toLowerCase().replace(/\b([a-z])/g, (m, c) => c.toUpperCase()); }
+
+// --- build the descriptionHtml from crawled ai_description (never AI-invent) ---
+function buildDescription(row) {
+  let d = (row.ai_description || '').trim();
+  if (!d || d.length < 20) return null;
+  d = banWallpaper(d);
+  return `<p>${d.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`;
+}
+
+function widthInches(row) {
+  if (row.width_inches != null) return String(row.width_inches);
+  const m = String(row.width || '').match(/([\d.]+)/);
+  return m ? m[1] : null;
+}
+
+// --- build the full metafield set from crawled catalog data ---
+function buildMetafields(row) {
+  const mf = [];
+  const add = (namespace, key, type, value) => {
+    if (value == null) return;
+    const v = String(value).trim();
+    if (!v || /^unknown$/i.test(v)) return;
+    mf.push({ namespace, key, type, value: banWallpaper(v) });
+  };
+  const widthDisp = row.width ? String(row.width).trim() : (widthInches(row) ? `${widthInches(row)}"` : null);
+
+  // global namespace (store convention for WG)
+  add('global', 'width', 'single_line_text_field', widthDisp);
+  add('global', 'material', 'single_line_text_field', row.material);
+  add('global', 'color', 'single_line_text_field', row.color_name);
+  add('global', 'Collection', 'single_line_text_field', row.collection);
+  add('global', 'finish', 'single_line_text_field', row.finish);
+  if (row.repeat_v) add('global', 'repeat', 'single_line_text_field', row.repeat_v);
+  add('global', 'title_tag', 'single_line_text_field',
+    `${titleCase(row.pattern_name || '')} - ${titleCase(row.color_name || '')} Wallcovering by Wolf Gordon Wallcoverings`.trim());
+  add('global', 'description_tag', 'single_line_text_field',
+    `Authorized Dealer of ${row.mfr_sku || row.dw_sku} by WOLF GORDON WALLCOVERINGS at Designer Wallcoverings`);
+
+  // custom namespace
+  add('custom', 'width', 'single_line_text_field', widthInches(row) ? `${widthInches(row)} Inches` : widthDisp);
+  add('custom', 'pattern_name', 'single_line_text_field', row.pattern_name);
+  add('custom', 'manufacturer_sku', 'single_line_text_field', row.mfr_sku);
+  add('custom', 'material', 'multi_line_text_field', row.material);
+  add('custom', 'collection_name', 'single_line_text_field', row.collection);
+  add('custom', 'color', 'single_line_text_field', row.color_name);
+  add('custom', 'fire_rating', 'single_line_text_field', row.fire_rating);
+  add('custom', 'color_hex', 'single_line_text_field', row.color_hex || row.dominant_color_hex);
+
+  // dwc namespace (DW standing rule: manufacturer_sku backup + real vendor + pattern/color)
+  add('dwc', 'manufacturer_sku', 'single_line_text_field', row.mfr_sku);
+  add('dwc', 'pattern_name', 'single_line_text_field', row.pattern_name);
+  add('dwc', 'color', 'single_line_text_field', row.color_name);
+  add('dwc', 'brand', 'single_line_text_field', 'Wolf Gordon');
+
+  // specs namespace (style/pattern from ai_styles/ai_patterns when present)
+  const firstOf = (j) => { try { const a = typeof j === 'string' ? JSON.parse(j) : j; return Array.isArray(a) && a.length ? String(a[0]) : null; } catch { return null; } };
+  add('specs', 'style', 'single_line_text_field', firstOf(row.ai_styles));
+  add('specs', 'pattern', 'single_line_text_field', firstOf(row.ai_patterns) || row.design);
+
+  // match type (commercial)
+  if (row.match_type) add('custom', 'pattern_repeat', 'single_line_text_field', row.match_type);
+
+  return mf;
+}
+
+async function loadLiveProduct(pid) {
+  const d = await gql(`{ node(id:"gid://shopify/Product/${pid}"){ ... on Product {
+    id status descriptionHtml
+    mediaCount{ count }
+    media(first:30){ edges{ node{ ... on MediaImage { image{ url } } } } }
+    metafields(first:80){ edges{ node{ namespace key } } }
+    variants(first:3){ edges{ node{ sku } } }
+  } } }`);
+  return d.node;
+}
+
+async function setMetafields(pid, mfs) {
+  // productSet metafields would replace — instead use metafieldsSet (upsert).
+  const inputs = mfs.map(m => ({ ownerId: `gid://shopify/Product/${pid}`, namespace: m.namespace, key: m.key, type: m.type, value: m.value }));
+  // metafieldsSet caps at 25 per call
+  for (let i = 0; i < inputs.length; i += 25) {
+    const chunk = inputs.slice(i, i + 25);
+    const d = await gql(`mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ field message } } }`, { mf: chunk });
+    const ue = d.metafieldsSet.userErrors;
+    if (ue && ue.length) throw new Error('metafieldsSet: ' + JSON.stringify(ue));
+  }
+}
+
+async function setDescription(pid, html) {
+  const d = await gql(`mutation($id:ID!,$d:String!){ productUpdate(input:{id:$id, descriptionHtml:$d}){ userErrors{ message } } }`,
+    { id: `gid://shopify/Product/${pid}`, d: html });
+  const ue = d.productUpdate.userErrors; if (ue && ue.length) throw new Error('descUpdate: ' + JSON.stringify(ue));
+}
+
+async function setStatus(pid, status) {
+  const d = await gql(`mutation($id:ID!,$s:ProductStatus!){ productUpdate(input:{id:$id, status:$s}){ product{status} userErrors{ message } } }`,
+    { id: `gid://shopify/Product/${pid}`, s: status });
+  const ue = d.productUpdate.userErrors; if (ue && ue.length) throw new Error('statusUpdate: ' + JSON.stringify(ue));
+}
+
+async function addTags(pid, tags) {
+  const d = await gql(`mutation($id:ID!,$t:[String!]!){ tagsAdd(id:$id, tags:$t){ userErrors{ message } } }`,
+    { id: `gid://shopify/Product/${pid}`, t: tags });
+  const ue = d.tagsAdd.userErrors; if (ue && ue.length) throw new Error('tagsAdd: ' + JSON.stringify(ue));
+}
+
+async function addImages(pid, urls) {
+  const d = await gql(`mutation($id:ID!,$m:[CreateMediaInput!]!){ productCreateMedia(productId:$id, media:$m){ mediaUserErrors{ message } } }`,
+    { id: `gid://shopify/Product/${pid}`, m: urls.map(u => ({ originalSource: u, mediaContentType: 'IMAGE' })) });
+  const ue = d.productCreateMedia.mediaUserErrors; if (ue && ue.length) console.error('    image-add warn:', JSON.stringify(ue));
+}
+
+function catalogImages(row) {
+  const imgs = [];
+  if (row.image_url) imgs.push(row.image_url);
+  if (row.all_images) {
+    try { const a = typeof row.all_images === 'string' ? JSON.parse(row.all_images) : row.all_images; if (Array.isArray(a)) for (const u of a) if (u && !imgs.includes(u)) imgs.push(u); } catch {}
+  }
+  return imgs;
+}
+
+async function main() {
+  console.log('='.repeat(74));
+  console.log(`  WG ENRICHMENT BACKFILL  mode=${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'}${CANARY ? '  [CANARY]' : ''}${LIMIT ? `  [limit ${LIMIT}]` : ''}`);
+  console.log(`  store=${SHOP}  api=${API}  token=...${TOKEN.slice(-4)}`);
+  console.log('='.repeat(74));
+
+  // target = bare-shell cohort: catalog rows w/ a recorded pid. Restrict to canary/skus/limit.
+  let where = `shopify_product_id IS NOT NULL AND shopify_product_id<>''`;
+  let params = [];
+  if (CANARY) { where += ` AND dw_sku = ANY($1)`; params = [CANARY_SKUS]; }
+  else if (SKUS_ARG) { where += ` AND dw_sku = ANY($1)`; params = [SKUS_ARG]; }
+  const rows = (await pool.query(
+    `SELECT id, mfr_sku, dw_sku, pattern_name, color_name, collection, product_type, material,
+            width, width_inches, repeat_v, repeat_h, match_type, fire_rating, finish, design,
+            image_url, all_images, color_hex, dominant_color_hex, ai_description, ai_styles, ai_patterns,
+            shopify_product_id
+     FROM wolf_gordon_catalog WHERE ${where} ORDER BY updated_at DESC ${LIMIT && !CANARY && !SKUS_ARG ? `LIMIT ${LIMIT}` : ''}`,
+    params)).rows;
+  console.log(`  candidate rows: ${rows.length}`);
+
+  const summary = { processed: 0, descSet: 0, mfSet: 0, imgAdded: 0, drafted: 0, tagged: 0, skippedComplete: 0, errors: 0 };
+  for (const row of rows) {
+    const pid = row.shopify_product_id;
+    try {
+      const live = await loadLiveProduct(pid);
+      if (!live) { console.log(`  [MISS] ${row.dw_sku} pid=${pid} not found`); continue; }
+      summary.processed++;
+
+      const have = new Set(live.metafields.edges.map(m => `${m.node.namespace}.${m.node.key}`));
+      const wantMf = buildMetafields(row).filter(m => !have.has(`${m.namespace}.${m.key}`));
+      const curDesc = (live.descriptionHtml || '').replace(/<[^>]+>/g, '').trim();
+      const wantDesc = curDesc.length < 20 ? buildDescription(row) : null;
+
+      const liveImgs = new Set((live.media.edges || []).map(m => m.node?.image?.url).filter(Boolean).map(u => u.split('?')[0]));
+      const wantImgs = catalogImages(row).filter(u => u && !liveImgs.has(u.split('?')[0]));
+      const liveImgCount = live.mediaCount?.count || 0;
+      const willHaveImg = liveImgCount > 0 || wantImgs.length > 0;
+
+      // width metafield presence after backfill
+      const widthDisp = row.width || (row.width_inches ? `${row.width_inches}"` : null);
+      const willHaveWidth = have.has('global.width') || have.has('custom.width') ||
+        wantMf.some(m => m.key === 'width') || !!widthDisp;
+
+      const needsImageTag = !willHaveImg;
+      const needsWidthTag = !willHaveWidth;
+      const shouldDraft = (needsImageTag || needsWidthTag);
+
+      if (!wantMf.length && !wantDesc && !wantImgs.length && !shouldDraft) { summary.skippedComplete++; continue; }
+
+      console.log(`  [FIX] ${row.dw_sku} ${row.pattern_name} ${row.color_name} | +${wantMf.length}mf desc=${wantDesc ? 'Y' : '-'} +${wantImgs.length}img${shouldDraft ? ' DRAFT' : ''}`);
+
+      if (APPLY) {
+        if (wantImgs.length) { await addImages(pid, wantImgs); summary.imgAdded += wantImgs.length; }
+        if (wantMf.length) { await setMetafields(pid, wantMf); summary.mfSet += wantMf.length; }
+        if (wantDesc) { await setDescription(pid, wantDesc); summary.descSet++; }
+        if (shouldDraft) {
+          if (live.status === 'ACTIVE') { await setStatus(pid, 'DRAFT'); summary.drafted++; }
+          const tags = []; if (needsImageTag) tags.push('Needs-Image'); if (needsWidthTag) tags.push('Needs-Width');
+          if (tags.length) { await addTags(pid, tags); summary.tagged++; }
+        }
+      } else {
+        if (wantImgs.length) summary.imgAdded += wantImgs.length;
+        if (wantMf.length) summary.mfSet += wantMf.length;
+        if (wantDesc) summary.descSet++;
+        if (shouldDraft) summary.drafted++;
+      }
+    } catch (e) {
+      summary.errors++;
+      console.error(`  [ERR] ${row.dw_sku} pid=${pid}: ${e.message}`);
+    }
+  }
+
+  console.log('-'.repeat(74));
+  console.log((APPLY ? '  APPLIED. ' : '  DRY-RUN. ') + JSON.stringify(summary));
+  await pool.end();
+}
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
diff --git a/inspect-rich-product.js b/inspect-rich-product.js
new file mode 100644
index 0000000..3f3d7ae
--- /dev/null
+++ b/inspect-rich-product.js
@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+// Read-only: dump the full metafield set + descriptionHtml of one richly-enriched
+// WG product so the backfill mirrors store conventions (namespace/key/type).
+const fs = require('fs'); const os = require('os');
+function loadToken() {
+  if (process.env.SHOPIFY_ADMIN_TOKEN) return process.env.SHOPIFY_ADMIN_TOKEN;
+  const p = os.homedir() + '/Projects/secrets-manager/.env';
+  const line = fs.readFileSync(p, 'utf8').split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='));
+  return line.slice('SHOPIFY_ADMIN_TOKEN='.length).replace(/^["']|["']$/g, '').trim();
+}
+const TOKEN = loadToken();
+const SHOP = 'designer-laboratory-sandbox.myshopify.com'; const API = '2024-10';
+const GQL = `https://${SHOP}/admin/api/${API}/graphql.json`;
+async function gql(q, v) { const res = await fetch(GQL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) }); const j = await res.json(); if (j.errors) throw new Error(JSON.stringify(j.errors)); return j.data; }
+(async () => {
+  // find a WG product with many metafields + a description
+  const d = await gql(`{ products(first:60, query:"vendor:'Wolf Gordon'"){ edges{ node{ id title status descriptionHtml metafields(first:60){edges{node{namespace key type value}}} } } } }`);
+  let best = null;
+  for (const e of d.products.edges) { const n = e.node; if ((n.metafields.edges.length) > (best?.metafields.edges.length || 0)) best = n; }
+  console.log('TITLE:', best.title, '| status', best.status);
+  console.log('DESC(first 300):', (best.descriptionHtml || '').slice(0, 300));
+  console.log('METAFIELDS:');
+  for (const m of best.metafields.edges) console.log(`  ${m.node.namespace}.${m.node.key} [${m.node.type}] = ${String(m.node.value).slice(0, 70)}`);
+})().catch(e => { console.error('ERR', e.message); process.exit(1); });

← a437445 Add Wolf Gordon image-repair script (idempotent, image-add o  ·  back to Wolfgordon Crawl  ·  backfill: dedup images by basename (catalog cdn2-optimize vs fe335e5 →