← back to Interiordesignershowroom
TK-11260: HOTSPOT_PROVIDER dispatcher + openai fallback, backfill 14 empty-hotspot rooms
323ecdb5ca2307061dbbd6f9de1579047570461e · 2026-09-05 10:01:19 -0700 · Steve Abrams
Gemini AI Studio prepay is still depleted (confirmed live 429
RESOURCE_EXHAUSTED), so the 12 Flux-regenerated room scenes from TK-10402
plus steelcase-office/mid-century-room stayed at hotspots=[] (rendered but
not shoppable). Mirrors the existing SCENE_PROVIDER pattern (lib/scene.js):
lib/hotspots.js is now a thin dispatcher over lib/hotspot-providers/{gemini,openai}.js,
same {locateProducts, COST_PER_CALL} contract, so server.js and
backfill-room-scenes.js are untouched.
Ran scripts/backfill-hotspots.js (new, hotspots-only — ~10x cheaper than a
full scene regen) with HOTSPOT_PROVIDER=openai against the LOCAL dev DB:
14 rooms updated, $0.07 total (gpt-5.2 vision, ~$0.005/room). 13/14 rooms
now carry at least one hotspot; "room" got a genuine 0-match (frontend
edge-chip fallback covers it, same as Gemini's own omit-if-unsure behavior).
Ledgered as reversible (undo = UPDATE rooms SET hotspots='[]' for the 14
slugs) at ~/.claude/yolo-queue/executed-reversible/ledger.jsonl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAXbNq4CYRmd9hRam2NU6P
Files touched
A lib/hotspot-providers/gemini.jsA lib/hotspot-providers/openai.jsM lib/hotspots.jsA scripts/backfill-hotspots.js
Diff
commit 323ecdb5ca2307061dbbd6f9de1579047570461e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Sep 5 10:01:19 2026 -0700
TK-11260: HOTSPOT_PROVIDER dispatcher + openai fallback, backfill 14 empty-hotspot rooms
Gemini AI Studio prepay is still depleted (confirmed live 429
RESOURCE_EXHAUSTED), so the 12 Flux-regenerated room scenes from TK-10402
plus steelcase-office/mid-century-room stayed at hotspots=[] (rendered but
not shoppable). Mirrors the existing SCENE_PROVIDER pattern (lib/scene.js):
lib/hotspots.js is now a thin dispatcher over lib/hotspot-providers/{gemini,openai}.js,
same {locateProducts, COST_PER_CALL} contract, so server.js and
backfill-room-scenes.js are untouched.
Ran scripts/backfill-hotspots.js (new, hotspots-only — ~10x cheaper than a
full scene regen) with HOTSPOT_PROVIDER=openai against the LOCAL dev DB:
14 rooms updated, $0.07 total (gpt-5.2 vision, ~$0.005/room). 13/14 rooms
now carry at least one hotspot; "room" got a genuine 0-match (frontend
edge-chip fallback covers it, same as Gemini's own omit-if-unsure behavior).
Ledgered as reversible (undo = UPDATE rooms SET hotspots='[]' for the 14
slugs) at ~/.claude/yolo-queue/executed-reversible/ledger.jsonl.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAXbNq4CYRmd9hRam2NU6P
---
lib/hotspot-providers/gemini.js | 86 ++++++++++++++++++++++++++++++++++
lib/hotspot-providers/openai.js | 100 +++++++++++++++++++++++++++++++++++++++
lib/hotspots.js | 101 ++++++++++------------------------------
scripts/backfill-hotspots.js | 94 +++++++++++++++++++++++++++++++++++++
4 files changed, 305 insertions(+), 76 deletions(-)
diff --git a/lib/hotspot-providers/gemini.js b/lib/hotspot-providers/gemini.js
new file mode 100644
index 0000000..81f46a4
--- /dev/null
+++ b/lib/hotspot-providers/gemini.js
@@ -0,0 +1,86 @@
+// HOTSPOT PROVIDER: gemini — Vision-locate products inside a generated room-setting
+// image so the UI can put a shoppable hotspot ON each actual piece. Uses Gemini 2.5
+// Flash bounding-box detection (returns [ymin,xmin,ymax,xmax] normalized 0-1000). Any
+// product the model can't confidently place is simply omitted — the frontend renders
+// those as edge chips so nothing becomes unselectable. Cheap: one Flash text call per
+// scene. Selected via HOTSPOT_PROVIDER=gemini (default) in lib/hotspots.js.
+const fs = require('fs');
+
+const MODEL = 'gemini-2.5-flash';
+const COST_PER_CALL = 0.001; // ~1 image + small JSON out on Flash; shown to Steve
+
+function buildPrompt(products) {
+ const list = products.map((p, i) => `${i + 1}. ${p.title}`).join('\n');
+ return [
+ 'This is a photograph of a furnished room. Below is a numbered list of the products that appear in it.',
+ 'For EACH product you can clearly locate, return its bounding box.',
+ 'Respond with ONLY a compact JSON array, no prose, no code fence:',
+ '[{"i": <product number>, "box": [ymin, xmin, ymax, xmax]}]',
+ 'Coordinates are normalized 0-1000 (y = top→bottom, x = left→right).',
+ 'Omit any product you cannot confidently locate. Products:',
+ list,
+ ].join('\n');
+}
+
+// Intersection-over-union of two %-boxes — used to reject a new hotspot that lands
+// on the same visual object as one already accepted (lookalike products the model
+// tagged to the single instance it could actually see in the scene).
+function iou(a, b) {
+ const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
+ const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
+ const inter = ix * iy;
+ return inter ? inter / (a.w * a.h + b.w * b.h - inter) : 0;
+}
+
+function toHotspots(raw, products) {
+ const seen = new Set();
+ const hotspots = [];
+ for (const d of raw) {
+ const idx = Number(d.i) - 1;
+ const p = products[idx];
+ if (!p || seen.has(p.id) || !Array.isArray(d.box) || d.box.length !== 4) continue;
+ let [ymin, xmin, ymax, xmax] = d.box.map(Number);
+ const cl = (n) => Math.max(0, Math.min(1000, n || 0));
+ ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
+ if (xmax <= xmin || ymax <= ymin) continue;
+ const box = { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 };
+ if (hotspots.some((h) => iou(h.box, box) > 0.45)) continue; // don't stack on one object
+ seen.add(p.id);
+ hotspots.push({ id: p.id, title: p.title, price: p.sale_price ?? p.price, image_url: p.image_url, advertiser: p.advertiser, box });
+ }
+ return hotspots;
+}
+
+// products: [{id, title, image_url, price, sale_price, advertiser}]
+// imagePath: absolute path to the generated PNG on disk
+async function locateProducts(imagePath, products = []) {
+ const key = process.env.GEMINI_API_KEY;
+ if (!key || !products.length) return { hotspots: [], cost: 0 };
+ let b64;
+ try { b64 = fs.readFileSync(imagePath).toString('base64'); }
+ catch { return { hotspots: [], cost: 0 }; }
+
+ const prompt = buildPrompt(products);
+ let j;
+ try {
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${key}`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contents: [{ parts: [{ inlineData: { mimeType: 'image/png', data: b64 } }, { text: prompt }] }],
+ generationConfig: { temperature: 0, responseMimeType: 'application/json' },
+ }),
+ });
+ j = await res.json();
+ } catch (e) { return { hotspots: [], cost: 0, error: e.message }; }
+ if (j.error) return { hotspots: [], cost: 0, error: j.error.message };
+
+ const text = (j.candidates && j.candidates[0] && j.candidates[0].content
+ && j.candidates[0].content.parts || []).map((p) => p.text || '').join('');
+ let raw;
+ try { raw = JSON.parse(text); } catch { raw = []; }
+ if (!Array.isArray(raw)) raw = [];
+
+ return { hotspots: toHotspots(raw, products), cost: COST_PER_CALL };
+}
+
+module.exports = { locateProducts, COST_PER_CALL };
diff --git a/lib/hotspot-providers/openai.js b/lib/hotspot-providers/openai.js
new file mode 100644
index 0000000..407b2b4
--- /dev/null
+++ b/lib/hotspot-providers/openai.js
@@ -0,0 +1,100 @@
+// HOTSPOT PROVIDER: openai — FALLBACK for when the Gemini prepay is depleted
+// (TK-11260, follow-on to the TK-10402 SCENE_PROVIDER fallback pattern). Same
+// contract as hotspot-providers/gemini.js: vision-locate each product inside a
+// generated room-setting image and return a normalized %-box per product so the
+// frontend can render a shoppable hotspot ON the actual piece. Uses gpt-5.2 (vision)
+// via the Responses API — cost ~$0.003-0.006/room (image + short JSON out), a
+// little pricier than Gemini Flash's ~$0.001 but funded when Gemini isn't.
+// Selected via HOTSPOT_PROVIDER=openai in lib/hotspots.js.
+const fs = require('fs');
+
+const MODEL = 'gpt-5.2';
+const COST_PER_CALL = 0.005; // conservative estimate for one image + small JSON out; shown to Steve
+
+function buildPrompt(products) {
+ const list = products.map((p, i) => `${i + 1}. ${p.title}`).join('\n');
+ return [
+ 'This is a photograph of a furnished room. Below is a numbered list of the products that appear in it.',
+ 'For EACH product you can clearly locate, return its bounding box.',
+ 'Respond with ONLY a compact JSON array, no prose, no code fence, no markdown:',
+ '[{"i": <product number>, "box": [ymin, xmin, ymax, xmax]}]',
+ 'Coordinates are normalized 0-1000 (y = top→bottom, x = left→right), relative to the full image.',
+ 'Omit any product you cannot confidently locate. Products:',
+ list,
+ ].join('\n');
+}
+
+function iou(a, b) {
+ const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
+ const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
+ const inter = ix * iy;
+ return inter ? inter / (a.w * a.h + b.w * b.h - inter) : 0;
+}
+
+function toHotspots(raw, products) {
+ const seen = new Set();
+ const hotspots = [];
+ for (const d of raw) {
+ const idx = Number(d.i) - 1;
+ const p = products[idx];
+ if (!p || seen.has(p.id) || !Array.isArray(d.box) || d.box.length !== 4) continue;
+ let [ymin, xmin, ymax, xmax] = d.box.map(Number);
+ const cl = (n) => Math.max(0, Math.min(1000, n || 0));
+ ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
+ if (xmax <= xmin || ymax <= ymin) continue;
+ const box = { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 };
+ if (hotspots.some((h) => iou(h.box, box) > 0.45)) continue;
+ seen.add(p.id);
+ hotspots.push({ id: p.id, title: p.title, price: p.sale_price ?? p.price, image_url: p.image_url, advertiser: p.advertiser, box });
+ }
+ return hotspots;
+}
+
+function extractJsonArray(text) {
+ if (!text) return [];
+ // Strip a stray code fence if the model ignores the "no code fence" instruction.
+ const cleaned = text.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
+ try {
+ const parsed = JSON.parse(cleaned);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch { return []; }
+}
+
+async function locateProducts(imagePath, products = []) {
+ const key = process.env.OPENAI_API_KEY;
+ if (!key || !products.length) return { hotspots: [], cost: 0 };
+ let b64;
+ try { b64 = fs.readFileSync(imagePath).toString('base64'); }
+ catch { return { hotspots: [], cost: 0 }; }
+
+ const prompt = buildPrompt(products);
+ let j;
+ try {
+ const res = await fetch('https://api.openai.com/v1/responses', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
+ body: JSON.stringify({
+ model: MODEL,
+ input: [{
+ role: 'user',
+ content: [
+ { type: 'input_text', text: prompt },
+ { type: 'input_image', image_url: `data:image/png;base64,${b64}` },
+ ],
+ }],
+ }),
+ });
+ j = await res.json();
+ } catch (e) { return { hotspots: [], cost: 0, error: e.message }; }
+ if (j.error) return { hotspots: [], cost: 0, error: j.error.message };
+
+ const text = (j.output || [])
+ .flatMap((o) => (o.content || []))
+ .map((c) => c.text || '')
+ .join('');
+ const raw = extractJsonArray(text);
+
+ return { hotspots: toHotspots(raw, products), cost: COST_PER_CALL };
+}
+
+module.exports = { locateProducts, COST_PER_CALL };
diff --git a/lib/hotspots.js b/lib/hotspots.js
index b6b3bb9..7011d2b 100644
--- a/lib/hotspots.js
+++ b/lib/hotspots.js
@@ -1,79 +1,28 @@
-// Vision-locate products inside a generated room-setting image so the UI can put
-// a shoppable hotspot ON each actual piece. Uses Gemini 2.5 Flash bounding-box
-// detection (returns [ymin,xmin,ymax,xmax] normalized 0-1000). Any product the
-// model can't confidently place is simply omitted — the frontend renders those as
-// edge chips so nothing becomes unselectable. Cheap: one Flash text call per scene.
-const fs = require('fs');
+// Hotspot-locate DISPATCHER (TK-11260, following the TK-10402 SCENE_PROVIDER pattern).
+// Selects the vision backend by HOTSPOT_PROVIDER so a depleted provider is a one-flag flip:
+//
+// HOTSPOT_PROVIDER=gemini (default) — Gemini 2.5 Flash, ~$0.001/room. Cheapest.
+// HOTSPOT_PROVIDER=openai — gpt-5.2 vision fallback, ~$0.005/room. Needs a
+// funded OPENAI_API_KEY (already routed via secrets).
+//
+// Both providers export the same contract { locateProducts(imagePath, products), COST_PER_CALL },
+// so every caller (server.js /api/rooms, scripts/backfill-room-scenes.js, the one-off
+// hotspots-only backfill) is untouched. Adding a new backend = drop a
+// lib/hotspot-providers/<name>.js exporting that contract + name it here.
+const PROVIDERS = {
+ gemini: () => require('./hotspot-providers/gemini'),
+ openai: () => require('./hotspot-providers/openai'),
+};
-const MODEL = 'gemini-2.5-flash';
-const COST_PER_CALL = 0.001; // ~1 image + small JSON out on Flash; shown to Steve
-
-// products: [{id, title, image_url, price, sale_price, advertiser}]
-// imagePath: absolute path to the generated PNG on disk
-// returns: [{id, title, price, image_url, advertiser, box:{x,y,w,h}}] (x/y/w/h in %)
-async function locateProducts(imagePath, products = []) {
- const key = process.env.GEMINI_API_KEY;
- if (!key || !products.length) return { hotspots: [], cost: 0 };
- let b64;
- try { b64 = fs.readFileSync(imagePath).toString('base64'); }
- catch { return { hotspots: [], cost: 0 }; }
-
- const list = products.map((p, i) => `${i + 1}. ${p.title}`).join('\n');
- const prompt = [
- 'This is a photograph of a furnished room. Below is a numbered list of the products that appear in it.',
- 'For EACH product you can clearly locate, return its bounding box.',
- 'Respond with ONLY a compact JSON array, no prose, no code fence:',
- '[{"i": <product number>, "box": [ymin, xmin, ymax, xmax]}]',
- 'Coordinates are normalized 0-1000 (y = top→bottom, x = left→right).',
- 'Omit any product you cannot confidently locate. Products:',
- list,
- ].join('\n');
-
- let j;
- try {
- const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${key}`, {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contents: [{ parts: [{ inlineData: { mimeType: 'image/png', data: b64 } }, { text: prompt }] }],
- generationConfig: { temperature: 0, responseMimeType: 'application/json' },
- }),
- });
- j = await res.json();
- } catch (e) { return { hotspots: [], cost: 0, error: e.message }; }
- if (j.error) return { hotspots: [], cost: 0, error: j.error.message };
-
- const text = (j.candidates && j.candidates[0] && j.candidates[0].content
- && j.candidates[0].content.parts || []).map((p) => p.text || '').join('');
- let raw;
- try { raw = JSON.parse(text); } catch { raw = []; }
- if (!Array.isArray(raw)) raw = [];
-
- // Intersection-over-union of two %-boxes — used to reject a new hotspot that lands
- // on the same visual object as one already accepted (lookalike products the model
- // tagged to the single instance it could actually see in the scene).
- const iou = (a, b) => {
- const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
- const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
- const inter = ix * iy;
- return inter ? inter / (a.w * a.h + b.w * b.h - inter) : 0;
- };
- const seen = new Set();
- const hotspots = [];
- for (const d of raw) {
- const idx = Number(d.i) - 1;
- const p = products[idx];
- if (!p || seen.has(p.id) || !Array.isArray(d.box) || d.box.length !== 4) continue;
- let [ymin, xmin, ymax, xmax] = d.box.map(Number);
- // clamp to 0-1000 and ensure min<max
- const cl = (n) => Math.max(0, Math.min(1000, n || 0));
- ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
- if (xmax <= xmin || ymax <= ymin) continue;
- const box = { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 };
- if (hotspots.some((h) => iou(h.box, box) > 0.45)) continue; // don't stack on one object
- seen.add(p.id);
- hotspots.push({ id: p.id, title: p.title, price: p.sale_price ?? p.price, image_url: p.image_url, advertiser: p.advertiser, box });
- }
- return { hotspots, cost: COST_PER_CALL };
+const NAME = (process.env.HOTSPOT_PROVIDER || 'gemini').toLowerCase();
+const load = PROVIDERS[NAME];
+if (!load) {
+ throw new Error(`Unknown HOTSPOT_PROVIDER "${NAME}". Valid: ${Object.keys(PROVIDERS).join(', ')}`);
}
+const provider = load();
-module.exports = { locateProducts, COST_PER_CALL };
+module.exports = {
+ PROVIDER: NAME,
+ locateProducts: provider.locateProducts,
+ COST_PER_CALL: provider.COST_PER_CALL,
+};
diff --git a/scripts/backfill-hotspots.js b/scripts/backfill-hotspots.js
new file mode 100644
index 0000000..f6a46fd
--- /dev/null
+++ b/scripts/backfill-hotspots.js
@@ -0,0 +1,94 @@
+// 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();
+})();
← 156ccf7 TK-10402: undo script for Flux room-scene backfill
·
back to Interiordesignershowroom
·
TK-11341: add AdSense Auto-Ads loader + ads.txt (revert to r 0358e6e →