← 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
A audit-live-state.jsA audit-pushed-cohort.jsA backfill-enrichment.jsA inspect-rich-product.js
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, '<').replace(/>/g, '>')}</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 →