← back to Designerwallcoverings
scripts/artmura-onboard/interior-designer-enrich.js
151 lines
#!/usr/bin/env node
/**
* Interior-Designer enrichment — per-SKU style tags, color tags, and color HEX.
*
* For each product: fetch its image, run Gemini 2.5 Flash vision as an interior
* designer, and write the result to Shopify:
* metafields (namespace "custom"):
* style_tags list.single_line_text_field ["Natural","Textured",...]
* color_tags list.single_line_text_field ["Taupe","Greige",...]
* color_hex list.single_line_text_field ["#726B5B","#7C7667",...]
* dominant_hex single_line_text_field "#726B5B"
* pattern_desc single_line_text_field "vertical ribbed stripes"
* + merges style_tags + color_tags into the product's tags (additive).
*
* Targets the newest N products (Shopify created_at desc) across all vendors,
* or a single vendor with --vendor. Idempotent: skips products that already
* have custom.dominant_hex unless --force. thinkingBudget:0 to keep cost low.
*
* node interior-designer-enrich.js # dry-run, newest 1000
* node interior-designer-enrich.js --limit=5 --apply # canary
* node interior-designer-enrich.js --limit=1000 --apply # backfill
* node interior-designer-enrich.js --vendor="Tres Tintas" --apply
* flags: --budget=2.00 (USD cap, default 3), --force
*/
const SHOP = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const GKEY = process.env.GEMINI_API_KEY;
const EP = `https://${SHOP}/admin/api/2024-10/graphql.json`;
const GEP = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GKEY}`;
const APPLY = process.argv.includes('--apply');
const FORCE = process.argv.includes('--force');
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '1000', 10);
const BUDGET = parseFloat((process.argv.find(a => a.startsWith('--budget=')) || '').split('=')[1] || '3');
const VENDOR = (process.argv.find(a => a.startsWith('--vendor=')) || '').split('=')[1] || null;
// gemini-2.5-flash pricing (USD / 1M tokens)
const PRICE_IN = 0.30, PRICE_OUT = 2.50;
let spend = 0, calls = 0;
if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
if (!GKEY) { console.error('FATAL: set GEMINI_API_KEY'); process.exit(1); }
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function gql(query, variables = {}) {
for (let a = 0; a < 4; a++) {
try {
const res = await fetch(EP, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
const j = await res.json();
if (j.errors) { if (a === 3) throw new Error(JSON.stringify(j.errors)); await sleep(900 * (a + 1)); continue; }
const rem = j.extensions?.cost?.throttleStatus?.currentlyAvailable;
if (rem !== undefined && rem < 250) await sleep(600);
return j.data;
} catch (e) { if (a === 3) throw e; await sleep(900 * (a + 1)); }
}
}
const PROMPT = 'You are a senior interior designer cataloguing a wallcovering. Analyze the image and return ONLY compact JSON: {"style_tags":[3-5 design-style adjectives],"color_tags":[SINGULAR color family names, e.g. "brown" never "browns", "orange" never "oranges"],"colors":[{"name":"..","hex":"#RRGGBB"}],"dominant_hex":"#RRGGBB","pattern":"short phrase"}. Hex must be valid 6-digit. All color_tags MUST be singular. No prose.';
async function enrich(imgUrl) {
const ir = await fetch(imgUrl); if (!ir.ok) throw new Error('img ' + ir.status);
const buf = Buffer.from(await ir.arrayBuffer());
const mime = imgUrl.match(/\.png(\?|$)/i) ? 'image/png' : 'image/jpeg';
// LOCAL-FIRST cost-saver (2026-07-03): free ollama qwen2.5vl before paid Gemini. Force Gemini with ENRICH_GEMINI_ONLY=1.
if (process.env.ENRICH_GEMINI_ONLY !== '1') {
try {
const lr = await fetch('http://127.0.0.1:11434/api/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: process.env.OLLAMA_VISION_MODEL || 'qwen2.5vl:7b', prompt: PROMPT,
images: [buf.toString('base64')], format: 'json', stream: false, options: { temperature: 0.2 } }),
signal: AbortSignal.timeout(90000) });
if (lr.ok) { const lj = await lr.json(); return JSON.parse((lj.response || '').replace(/^```json\s*|\s*```$/g, '').trim()); }
} catch (_) { /* fall through to Gemini */ }
}
const body = { contents: [{ parts: [{ text: PROMPT }, { inline_data: { mime_type: mime, data: buf.toString('base64') } }] }],
generationConfig: { temperature: 0.2, responseMimeType: 'application/json', thinkingConfig: { thinkingBudget: 0 } } };
for (let a = 0; a < 3; a++) {
const r = await fetch(GEP, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (r.status === 429) { await sleep(2000 * (a + 1)); continue; }
const j = await r.json();
const u = j.usageMetadata || {};
spend += ((u.promptTokenCount || 0) * PRICE_IN + (u.candidatesTokenCount || 0) * PRICE_OUT) / 1e6;
calls++;
const t = j?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!t) throw new Error('no text: ' + JSON.stringify(j).slice(0, 160));
return JSON.parse(t);
}
throw new Error('gemini 429 after retries');
}
const M_META = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{field message} } }`;
const M_TAGS = `mutation($id:ID!,$tags:[String!]!){ tagsAdd(id:$id, tags:$tags){ userErrors{message} } }`;
async function writeProduct(p, d) {
const errs = [];
const hexes = (d.colors || []).map(c => c.hex).filter(h => /^#[0-9a-fA-F]{6}$/.test(h));
const S = 'single_line_text_field';
const mf = [
{ ownerId: p.id, namespace: 'custom', key: 'style_tags', type: S, value: (d.style_tags || []).map(String).join(', ') },
{ ownerId: p.id, namespace: 'custom', key: 'color_tags', type: S, value: (d.color_tags || []).map(String).join(', ') },
{ ownerId: p.id, namespace: 'custom', key: 'color_hex', type: S, value: hexes.join(', ') },
{ ownerId: p.id, namespace: 'custom', key: 'dominant_hex', type: S, value: String(d.dominant_hex || hexes[0] || '') },
{ ownerId: p.id, namespace: 'custom', key: 'pattern_desc', type: S, value: String(d.pattern || '') },
].filter(m => m.value && m.value.trim());
const r1 = await gql(M_META, { mf });
(r1.metafieldsSet?.userErrors || []).forEach(e => errs.push('mf:' + e.message));
const newTags = [...(d.style_tags || []), ...(d.color_tags || [])].map(String).map(s => s.trim()).filter(Boolean);
if (newTags.length) {
const r2 = await gql(M_TAGS, { id: p.id, tags: newTags });
(r2.tagsAdd?.userErrors || []).forEach(e => errs.push('tag:' + e.message));
}
return errs;
}
async function collect() {
// single-quote the vendor phrase (double quotes would terminate the GraphQL string literal);
// a --vendor run enriches ALL of that vendor regardless of status (Innovations is discontinued
// but we still want colors/styles/hex on every SKU). No --vendor -> newest active only.
const q = VENDOR ? `vendor:'${VENDOR}'` : `status:active`;
let cursor = null, all = [];
while (all.length < LIMIT) {
const d = await gql(`query($c:String){ products(first:100, sortKey:CREATED_AT, reverse:true, query:"${q}", after:$c){ pageInfo{hasNextPage endCursor} edges{node{ id title vendor featuredImage{url} dominant:metafield(namespace:"custom",key:"dominant_hex"){value} }} } }`, { c: cursor });
d.products.edges.forEach(e => all.push(e.node));
if (!d.products.pageInfo.hasNextPage) break;
cursor = d.products.pageInfo.endCursor;
}
return all.slice(0, LIMIT);
}
(async () => {
const rows = await collect();
const targets = rows.filter(p => p.featuredImage?.url && (FORCE || !p.dominant?.value));
const skipNoImg = rows.filter(p => !p.featuredImage?.url).length;
const skipDone = rows.filter(p => p.featuredImage?.url && !FORCE && p.dominant?.value).length;
console.log(`enrich: scanned ${rows.length} newest active${VENDOR ? ' [' + VENDOR + ']' : ''} · to-do ${targets.length} · skip(done) ${skipDone} · skip(no-img) ${skipNoImg} · budget $${BUDGET} · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
if (!APPLY) { console.log('sample:', targets.slice(0, 3).map(t => `${t.vendor}/${t.title.slice(0, 30)}`).join(' | ')); console.log('DRY-RUN. --apply to execute.'); return; }
let ok = 0, err = 0;
for (const p of targets) {
if (spend >= BUDGET) { console.log(`\nBUDGET CAP $${BUDGET} reached after ${ok} done ($${spend.toFixed(4)}). Stopping.`); break; }
try {
const d = await enrich(p.featuredImage.url);
const we = await writeProduct(p, d);
if (we.length) { err++; console.error(` ⚠ ${p.title.slice(0, 36)}: ${we.slice(0, 2).join('|')}`); }
else ok++;
if ((ok + err) % 25 === 0) console.log(` ...${ok + err}/${targets.length} (ok=${ok} err=${err}) $${spend.toFixed(4)} (${calls} calls)`);
await sleep(120);
} catch (e) { err++; console.error(` ERR ${p.title.slice(0, 36)}: ${e.message.slice(0, 120)}`); await sleep(400); }
}
console.log(`\nDONE. enriched=${ok} errors=${err} · gemini calls=${calls} · spend=$${spend.toFixed(4)}`);
})().catch(e => { console.error(e); process.exit(1); });