← back to Crezana Scraper

enrich.mjs

86 lines

#!/usr/bin/env node
// Enrich crezana_catalog rows with qwen2.5vl:7b vision on Mac1 Ollama. $0 local.
// Fills: ai_colors (hex+name), color_hex, dominant_color_hex, ai_styles, ai_tags, description.
import pg from 'pg';

const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
const MODEL = 'qwen2.5vl:7b';
const PGHOST = process.env.PGHOST || '/tmp';
const CONCURRENCY = 3;

const PROMPT = `You are a textile/fabric merchandiser. Look at this fabric or textile swatch image and return STRICT JSON only, no prose:
{
 "description": "one vivid sentence (max 30 words) describing this fabric — texture, motif, mood",
 "colors": [{"name":"color name","hex":"#RRGGBB"}, ...up to 4, ordered by dominance],
 "dominant_hex": "#RRGGBB",
 "styles": ["style tag", ...up to 3 e.g. Modern, Traditional, Damask, Floral, Geometric, Textured, Embroidered],
 "tags": ["descriptive tag", ...up to 6]
}`;

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

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

const HEX = /^#[0-9a-fA-F]{6}$/;
function cleanHex(h) { return (typeof h === 'string' && HEX.test(h.trim())) ? h.trim().toLowerCase() : null; }

async function main() {
  const onlyNew = process.argv.includes('--new');
  const client = new pg.Client({ host: PGHOST, database: 'dw_unified' });
  await client.connect();
  const where = onlyNew ? "AND ai_accepted_at IS NULL" : "";
  const { rows } = await client.query(
    `SELECT id, mfr_sku, image_url FROM crezana_catalog WHERE image_url IS NOT NULL ${where} ORDER BY id`);
  console.log(`Enriching ${rows.length} rows with ${MODEL} (concurrency ${CONCURRENCY}) — $0 (local)`);

  let done = 0, ok = 0, fail = 0;
  const queue = [...rows];
  async function worker() {
    while (queue.length) {
      const row = queue.shift();
      try {
        // request a reasonably sized render from the isteam CDN
        const url = row.image_url + (row.image_url.includes('/:/') ? '' : '/:/rs=w:600');
        const b64 = await fetchImageB64(url);
        const v = await visionCall(b64);
        const colors = Array.isArray(v.colors) ? v.colors.filter(c => c && c.name).map(c => ({ name: String(c.name).slice(0,40), hex: cleanHex(c.hex) })) : [];
        const domHex = cleanHex(v.dominant_hex) || (colors[0] && colors[0].hex) || null;
        const styles = Array.isArray(v.styles) ? v.styles.map(s => String(s).slice(0,40)).slice(0,3) : [];
        const tags = Array.isArray(v.tags) ? v.tags.map(t => String(t).slice(0,40)).slice(0,6) : [];
        const desc = typeof v.description === 'string' ? v.description.slice(0, 300) : null;
        await client.query(
          `UPDATE crezana_catalog SET
             ai_colors=$1, color_hex=$2, dominant_color_hex=$2,
             ai_styles=$3, ai_tags=$4, description=COALESCE($5, description), ai_description=$5,
             ai_accepted_at=now(), updated_at=now()
           WHERE id=$6`,
          [JSON.stringify(colors), domHex, JSON.stringify(styles), JSON.stringify(tags), desc, row.id]);
        ok++;
      } catch (e) {
        fail++;
        if (fail <= 5) console.error(`  ! ${row.mfr_sku}: ${e.message}`);
      }
      done++;
      if (done % 25 === 0) console.log(`  ${done}/${rows.length} (ok=${ok} fail=${fail})`);
    }
  }
  await Promise.all(Array.from({ length: CONCURRENCY }, worker));
  await client.end();
  console.log(`Done. ok=${ok} fail=${fail}. Cost: $0 (local)`);
}
main().catch(e => { console.error(e); process.exit(1); });