← back to Letsbegin
enrich-write.js
338 lines
#!/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); }
// HARD GUARD: sandbox store only (matches rw-cost-write.js / rw-material-options.js).
if (STORE !== 'designer-laboratory-sandbox.myshopify.com') {
console.error(`FATAL: refusing to write to non-sandbox store "${STORE}"`); 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); });