← back to Designer Wallcoverings

enrich-arte-puppeteer.js

165 lines

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

const GEMINI_KEY = '${GOOGLE_API_KEY}';
const GEMINI_MODEL = 'gemini-3.5-flash';
const PG_URL = (process.env.DATABASE_URL || 'postgresql://dw_admin@127.0.0.1:5432/dw_unified');

const ARTE_PRODUCTS = [
  { vc_id: 123, sku: '19433', name: 'Horus Desert Sun', url: 'https://www.arte-international.com/en/collections/essentials-luxor/horus/19433' },
  { vc_id: 16,  sku: '23401', name: 'Amarna Bister', url: 'https://www.arte-international.com/en/collections/memphis/amarna/23401' },
  { vc_id: 15,  sku: '23412', name: 'Sakkara Antique Gold', url: 'https://www.arte-international.com/en/collections/memphis/sakkara/23412' },
  { vc_id: 172, sku: '23413', name: 'Sakkara Off-white', url: 'https://www.arte-international.com/en/collections/memphis/sakkara/23413' },
  { vc_id: 72,  sku: '23421', name: 'Ibis Biscuit', url: 'https://www.arte-international.com/en/collections/memphis/ibis/23421' },
  { vc_id: 75,  sku: '23422', name: 'Ibis Chestnut', url: 'https://www.arte-international.com/en/collections/memphis/ibis/23422' },
  { vc_id: 78,  sku: '23431', name: 'Siwa Bone', url: 'https://www.arte-international.com/en/collections/memphis/siwa/23431' },
  { vc_id: 83,  sku: '23433', name: 'Siwa Biscuit', url: 'https://www.arte-international.com/en/collections/memphis/siwa/23433' },
  { vc_id: 183, sku: '29221', name: 'Galon Grey Linen', url: 'https://www.arte-international.com/en/collections/allures/galon/29221' },
];

const PROMPT = `You are an interior design color analyst for luxury wallcoverings.

Analyze this wallcovering image and return a JSON object with these fields:
- "colors": array of 3-6 prominent colors visible (e.g. ["Gold", "Ivory", "Charcoal"])
- "backgroundColor": the single dominant background color (e.g. "Ivory")
- "styles": array of 1-3 design styles (e.g. ["Contemporary", "Art Deco"])
- "patterns": array of 1-2 pattern types (e.g. ["Geometric", "Textured"])
- "tags": array of 4-8 interior design tags (e.g. ["luxury", "metallic", "hospitality", "accent wall"])
- "description": a 2-sentence commercial description for an interior designer audience. NEVER use the word "wallpaper" — always say "wallcovering".

Return ONLY the JSON object, no markdown fences, no explanation.`;

function callGemini(imageBase64, mimeType) {
  return new Promise((resolve, reject) => {
    const body = JSON.stringify({
      contents: [{ parts: [
        { text: PROMPT },
        { inline_data: { mime_type: mimeType || 'image/jpeg', data: imageBase64 } }
      ]}],
      generationConfig: { temperature: 0.3, maxOutputTokens: 1024, thinkingConfig: { thinkingBudget: 0 } }
    });
    const url = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`);
    const opts = { method: 'POST', hostname: url.hostname, path: url.pathname + url.search, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } };
    const req = https.request(opts, (res) => {
      const chunks = [];
      res.on('data', c => chunks.push(c));
      res.on('end', () => {
        try {
          const data = JSON.parse(Buffer.concat(chunks).toString());
          if (data.error) return reject(new Error(data.error.message));
          let text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
          text = text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
          resolve(JSON.parse(text));
        } catch (e) { reject(e); }
      });
    });
    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

async function main() {
  const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] });
  const db = new Client({ connectionString: PG_URL });
  await db.connect();
  
  let success = 0, failed = 0;
  
  for (const p of ARTE_PRODUCTS) {
    const page = await browser.newPage();
    try {
      console.log(`[arte] Navigating to ${p.sku} — ${p.name}...`);
      await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36');
      await page.goto(p.url, { waitUntil: 'networkidle2', timeout: 30000 });
      await sleep(2000);
      
      // Find the packshot/product image on the page
      const imgSrc = await page.evaluate(() => {
        // Look for the main product image - Arte uses various selectors
        const selectors = [
          'img[src*="Packshot"]',
          'img[src*="packshot"]', 
          'img[src*="Flatshot"]',
          '.product-image img',
          '.product-detail img',
          '[class*="product"] img[src*="arte-international"]',
          'img[src*="edge.arte-international.com"]'
        ];
        for (const sel of selectors) {
          const img = document.querySelector(sel);
          if (img && img.src) return img.src;
        }
        // Fallback: find the largest image on page with arte domain
        const allImgs = Array.from(document.querySelectorAll('img[src*="arte-international"]'));
        if (allImgs.length) {
          return allImgs.sort((a, b) => (b.naturalWidth || 0) - (a.naturalWidth || 0))[0]?.src;
        }
        return null;
      });
      
      if (!imgSrc) {
        // Fallback: take a screenshot of the product area
        console.log(`  No image found, taking page screenshot...`);
        const screenshot = await page.screenshot({ type: 'jpeg', quality: 85 });
        const ai = await callGemini(screenshot.toString('base64'), 'image/jpeg');
        await saveToDb(db, p, ai);
        success++;
        console.log(`  ✅ (screenshot) Colors: ${(ai.colors||[]).join(', ')}`);
      } else {
        console.log(`  Found image: ${imgSrc.substring(0, 80)}...`);
        // Fetch the image through puppeteer's page context (inherits cookies/referer)
        const imgB64 = await page.evaluate(async (src) => {
          const resp = await fetch(src);
          const blob = await resp.blob();
          return new Promise((resolve) => {
            const reader = new FileReader();
            reader.onloadend = () => resolve(reader.result.split(',')[1]);
            reader.readAsDataURL(blob);
          });
        }, imgSrc);
        
        const ai = await callGemini(imgB64, 'image/jpeg');
        await saveToDb(db, p, ai);
        success++;
        console.log(`  ✅ Colors: ${(ai.colors||[]).join(', ')} | BG: ${ai.backgroundColor}`);
      }
      await sleep(1500);
    } catch (err) {
      console.error(`  ❌ FAILED ${p.sku}: ${err.message}`);
      failed++;
    } finally {
      await page.close();
    }
  }
  
  await browser.close();
  await db.end();
  console.log(`\nDone: ${success} enriched, ${failed} failed out of ${ARTE_PRODUCTS.length}`);
}

async function saveToDb(db, p, ai) {
  const colorsStr = (ai.colors || []).join(', ');
  const stylesStr = (ai.styles || []).join(', ');
  const patternsStr = (ai.patterns || []).join(', ');
  const tagsStr = (ai.tags || []).join(', ');
  
  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.vc_id]);
  
  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, ai_accepted_at = NOW() 
    WHERE arte_sku = $7`,
    [JSON.stringify(ai.colors), ai.backgroundColor, JSON.stringify(ai.styles), 
     JSON.stringify(ai.patterns), JSON.stringify(ai.tags), ai.description, p.sku]);
}

main().catch(console.error);