← back to Designer Wallcoverings

gemini-enrich-gaps.js

176 lines

const https = require('https');
const http = require('http');
const { Client } = require('pg');

const GEMINI_KEY = '${GOOGLE_API_KEY}';
const GEMINI_MODEL = 'gemini-3.5-flash';
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`;

const DB = (process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified');

// Products to process — using arte_catalog packshot images for Arte, vendor_catalog images for Black Edition
const products = [
  // Arte (9) — packshot images from arte_catalog
  { id: 123, vendor: 'arte', sku: '19433', name: 'Horus Desert Sun', img: 'https://edge.arte-international.com/media/a0686a22-83f6-46a5-86fe-b17566b88245/conversions/EssentialsLuxor_Horus_19433_Packshot_Web_LR-thumb-two-thirds.jpg' },
  { id: 16,  vendor: 'arte', sku: '23401', name: 'Amarna Bister', img: 'https://edge.arte-international.com/media/d4610242-66e0-4b12-8723-df90ddb40e04/conversions/Memphis_Amarna_23401_Packshot_Web_HR-thumb-two-thirds.jpg' },
  { id: 15,  vendor: 'arte', sku: '23412', name: 'Sakkara Antique Gold', img: 'https://edge.arte-international.com/media/6fe8a7ec-ecdd-49c4-b398-223293c48eed/conversions/Memphis_Sakkara_23412_Packshot_Web_HR-thumb-two-thirds.jpg' },
  { id: 172, vendor: 'arte', sku: '23413', name: 'Sakkara Off-white', img: 'https://edge.arte-international.com/media/a45badcf-d133-44b1-bcd6-1d74b4f13141/conversions/Memphis_Sakkara_23413_Packshot_Web_HR-thumb-two-thirds.jpg' },
  { id: 72,  vendor: 'arte', sku: '23421', name: 'Ibis Biscuit', img: 'https://edge.arte-international.com/media/fb1738af-4e34-4d36-9a3e-7392f590381a/conversions/Memphis_Ibis_23421_Packshot_Web_LR-thumb-two-thirds.jpg' },
  { id: 75,  vendor: 'arte', sku: '23422', name: 'Ibis Chestnut', img: 'https://edge.arte-international.com/media/2899330e-abba-481e-9098-ffd76f1731cf/conversions/Memphis_Ibis_23422_Packshot_Web_LR-thumb-two-thirds.jpg' },
  { id: 78,  vendor: 'arte', sku: '23431', name: 'Siwa Bone', img: 'https://edge.arte-international.com/media/2ef17968-181d-4da5-8c8a-59f719a3c0fd/conversions/Memphis_Siwa_23431_Packshot_Web_LR-thumb-two-thirds.jpg' },
  { id: 83,  vendor: 'arte', sku: '23433', name: 'Siwa Biscuit', img: 'https://edge.arte-international.com/media/fac5d958-0b0e-437f-abbe-a07a2c147676/conversions/Memphis_Siwa_23433_Packshot_Web_LR-thumb-two-thirds.jpg' },
  { id: 183, vendor: 'arte', sku: '29221', name: 'Galon Grey Linen', img: 'https://edge.arte-international.com/media/f92ad97a-a59b-431c-84ad-0a7520995f6a/conversions/Allures_Galon_29221_Flatshot_Web_LR-thumb-two-thirds.jpg' },
  // Black Edition (4)
  { id: 1751, vendor: 'black_edition', sku: 'W924/01', name: 'Mizumi Panel Carbon', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-01FP-mizumi-panel-charcoal_00.jpg' },
  { id: 1752, vendor: 'black_edition', sku: 'W924/02', name: 'Mizumi Panel Midnight', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-02FP-mizumi-panel-midnight_00.jpg' },
  { id: 1753, vendor: 'black_edition', sku: 'W924/03', name: 'Mizumi Panel Basalt', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-03FP-mizumi-panel-stone_00.jpg' },
  { id: 1754, vendor: 'black_edition', sku: 'W924/04', name: 'Mizumi Panel Viridian', img: 'https://static.theromogroup.com/rb/cache/image/720x720/catalog/product/W/9/W924-04FP-mizumi-panel-teal_02.jpg' },
];

const PROMPT = `You are an interior design wallcovering analyst. Analyze this wallcovering product image and return ONLY a JSON object (no markdown, no code fences) with these fields:

