← back to Fromental Internal
scripts/enrich-fromental.js
129 lines
#!/usr/bin/env node
/**
* enrich-fromental.js — LOCAL ($0) Ollama enrichment of fromental_catalog.
*
* Steve HARD RULE: all design/image enrichment runs on LOCAL models, never paid
* Gemini/Claude-vision/Replicate. This uses Mac1 Ollama:
* - qwen2.5vl:7b -> vision: colors (hex+name), color_hex, styles, tags,
* and a clean description when body_html is thin.
*
* Resumable: skips rows where ai_accepted_at IS NOT NULL. Batches politely.
* node scripts/enrich-fromental.js [limit]
* LIMIT env or argv[2] caps rows this run (default 100). Pass "all" for全部.
*
* Cost: $0 (local Ollama, unmetered).
*/
const { Client } = require('pg');
const DB = process.env.DATABASE_URL || 'postgresql://localhost/dw_unified';
const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
const VISION_MODEL = process.env.VISION_MODEL || 'qwen2.5vl:7b';
const argLimit = process.argv[2] || process.env.LIMIT || '100';
const LIMIT = argLimit === 'all' ? 1000000 : parseInt(argLimit, 10);
async function fetchImageB64(url) {
// Fromental images can be huge; request a Shopify-resized variant to keep it small.
let u = url;
if (/cdn\.shopify\.com/.test(u) && !/[?&]width=/.test(u)) {
u += (u.includes('?') ? '&' : '?') + 'width=600';
}
const res = await fetch(u, { headers: { 'User-Agent': 'DW-enrich/1.0' } });
if (!res.ok) throw new Error(`img HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString('base64');
}
async function ollamaVision(prompt, imgB64) {
const res = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: VISION_MODEL,
prompt,
images: [imgB64],
stream: false,
format: 'json',
options: { temperature: 0.2 },
}),
});
if (!res.ok) throw new Error(`ollama HTTP ${res.status}`);
const j = await res.json();
return j.response;
}
function safeParse(s) {
if (!s) return null;
try { return JSON.parse(s); } catch {}
const m = s.match(/\{[\s\S]*\}/);
if (m) { try { return JSON.parse(m[0]); } catch {} }
return null;
}
const PROMPT = (row) => `You are a luxury interior-design cataloguer describing a Fromental hand-painted / embroidered wallcovering or artwork image.
Pattern: "${row.pattern_name || ''}" Colorway: "${row.color_name || ''}" Type: "${row.product_type || ''}".
Look at the image and return STRICT JSON with these keys only:
{
"colors": [{"name":"warm ivory","hex":"#efe7d8"}, ...], // 2-5 dominant colors, real hex you SEE
"dominant_hex": "#rrggbb", // single most-dominant hex
"styles": ["Chinoiserie","Scenic", ...], // 1-4 interior/design styles
"tags": ["hand-painted","floral","birds", ...], // 3-8 lowercase descriptive tags
"description": "..." // 1-2 elegant sentences, editorial tone, NO price, NO brand claims beyond Fromental
}
Only output the JSON object.`;
async function main() {
const client = new Client({ connectionString: DB });
await client.connect();
const { rows } = await client.query(
`SELECT id, mfr_sku, pattern_name, color_name, product_type, image_url, description
FROM fromental_catalog
WHERE ai_accepted_at IS NULL AND image_url IS NOT NULL
ORDER BY (description IS NULL) DESC, id
LIMIT $1`,
[LIMIT]
);
console.log(`Enriching ${rows.length} rows (model ${VISION_MODEL}, LOCAL $0)...`);
let ok = 0, fail = 0;
for (const row of rows) {
try {
const b64 = await fetchImageB64(row.image_url);
const raw = await ollamaVision(PROMPT(row), b64);
const j = safeParse(raw);
if (!j) { throw new Error('unparseable model output'); }
const colors = Array.isArray(j.colors) ? j.colors : [];
const domHex = typeof j.dominant_hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(j.dominant_hex) ? j.dominant_hex : null;
const styles = Array.isArray(j.styles) ? j.styles : [];
const tags = Array.isArray(j.tags) ? j.tags : [];
const aiDesc = typeof j.description === 'string' ? j.description.trim() : null;
// only fill ai_description into description if the scraped one is thin
const useDesc = (!row.description || row.description.length < 40) ? aiDesc : null;
await client.query(
`UPDATE fromental_catalog SET
ai_colors=$2, color_hex=$3, dominant_color_hex=$3, ai_styles=$4, ai_tags=$5,
ai_description=$6,
description=COALESCE($7, description),
ai_accepted_at=NOW(), updated_at=NOW()
WHERE id=$1`,
[
row.id, JSON.stringify(colors), domHex, JSON.stringify(styles),
JSON.stringify(tags), aiDesc, useDesc,
]
);
ok++;
if (ok % 10 === 0) process.stdout.write(` ${ok} enriched...\n`);
} catch (e) {
fail++;
process.stderr.write(` FAIL ${row.mfr_sku}: ${e.message}\n`);
}
await new Promise((r) => setTimeout(r, 150)); // polite pacing
}
console.log(`Done. enriched=${ok} failed=${fail}. Cost: $0 (local Ollama).`);
await client.end();
}
main().catch((e) => { console.error(e); process.exit(1); });