← back to Interiordesignershowroom
scripts/backfill-hotspots.js
95 lines
// TK-11260 — hotspots-ONLY backfill for rooms that already have a scene_image but
// empty hotspots ('[]'). Follow-on to TK-10402's Flux backfill: the Flux scene path
// (SCENE_PROVIDER=replicate-flux) doesn't run vision-locate, so those 12 rooms
// rendered but weren't shoppable. Unlike scripts/backfill-room-scenes.js (which also
// regenerates the SCENE — paid, ~$0.04/room), this only re-runs lib/hotspots against
// the EXISTING scene_image, so it's ~10x cheaper.
//
// node scripts/backfill-hotspots.js --dry-run # $0: list targets + est cost
// node scripts/backfill-hotspots.js --limit 1 # CANARY: one room, show cost
// node scripts/backfill-hotspots.js --max-cost 1 # hard spend cap (default $1)
// node scripts/backfill-hotspots.js # all empty-hotspots rooms w/ a scene
// HOTSPOT_PROVIDER=openai node scripts/backfill-hotspots.js # force the fallback provider
//
// Idempotent: only rooms with scene_image NOT NULL AND (hotspots IS NULL OR hotspots = '[]')
// unless --force. Whichever DATABASE_URL is active is the one updated. Per-room + running
// cost printed every step. Failures are per-room (logged, skipped) — never aborts the batch.
require('dotenv').config();
const path = require('path');
const db = require('../lib/db');
const hotspots = require('../lib/hotspots');
const { flag, num, MAX_FAILS } = require('../lib/run-guard');
const FORCE = flag('--force');
const DRY = flag('--dry-run');
const LIMIT = num('--limit', null);
const MAX_COST = num('--max-cost', Number(process.env.MAX_COST || 1));
// The provider contract silently returns {hotspots:[],cost:0} when its key is
// missing (intentional graceful-degrade for the LIVE server path — a room must
// still render without a configured vision key). That's dangerous for a batch
// backfill: a missing/unrouted key would look identical to "nothing found" and
// get written as a false "done". Fail loud here instead.
const REQUIRED_KEY = { gemini: 'GEMINI_API_KEY', openai: 'OPENAI_API_KEY' };
const needKey = REQUIRED_KEY[hotspots.PROVIDER];
if (needKey && !process.env[needKey]) {
console.error(`[hotspots] ABORT: HOTSPOT_PROVIDER=${hotspots.PROVIDER} needs ${needKey}, which is not set. $0 spent.`);
process.exit(2);
}
(async () => {
const where = FORCE
? 'WHERE scene_image IS NOT NULL'
: "WHERE scene_image IS NOT NULL AND (hotspots IS NULL OR hotspots::text = '[]')";
const lim = LIMIT ? `LIMIT ${LIMIT}` : '';
const { rows: targets } = await db.query(
`SELECT slug, scene_image, product_ids FROM rooms ${where} ORDER BY updated_at ${lim}`);
const perCall = hotspots.COST_PER_CALL || 0;
const est = targets.length * perCall;
console.log(`[hotspots] provider=${hotspots.PROVIDER} — ${targets.length} room(s) need hotspots. Est cost ~$${est.toFixed(3)} ($${perCall}/room). Cap: $${MAX_COST.toFixed(2)}.`);
if (DRY) {
for (const r of targets) console.log(` · would locate ${r.slug} (${(r.product_ids || []).length} products)`);
console.log('[hotspots] DRY RUN — nothing called, $0 spent.');
process.exit(0);
}
if (est > MAX_COST) {
console.error(`[hotspots] ABORT: estimated $${est.toFixed(3)} exceeds --max-cost $${MAX_COST.toFixed(2)}. Raise the cap or use --limit. $0 spent.`);
process.exit(2);
}
let spent = 0, done = 0, skipped = 0, fails = 0;
for (const r of targets) {
if (spent + perCall > MAX_COST) {
console.error(`[hotspots] STOP: next room would exceed the $${MAX_COST.toFixed(2)} cap (spent $${spent.toFixed(3)}).`);
break;
}
try {
const ids = r.product_ids || [];
if (!ids.length) { console.log(` · ${r.slug}: no products — skipped`); skipped++; continue; }
const { rows: products } = await db.query(
`SELECT id, title, image_url, price, sale_price, advertiser FROM products WHERE id = ANY($1) AND NOT suppressed`, [ids]);
if (!products.length) { console.log(` · ${r.slug}: no usable products — skipped`); skipped++; continue; }
const imgPath = path.join(__dirname, '..', 'public', r.scene_image);
const loc = await hotspots.locateProducts(imgPath, products);
if (loc.error) throw new Error(loc.error);
await db.query('UPDATE rooms SET hotspots=$1, updated_at=now() WHERE slug=$2',
[JSON.stringify(loc.hotspots || []), r.slug]);
spent += loc.cost || 0; done++; fails = 0;
console.log(` ✓ ${r.slug}: ${loc.hotspots.length}/${products.length} spots ($${(loc.cost || 0).toFixed(4)}) running: $${spent.toFixed(4)}`);
} catch (e) {
fails += 1;
console.error(` ✗ ${r.slug}: ${e.message}`);
if (fails >= MAX_FAILS) {
console.error(`[hotspots] ABORT: ${MAX_FAILS} consecutive failures — likely a dead/unfunded credential. Spent $${spent.toFixed(4)}.`);
break;
}
}
}
console.log(`[hotspots] done. ${done} updated, ${skipped} skipped, spent $${spent.toFixed(4)}.`);
await db.pool.end();
})();