{
  "colors": ["<list 3-6 dominant colors you actually see, e.g. Warm Gold, Ivory, Charcoal>"],
  "backgroundColor": "<the single most dominant background color>",
  "styles": ["<1-3 design styles, e.g. Contemporary, Art Deco, Mid-Century Modern>"],
  "patterns": ["<1-3 pattern types, e.g. Geometric, Textured, Floral, Abstract>"],
  "tags": ["<5-10 interior design tags for search, e.g. luxury, metallic, neutral, organic, minimalist>"],
  "description": "<2 sentences describing the wallcovering for a commercial buyer>"
}

Product: PRODUCT_NAME
Return ONLY the JSON object.`;

function fetchJSON(url, body) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const parsed = new URL(url);
    const req = https.request({
      hostname: parsed.hostname,
      path: parsed.pathname + parsed.search,
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
    }, (res) => {
      let chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => {
        try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
        catch(e) { reject(new Error('JSON parse error: ' + Buffer.concat(chunks).toString().slice(0, 500))); }
      });
    });
    req.on('error', reject);
    req.write(data);
    req.end();
  });
}

async function analyzeWithGemini(product) {
  const prompt = PROMPT.replace('PRODUCT_NAME', product.name);
  const body = {
    contents: [{
      parts: [
        { text: prompt },
        { inlineData: undefined },
        { fileData: undefined }
      ]
    }],
    generationConfig: { temperature: 0.2, maxOutputTokens: 1024, thinkingConfig: { thinkingBudget: 0 } }
  };
  // Use image URL reference
  body.contents[0].parts = [
    { text: prompt },
    { text: `Image URL for reference: ${product.img}` }
  ];
  
  // Actually fetch the image and send inline
  const imgBuf = await fetchImage(product.img);
  if (imgBuf) {
    body.contents[0].parts = [
      { text: prompt },
      { inlineData: { mimeType: 'image/jpeg', data: imgBuf.toString('base64') } }
    ];
  }

  const resp = await fetchJSON(GEMINI_URL, body);
  if (resp.error) throw new Error(resp.error.message);
  
  const text = resp.candidates?.[0]?.content?.parts?.[0]?.text || '';
  // Strip markdown fences if present
  const clean = text.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
  return JSON.parse(clean);
}

function fetchImage(url) {
  return new Promise((resolve) => {
    const mod = url.startsWith('https') ? https : http;
    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        return fetchImage(res.headers.location).then(resolve);
      }
      if (res.statusCode !== 200) { resolve(null); return; }
      let chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => resolve(Buffer.concat(chunks)));
    }).on('error', () => resolve(null));
  });
}

async function main() {
  const db = new Client({ connectionString: DB });
  await db.connect();
  
  let success = 0, failed = 0;
  
  for (const p of products) {
    try {
      console.log(`[${p.vendor}] Analyzing ${p.sku} - ${p.name}...`);
      const ai = await analyzeWithGemini(p);
      
      const colorsStr = (ai.colors || []).join(', ');
      const tagsStr = (ai.tags || []).join(', ');
      const stylesStr = (ai.styles || []).join(', ');
      const patternsStr = (ai.patterns || []).join(', ');
      
      // Update vendor_catalog
      await db.query(`
        UPDATE vendor_catalog SET
          ai_colors = $1,
          ai_background_color = $2,
          ai_styles = $3,
          ai_patterns = $4,
          ai_tags = $5,
          ai_description = $6
        WHERE id = $7
      `, [colorsStr, ai.backgroundColor, stylesStr, patternsStr, tagsStr, ai.description, p.id]);
      
      // Also update arte_catalog if Arte vendor
      if (p.vendor === 'arte') {
        await db.query(`
          UPDATE arte_catalog SET
            ai_colors = $1,
            ai_background_color = $2,
            ai_styles = $3,
            ai_patterns = $4,
            ai_tags = $5,
            ai_description = $6
          WHERE arte_sku = $7
        `, [colorsStr, ai.backgroundColor, stylesStr, patternsStr, tagsStr, ai.description, p.sku]);
      }
      
      console.log(`  ✅ ${p.sku}: colors=[${colorsStr}] bg=${ai.backgroundColor}`);
      success++;
      
      // Rate limit: ~300ms between calls
      await new Promise(r => setTimeout(r, 300));
    } catch (err) {
      console.error(`  ❌ ${p.sku}: ${err.message}`);
      failed++;
    }
  }
  
  console.log(`\nDone: ${success} succeeded, ${failed} failed out of ${products.length}`);
  await db.end();
}

main().catch(e => { console.error(e); process.exit(1); });