← 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); });