← back to Letsbegin
Add Claude-vision enrich-write helper (color+specs+tags→metafields, bypass Gemini cap)
f5b11c2ed86f684b5561a523818c5818ce769e94 · 2026-06-11 11:36:46 -0700 · Steve Abrams
Files touched
Diff
commit f5b11c2ed86f684b5561a523818c5818ce769e94
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jun 11 11:36:46 2026 -0700
Add Claude-vision enrich-write helper (color+specs+tags→metafields, bypass Gemini cap)
---
enrich-write.js | 332 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 332 insertions(+)
diff --git a/enrich-write.js b/enrich-write.js
new file mode 100644
index 0000000..797d73a
--- /dev/null
+++ b/enrich-write.js
@@ -0,0 +1,332 @@
+#!/usr/bin/env node
+/**
+ * enrich-write.js — WRITE-ONLY Claude-vision enrichment helper.
+ *
+ * Vision is done by the CALLING AGENT (Claude looks at the product image and
+ * derives color + tags). This script does NO vision — it just writes the
+ * agent's findings to Shopify metafields + image alt tags + normalizes the
+ * title + stamps the dw_unified.shopify_products row. Idempotent (safe re-run).
+ *
+ * Why: Gemini is spend-capped, so Claude/agents are the vision analyzer instead
+ * of last20day_full_monty.js's inline Gemini call. This is the write half of
+ * that script, with the Gemini phase ripped out.
+ *
+ * Usage:
+ * node enrich-write.js <shopify_id> <json-payload-file>
+ *
+ * <shopify_id> may be a bare numeric id or a full gid://shopify/Product/<id>.
+ *
+ * payload JSON:
+ * {
+ * "colorName": "Olive Green", // required — dominant color name
+ * "hex": "#6B7A3A", // required — dominant hex
+ * "colorDetails": [ {"name":"Olive Green","hex":"#6B7A3A","percentage":55}, ... ]
+ * // or { "colors":[...], "background":"White", ... } — stored as-is as JSON
+ * "specs": { "material":"Non-woven", "finish":"Matte", ... }, // optional → custom.<key>
+ * "styleTags": ["botanical","mural","dark"], // optional — added as Shopify product tags
+ * "title": "Palm Jungle - Olive Green | Cole & Son" // null/absent → auto-normalize from current title
+ * }
+ *
+ * Env: SHOPIFY_ADMIN_TOKEN (or SHOPIFY_PRODUCT_TOKEN). PG* optional (defaults
+ * user stevestudio2, db dw_unified, local socket).
+ *
+ * Exit non-zero on any Shopify userError or fatal.
+ */
+
+const https = require('https');
+const fs = require('fs');
+const { Client } = require('pg');
+
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || process.env.SHOPIFY_PRODUCT_TOKEN;
+const API = '/admin/api/2024-10/graphql.json';
+if (!TOKEN) { console.error('FATAL: SHOPIFY_ADMIN_TOKEN (or SHOPIFY_PRODUCT_TOKEN) env var required'); process.exit(1); }
+
+// ---------- args ----------
+const rawId = process.argv[2];
+const payloadFile = process.argv[3];
+if (!rawId || !payloadFile) {
+ console.error('Usage: node enrich-write.js <shopify_id> <json-payload-file>');
+ process.exit(2);
+}
+const PID = /^gid:\/\//.test(rawId) ? rawId : `gid://shopify/Product/${String(rawId).replace(/\D/g, '')}`;
+
+let payload;
+try { payload = JSON.parse(fs.readFileSync(payloadFile, 'utf8')); }
+catch (e) { console.error(`FATAL: cannot read/parse payload file: ${e.message}`); process.exit(2); }
+
+const colorName = (payload.colorName || '').trim();
+const hex = (payload.hex || '').trim();
+if (!colorName || !hex) { console.error('FATAL: payload requires colorName + hex'); process.exit(2); }
+const colorDetails = payload.colorDetails != null ? payload.colorDetails : null;
+const specs = payload.specs && typeof payload.specs === 'object' ? payload.specs : {};
+const styleTags = Array.isArray(payload.styleTags) ? payload.styleTags.map(t => String(t).trim()).filter(Boolean) : [];
+const providedTitle = payload.title != null && String(payload.title).trim() ? String(payload.title).trim() : null;
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// ---------- Shopify GraphQL (from last20day_full_monty.js) ----------
+function gqlRaw(body) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const req = https.request({
+ hostname: STORE, path: API, method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
+ }, res => { let c = ''; res.on('data', d => c += d); res.on('end', () => { try { resolve(JSON.parse(c)); } catch { resolve({ error: c.slice(0, 300) }); } }); });
+ req.on('error', reject);
+ req.setTimeout(60000, () => { req.destroy(); reject(new Error('timeout')); });
+ req.write(data); req.end();
+ });
+}
+async function gql(body, retries = 4) {
+ for (let attempt = 1; attempt <= retries; attempt++) {
+ try {
+ const result = await gqlRaw(body);
+ const throttled = result?.errors?.some(e => /Throttled/i.test(e.message || ''));
+ const lowBudget = (result?.extensions?.cost?.throttleStatus?.currentlyAvailable || 9999) < 200;
+ if (throttled) { await sleep(3000); continue; }
+ if (lowBudget) await sleep(1500);
+ return result;
+ } catch (e) {
+ if (attempt < retries) { await sleep(attempt * 2000); continue; }
+ throw e;
+ }
+ }
+}
+
+// ---------- title helpers (verbatim from last20day_full_monty.js) ----------
+function stripVendorSuffix(title) {
+ const pi = title.indexOf('|');
+ return (pi >= 0 ? title.slice(0, pi) : title).trim();
+}
+function extractPatternAndColor(title) {
+ let cleaned = stripVendorSuffix(title);
+ cleaned = cleaned.replace(/\(tm\)/gi, '').replace(/\bwallcoverings?\b/gi, '').replace(/\bwall\s*coverings?\b/gi, '').replace(/\bwall\s*papers?\b/gi, '').replace(/\s+/g, ' ').trim();
+ const commaIdx = cleaned.search(/,\s/);
+ const dashIdx = cleaned.search(/\s[-–]\s/);
+ function lastSep(re) { let m, last = -1; const r = new RegExp(re, 'g'); while ((m = r.exec(cleaned))) last = m.index; return last; }
+ const lastComma = lastSep(',\\s');
+ const lastDash = lastSep('\\s[-–]\\s');
+ if (commaIdx === -1 && dashIdx === -1) return { pattern: cleaned.trim(), color: null };
+ if (lastDash >= 0 && lastDash >= lastComma) {
+ const m = cleaned.slice(0, lastDash).trim();
+ const c = cleaned.slice(lastDash).replace(/^\s[-–]\s/, '').trim();
+ return { pattern: m, color: c };
+ }
+ if (lastComma >= 0) {
+ const m = cleaned.slice(0, lastComma).trim();
+ const c = cleaned.slice(lastComma + 1).trim();
+ return { pattern: m, color: c };
+ }
+ return { pattern: cleaned.trim(), color: null };
+}
+function toTitleCase(s) { if (!s) return ''; return s.replace(/\b\w+/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()); }
+function buildTitle(pattern, color, vendor) {
+ const tc = toTitleCase(color);
+ return tc ? `${pattern} - ${tc} | ${vendor}` : `${pattern} | ${vendor}`;
+}
+
+// ---------- spec key/type map (same conventions as last20day FIELD_MAP) ----------
+const MULTILINE_KEYS = new Set(['material', 'description', 'care_instructions']);
+function specType(key) { return MULTILINE_KEYS.has(key) ? 'multi_line_text_field' : 'single_line_text_field'; }
+
+// Look up the existing PRODUCT metafield definition type for custom.<key>.
+// Returns the type name (e.g. 'product_reference', 'single_line_text_field')
+// or null if no definition exists (free text accepted). Cached per process.
+const _defCache = new Map();
+async function definitionType(key) {
+ if (_defCache.has(key)) return _defCache.get(key);
+ let t = null;
+ try {
+ const r = await gql({ query: `{ metafieldDefinitions(first:1, ownerType: PRODUCT, namespace:"custom", key:"${key}"){ edges { node { type { name } } } } }` });
+ const edges = r?.data?.metafieldDefinitions?.edges || [];
+ if (edges.length) t = edges[0].node.type.name;
+ } catch { t = null; }
+ _defCache.set(key, t);
+ return t;
+}
+
+const PRODUCT_QUERY = (id) => ({ query: `{
+ product(id:"${id}") {
+ id title vendor tags
+ images(first:15){ edges{ node{ id url } } }
+ media(first:15){ edges{ node{ ... on MediaImage { id } } } }
+ variants(first:5){ edges{ node{ id sku } } }
+ metafields(first:100){ edges{ node{ namespace key value type } } }
+ }
+}` });
+
+const MF_MUTATION = 'mutation metafieldsSet($m: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $m) { metafields { key } userErrors { message field } } }';
+const PRODUCT_UPDATE = 'mutation ($i: ProductInput!) { productUpdate(input: $i) { product { id } userErrors { message field } } }';
+
+async function main() {
+ const errors = [];
+
+ // ---- fetch product ----
+ const fr = await gql(PRODUCT_QUERY(PID));
+ const p = fr?.data?.product;
+ if (!p) { console.error(`FATAL: product ${PID} not found on Shopify (resp: ${JSON.stringify(fr).slice(0, 200)})`); process.exit(1); }
+ const origTitle = p.title || '';
+ const vendor = p.vendor || 'Designer Wallcoverings';
+ const firstVariant = p.variants?.edges?.find(e => e.node?.sku && !/-sample$/i.test(e.node.sku))?.node || p.variants?.edges?.[0]?.node;
+ const sku = (firstVariant?.sku || '').replace(/-sample$/i, '').trim();
+ const existingTags = Array.isArray(p.tags) ? p.tags : [];
+
+ console.log(`[enrich-write] ${PID}`);
+ console.log(` vendor=${vendor} sku=${sku || '(none)'}`);
+ console.log(` origTitle="${origTitle}"`);
+
+ // ---- 1a. COLOR metafields (always written in their own batch so a bad spec
+ // key can't poison the color write — see pilot note re: legacy
+ // product_reference definitions on this sandbox store) ----
+ const colorMf = [
+ { ownerId: PID, namespace: 'custom', key: 'dominant_color', value: colorName, type: 'single_line_text_field' },
+ { ownerId: PID, namespace: 'custom', key: 'dominant_hex', value: hex, type: 'single_line_text_field' },
+ // mirror to the keys last20day_full_monty.js wrote so existing readers stay happy
+ { ownerId: PID, namespace: 'custom', key: 'color_name', value: colorName, type: 'single_line_text_field' },
+ { ownerId: PID, namespace: 'custom', key: 'color_hex', value: hex, type: 'single_line_text_field' },
+ ];
+ if (colorDetails != null) {
+ colorMf.push({ ownerId: PID, namespace: 'custom', key: 'color_details', value: JSON.stringify(colorDetails), type: 'json' });
+ }
+ {
+ const r = await gql({ query: MF_MUTATION, variables: { m: colorMf } });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ if (errs.length) errors.push(`Color metafields: ${errs.map(e => e.message).join('; ')}`);
+ else console.log(` color metafields set: ${colorMf.map(m => m.key).join(', ')}`);
+ await sleep(400);
+ }
+
+ // ---- 1b. SPEC metafields (separate batch). This store carries legacy
+ // custom.* metafield DEFINITIONS typed as product_reference (from an
+ // old CSV import — e.g. custom.finish, custom.material's siblings).
+ // A single type-incompatible key poisons the whole metafieldsSet
+ // batch, so we pre-check each spec key's existing definition type and
+ // SKIP any whose definition is incompatible with the text type we'd
+ // write — reported as a non-fatal warning, never blocks the row. ----
+ const specEntries = Object.entries(specs).filter(([, v]) => v != null && String(v).trim() !== '');
+ if (specEntries.length) {
+ const specMf = [];
+ for (const [k, v] of specEntries) {
+ const wantType = specType(k);
+ const defType = await definitionType(k); // null = no definition (free text OK)
+ if (defType && defType !== wantType) {
+ errors.push(`Spec ${k} skipped: existing definition is '${defType}', not '${wantType}'`);
+ console.log(` SKIP custom.${k}: existing definition is '${defType}' (incompatible)`);
+ continue;
+ }
+ // if a definition exists and matches, write with its type; else use wantType
+ specMf.push({ ownerId: PID, namespace: 'custom', key: k, value: String(v), type: defType || wantType });
+ }
+ if (specMf.length) {
+ const r = await gql({ query: MF_MUTATION, variables: { m: specMf } });
+ const errs = r?.data?.metafieldsSet?.userErrors || [];
+ if (errs.length) errors.push(`Spec metafields: ${errs.map(e => e.message).join('; ')}`);
+ else console.log(` spec metafields set: ${specMf.map(m => m.key).join(', ')}`);
+ await sleep(400);
+ }
+ }
+
+ // ---- 2. title normalization + style tags ----
+ let titleColor, pattern;
+ if (providedTitle) {
+ // caller gave an already-normalized title; still parse pattern/color for alt tag
+ const parsed = extractPatternAndColor(providedTitle);
+ pattern = parsed.pattern; titleColor = parsed.color || colorName;
+ } else {
+ const parsed = extractPatternAndColor(origTitle);
+ pattern = parsed.pattern;
+ titleColor = parsed.color || colorName;
+ }
+ const newTitle = providedTitle || buildTitle(pattern, titleColor, vendor);
+
+ // merge style tags + color into product tags (dedupe, case-insensitive)
+ const tagSet = new Map();
+ for (const t of existingTags) tagSet.set(t.toLowerCase(), t);
+ const wantTags = [colorName, ...styleTags].map(t => String(t).trim()).filter(Boolean);
+ for (const t of wantTags) if (!tagSet.has(t.toLowerCase())) tagSet.set(t.toLowerCase(), t);
+ const mergedTags = [...tagSet.values()];
+
+ const titleChanged = newTitle && newTitle !== origTitle;
+ const tagsChanged = mergedTags.length !== existingTags.length;
+ if (titleChanged || tagsChanged) {
+ const input = { id: PID };
+ if (titleChanged) input.title = newTitle;
+ if (tagsChanged) input.tags = mergedTags;
+ const r = await gql({ query: PRODUCT_UPDATE, variables: { i: input } });
+ const errs = r?.data?.productUpdate?.userErrors || [];
+ if (errs.length) errors.push(`Title/Tags: ${errs.map(e => e.message).join('; ')}`);
+ else console.log(` title${titleChanged ? `="${newTitle}"` : ' unchanged'}${tagsChanged ? ` tags=[${mergedTags.join(', ')}]` : ''}`);
+ await sleep(400);
+ } else { console.log(' title + tags already current'); }
+
+ // ---- 3. image alt tags: "{SKU} {Pattern} {Color} designer-wallcoverings-los-angeles" ----
+ const mediaEdges = p.media?.edges || [];
+ const altBase = `${sku} ${pattern} ${titleColor} designer-wallcoverings-los-angeles`.replace(/\s+/g, ' ').trim();
+ if (mediaEdges.length > 0) {
+ const updates = [];
+ for (let mi = 0; mi < mediaEdges.length; mi++) {
+ const node = mediaEdges[mi].node;
+ if (!node?.id) continue;
+ updates.push({ id: node.id, alt: mi === 0 ? altBase : `${altBase} ${mi + 1}` });
+ }
+ if (updates.length) {
+ const mediaJson = updates.map(m => `{id: "${m.id}", alt: "${m.alt.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"}`).join(', ');
+ const r = await gql({ query: `mutation { productUpdateMedia(productId: "${PID}", media: [${mediaJson}]) { media { ... on MediaImage { id } } mediaUserErrors { message } } }` });
+ const errs = r?.data?.productUpdateMedia?.mediaUserErrors || [];
+ if (errs.length) errors.push(`Alt: ${errs.map(e => e.message).join('; ')}`);
+ else console.log(` alt tags set on ${updates.length} image(s): "${altBase}"`);
+ await sleep(400);
+ }
+ } else { console.log(' no media to alt-tag'); }
+
+ // ---- 4. stamp dw_unified.shopify_products (idempotent) ----
+ // NOTE: full_monty_tags is an INTEGER column (a count, per existing schema) —
+ // the color + style tag STRINGS live on the Shopify product tags above. We
+ // stamp the count of tags applied so the column stays integer-valid.
+ const pg = new Client({ user: process.env.PGUSER || 'stevestudio2', database: process.env.PGDATABASE || 'dw_unified', host: process.env.PGHOST || '/tmp' });
+ let dbStamped = false;
+ try {
+ await pg.connect();
+ const tagCount = wantTags.length;
+ const res = await pg.query(
+ `UPDATE shopify_products
+ SET full_monty_at = now(),
+ fm_metafields_verified = 1,
+ fm_specs_at = now(),
+ fm_tags_at = now(),
+ full_monty_tags = $2
+ WHERE shopify_id = $1`,
+ [PID, tagCount]
+ );
+ if (res.rowCount > 0) { dbStamped = true; console.log(` DB stamped: full_monty_at=now, fm_metafields_verified=1, fm_specs_at=now, fm_tags_at=now, full_monty_tags=${tagCount} (rows=${res.rowCount})`); }
+ else { errors.push(`DB: no shopify_products row matched ${PID}`); console.log(` DB: WARNING no row matched ${PID}`); }
+ } catch (e) { errors.push(`DB: ${e.message}`); console.log(` DB: ERROR ${e.message}`); }
+ finally { try { await pg.end(); } catch {} }
+
+ // ---- 5. re-query to VERIFY metafields landed ----
+ await sleep(300);
+ const vr = await gql({ query: `{ product(id:"${PID}") { metafields(first:100){ edges{ node{ namespace key value } } } } }` });
+ const vmf = {};
+ for (const e of (vr?.data?.product?.metafields?.edges || [])) {
+ const { namespace, key, value } = e.node;
+ if (namespace === 'custom') vmf[key] = value;
+ }
+ const gotColor = vmf['dominant_color'];
+ const gotDetails = vmf['color_details'];
+ const colorOk = gotColor === colorName;
+ const detailsOk = colorDetails == null ? '(none provided)' : (gotDetails ? 'present' : 'MISSING');
+ console.log(`VERIFY ${PID} | custom.dominant_color="${gotColor || ''}" (${colorOk ? 'OK' : 'MISMATCH'}) | custom.dominant_hex="${vmf['dominant_hex'] || ''}" | custom.color_details=${detailsOk} | db_stamped=${dbStamped}`);
+
+ // Separate non-fatal warnings (spec keys skipped due to legacy product_reference
+ // definitions on this store) from real, blocking errors.
+ const warnings = errors.filter(e => /^Spec .* skipped/.test(e));
+ const fatal = errors.filter(e => !/^Spec .* skipped/.test(e));
+ if (warnings.length) console.log(`WARNINGS: ${warnings.join(' | ')}`);
+ if (fatal.length) { console.error(`USERERRORS: ${fatal.join(' | ')}`); process.exit(1); }
+ if (!colorOk) { console.error('FATAL: dominant_color verify mismatch'); process.exit(1); }
+ if (colorDetails != null && !gotDetails) { console.error('FATAL: color_details did not land'); process.exit(1); }
+ console.log('OK');
+}
+
+main().catch(e => { console.error(`FATAL: ${e.message}\n${e.stack}`); process.exit(1); });
← a780b03 Add last-20-day Full Monty backfill runner (1003 items, meta
·
back to Letsbegin
·
Add color_family controlled-vocab metafield from vendor moti 9b884d7 →