← back to Gracie Scraper

scripts/enrich.mjs

109 lines

#!/usr/bin/env node
/**
 * Gracie image enrichment — LOCAL qwen2.5vl:7b vision on Mac1 Ollama.
 * Reads gracie_catalog rows with an image_url, downloads the packshot,
 * asks qwen2.5vl for ai_colors (hex+name), color_hex (dominant), ai_styles,
 * ai_tags, and a clean 1-2 sentence description. Writes back to PG.
 * Cost: $0 (local Ollama). No paid API.
 *
 * Resumable: only enriches rows where ai_accepted_at IS NULL (or --all to redo).
 */
import { Client } from 'pg';

const OLLAMA = process.env.OLLAMA || 'http://192.168.1.133:11434';
const MODEL = 'qwen2.5vl:7b';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120 Safari/537.36';
const REDO = process.argv.includes('--all');
const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? parseInt(process.argv[i + 1], 10) : null; })();

// Sanity CDN lets us request a smaller render for faster vision — append ?w=768
function smallImg(url) {
  if (!url) return url;
  return url.includes('?') ? url : `${url}?w=768&q=75`;
}

async function fetchB64(url) {
  const res = await fetch(url, { headers: { 'User-Agent': UA } });
  if (!res.ok) throw new Error(`img HTTP ${res.status}`);
  const buf = Buffer.from(await res.arrayBuffer());
  return buf.toString('base64');
}

const PROMPT = `You are a luxury interior-design cataloguer looking at a hand-painted wallcovering panel image.
Return STRICT JSON only, no prose, with this exact shape:
{
 "colors": [{"name":"<color name e.g. Celadon>","hex":"#rrggbb"}, ... up to 5, ordered by prominence],
 "dominant_hex": "#rrggbb",
 "background_hex": "#rrggbb",
 "styles": ["<e.g. Chinoiserie, Scenic, Traditional>"],
 "tags": ["<motif/subject words e.g. flowering-tree, songbird, pagoda>"],
 "description": "<one or two elegant sentences describing the scene and palette>"
}
Use real color names an interior designer would use. Hex must be valid 6-digit. Do not include markdown fences.`;

function extractJson(txt) {
  if (!txt) return null;
  let s = txt.trim().replace(/^```json?/i, '').replace(/```$/,'').trim();
  const a = s.indexOf('{'), b = s.lastIndexOf('}');
  if (a === -1 || b === -1) return null;
  try { return JSON.parse(s.slice(a, b + 1)); } catch { return null; }
}

async function enrichOne(imgUrl) {
  const b64 = await fetchB64(smallImg(imgUrl));
  const res = await fetch(`${OLLAMA}/api/generate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: MODEL, prompt: PROMPT, images: [b64], stream: false,
      options: { temperature: 0.2 }, format: 'json',
    }),
  });
  if (!res.ok) throw new Error(`ollama HTTP ${res.status}`);
  const j = await res.json();
  return extractJson(j.response);
}

async function main() {
  const pg = new Client({ connectionString: process.env.PG || 'postgresql://localhost/dw_unified' });
  await pg.connect();
  const where = REDO ? 'image_url IS NOT NULL' : 'image_url IS NOT NULL AND ai_accepted_at IS NULL';
  const { rows } = await pg.query(
    `SELECT id, mfr_sku, image_url FROM gracie_catalog WHERE ${where} ORDER BY id ${LIMIT ? `LIMIT ${LIMIT}` : ''}`
  );
  console.log(`[enrich] ${rows.length} rows to enrich via ${MODEL} @ ${OLLAMA}. Cost: $0 (local)`);
  let ok = 0, fail = 0;
  for (const r of rows) {
    try {
      const e = await enrichOne(r.image_url);
      if (!e || !Array.isArray(e.colors)) { fail++; console.warn(`  ✗ ${r.mfr_sku}: no JSON`); continue; }
      const hex = (e.dominant_hex && /^#[0-9a-fA-F]{6}$/.test(e.dominant_hex)) ? e.dominant_hex.toLowerCase() : null;
      await pg.query(
        `UPDATE gracie_catalog SET
           ai_colors=$2, color_hex=COALESCE($3,color_hex), dominant_color_hex=COALESCE($3,dominant_color_hex),
           ai_background_color=$4, ai_styles=$5, ai_tags=COALESCE($6, ai_tags),
           ai_description=$7, ai_accepted_at=NOW(), agent_updated_at=NOW()
         WHERE id=$1`,
        [
          r.id,
          JSON.stringify(e.colors || []),
          hex,
          (e.background_hex && /^#[0-9a-fA-F]{6}$/.test(e.background_hex)) ? e.background_hex.toLowerCase() : null,
          JSON.stringify(e.styles || []),
          Array.isArray(e.tags) && e.tags.length ? JSON.stringify(e.tags) : null,
          e.description || null,
        ]
      );
      ok++;
      if (ok % 10 === 0) console.log(`  … ${ok}/${rows.length}`);
    } catch (err) {
      fail++;
      console.warn(`  ✗ ${r.mfr_sku}: ${err.message}`);
    }
  }
  console.log(`[enrich] DONE. ok=${ok} fail=${fail}. Cost: $0 (local)`);
  await pg.end();
}

main().catch((e) => { console.error('[enrich] FATAL', e); process.exit(1); });