← back to Dw Pairs Well

scripts/backfill-palette.js

123 lines

#!/usr/bin/env node
/**
 * Gemini palette backfill for shopify_products without enrichment.
 *
 * Pulls ACTIVE+image products missing shopify_color_enrichment rows,
 * calls Gemini 2.0 Flash on the image, extracts dominant_hex + hex_codes,
 * inserts into shopify_color_enrichment (status='backfill').
 *
 * Rate-limited to 20 rpm to stay well under Gemini free tier limits.
 *
 * Usage:
 *   GEMINI_API_KEY=xxx DATABASE_URL=postgres://... node scripts/backfill-palette.js [--limit 100] [--dry-run]
 */
require('dotenv').config();
const { Pool } = require('pg');

const LIMIT = parseInt(process.argv.find(a => a.startsWith('--limit='))?.split('=')[1] || '5500', 10);
const DRY = process.argv.includes('--dry-run');
const SLEEP_MS = parseInt(process.env.SLEEP_MS || '3500', 10);   // ~17 rpm
const GEMINI_KEY = process.env.GEMINI_API_KEY;
const MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash';

if (!GEMINI_KEY && !DRY) {
  console.error('FATAL: GEMINI_API_KEY missing. Set it or run --dry-run');
  process.exit(1);
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const PROMPT = `Look at this wallcovering pattern image. Return ONLY a JSON object with two fields:
{
  "dominant_hex": "#RRGGBB",
  "hex_codes": ["#RRGGBB", "#RRGGBB", "#RRGGBB", "#RRGGBB", "#RRGGBB"]
}
- dominant_hex: the single most prevalent color
- hex_codes: 3-6 colors representing the full palette, ordered by visual prominence
Return JSON ONLY, no markdown, no commentary.`;

async function fetchTargets(limit) {
  const r = await pool.query(`
    SELECT sp.shopify_id, sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url
    FROM shopify_products sp
    LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
    WHERE sp.status='ACTIVE'
      AND sp.image_url IS NOT NULL
      AND sce.shopify_id IS NULL
    ORDER BY sp.synced_at DESC NULLS LAST
    LIMIT $1
  `, [limit]);
  return r.rows;
}

async function geminiPalette(imageUrl) {
  // Download the image as base64
  const imgRes = await fetch(imageUrl);
  if (!imgRes.ok) throw new Error(`image fetch ${imgRes.status}`);
  const buf = Buffer.from(await imgRes.arrayBuffer());
  const mime = imgRes.headers.get('content-type') || 'image/jpeg';
  const b64 = buf.toString('base64');

  const body = {
    contents: [{
      parts: [
        { text: PROMPT },
        { inline_data: { mime_type: mime, data: b64 } }
      ]
    }],
    generationConfig: { temperature: 0, response_mime_type: 'application/json' }
  };
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${GEMINI_KEY}`;
  const r = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  });
  if (!r.ok) throw new Error(`gemini ${r.status} ${await r.text()}`);
  const j = await r.json();
  const text = j.candidates?.[0]?.content?.parts?.[0]?.text || '';
  const parsed = JSON.parse(text);
  return parsed;
}

async function insert(row, palette) {
  if (DRY) {
    console.log(`  [DRY] ${row.dw_sku} → ${palette.dominant_hex} + ${(palette.hex_codes||[]).length} codes`);
    return;
  }
  await pool.query(`
    INSERT INTO shopify_color_enrichment
      (shopify_id, handle, title, vendor, image_url, dominant_hex, hex_codes, status, gemini_model, enrichment_date)
    VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,'backfill',$8,now())
    ON CONFLICT (shopify_id) DO UPDATE SET
      dominant_hex = EXCLUDED.dominant_hex,
      hex_codes    = EXCLUDED.hex_codes,
      status       = 'backfill',
      enrichment_date = now()
  `, [row.shopify_id, row.handle, row.title, row.vendor, row.image_url,
      palette.dominant_hex, JSON.stringify(palette.hex_codes || []), MODEL]);
}

(async () => {
  console.log(`backfill-palette: target ${LIMIT} products  model=${MODEL}  sleep=${SLEEP_MS}ms`);
  const rows = await fetchTargets(LIMIT);
  console.log(`fetched ${rows.length} candidates`);
  let ok=0, err=0, skip=0;
  for (let i=0; i<rows.length; i++) {
    const row = rows[i];
    if (!row.image_url) { skip++; continue; }
    try {
      const pal = await geminiPalette(row.image_url);
      if (!pal?.dominant_hex) throw new Error('no dominant_hex');
      await insert(row, pal);
      ok++;
      if (ok % 25 === 0) console.log(`  ${i+1}/${rows.length}  ok=${ok}  err=${err}  ${row.dw_sku} → ${pal.dominant_hex}`);
    } catch (e) {
      err++;
      console.error(`  ${row.dw_sku} FAIL: ${e.message}`);
    }
    await new Promise(r => setTimeout(r, SLEEP_MS));
  }
  console.log(`DONE  ok=${ok}  err=${err}  skip=${skip}  total=${rows.length}`);
  await pool.end();
})().catch(e => { console.error('FATAL', e); process.exit(1); });