← back to Interiordesignershowroom
scripts/gen-guide.js
128 lines
// Auto-generate ONE editorial buying guide built around a REAL generated room:
// 1) build a fresh shoppable room via lib/roomgen (Gemini scene + hotspots, ~$0.04),
// varying the (room-type x style) combo away from the most recent guides so
// consecutive guides show different styled pieces;
// 2) author the guide body with LOCAL Ollama (qwen3:14b, $0) — template fallback
// if Ollama is unreachable so a cron run never dies;
// 3) publish the guide: hero = the room's actual scene render, "Shop this guide"
// = the exact pieces IN the scene, body links to the shoppable /room/<slug>.
//
// Cost per run ≈ $0.04 (Gemini scene+hotspots) + $0 (local LLM text). Shown every run.
// Run once: node scripts/gen-guide.js Batch: node scripts/gen-guide.js --count 5
// Env: OLLAMA_URL (default http://127.0.0.1:11434; on Kamatera use the Mac2
// tailnet endpoint http://100.82.17.107:11434), OLLAMA_MODEL (default qwen3:14b).
require('dotenv').config();
const db = require('../lib/db');
const roomsLib = require('../lib/rooms');
const { generateRoom, ROOM_TYPES, STYLES, cap } = require('../lib/roomgen');
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';
const COUNT = Math.max(1, parseInt((process.argv.find((a) => a.startsWith('--count')) || '').split('=')[1] || process.argv[process.argv.indexOf('--count') + 1] || '1', 10) || 1);
// Rotating editorial title shapes so 48 guides/day don't all read the same.
const TITLE_SHAPES = [
(s, r) => `The ${s} ${r} Edit`,
(s, r) => `Shop the Look: A ${s} ${r}`,
(s, r) => `How to Build a ${s} ${r}`,
(s, r) => `${s} ${r}, Piece by Piece`,
(s, r) => `One Room, Done Right: ${s} ${r}`,
(s, r) => `The ${s} ${r} Formula`,
];
// Pick a (room, style) combo not used by the most recent guides — this is what
// keeps the styled pieces varied run-to-run.
async function pickCombo() {
const { rows } = await db.query(
`SELECT room, style FROM guides WHERE published ORDER BY created_at DESC LIMIT 12`);
const recent = new Set(rows.map((g) => `${g.room}|${g.style}`));
// hard-rotate the room type: never reuse a room from the last 3 guides, so even
// when thin style inventory loosens to "modern", consecutive guides differ.
const recentRooms = new Set(rows.slice(0, 3).map((g) => g.room));
const combos = [];
for (const r of ROOM_TYPES) for (const s of STYLES) combos.push([r, s]);
combos.sort(() => Math.random() - 0.5);
const fresh =
combos.find(([r, s]) => !recentRooms.has(r) && !recent.has(`${r}|${s}`)) ||
combos.find(([r, s]) => !recent.has(`${r}|${s}`)) || combos[0];
return { room: fresh[0], style: fresh[1] };
}
function stripThink(t) { return (t || '').replace(/<think>[\s\S]*?<\/think>/g, '').trim(); }
async function ollamaBody({ title, style, room, products }) {
const list = products.map((p) => `- ${p.title}${p.price ? ` ($${p.price})` : ''}`).join('\n');
const prompt = `You are the senior editor of an interior-design shopping magazine. Write the body of a buying guide titled "${title}" about designing a ${style} ${room.replace(/-/g, ' ')}. The guide is built around ONE real designed room whose exact pieces are:\n${list}\n\nRules: 450-650 words of markdown. Use 3-5 "## " section headings. Practical, specific, designer-voice advice (dimensions, materials, color logic, layout rules) woven around those actual pieces — refer to several of them by name. No intro fluff like "In this guide", no conclusion heading, no links, no images, no emoji, no bullet list of the products (they are shown separately). American English.`;
const res = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: OLLAMA_MODEL, prompt, stream: false, options: { temperature: 0.8, num_predict: 1400 } }),
signal: AbortSignal.timeout(300000),
});
if (!res.ok) throw new Error(`ollama ${res.status}`);
const j = await res.json();
const body = stripThink(j.response);
if (body.length < 400) throw new Error('ollama body too short');
return body;
}
// Deterministic fallback so a scheduled run always publishes something coherent.
function templateBody({ style, room, products }) {
const S = cap(style), R = cap(room);
const names = products.map((p) => p.title);
return [
`## The idea`,
`This room started with one decision: commit to ${S.toLowerCase()} and let every piece earn its place. The anchor here is the ${names[0] || 'main piece'}, and everything else — ${names.slice(1, 4).join(', ')} — is chosen to support it rather than compete with it.`,
`## Get the anchor right first`,
`In a ${R.toLowerCase()}, the largest piece sets the scale for the whole space. Place it first, leave 30–36 inches of walkway around it, and only then size the secondary pieces. If the anchor feels heavy, lift it visually: exposed legs, lighter textiles, or a rug that extends at least 6 inches beyond its footprint on every side.`,
`## Layer the supporting pieces`,
`A designed room is one anchor, one or two mid-weight pieces, and a handful of accents at different heights. Mix at least two materials (wood + metal, boucle + stone) and keep the palette to three colors plus one accent so the ${S.toLowerCase()} character reads as intentional.`,
`## Light it in threes`,
`Never rely on the overhead alone — three sources at three heights (floor, table, ceiling), all warm 2700K on dimmers, is what makes the finished room in the photo feel inhabitable rather than staged.`,
`## Shop it as a set or steal the formula`,
`Every piece below is the exact item in the rendered room — take the whole formula, or swap any single piece and keep the proportions.`,
].join('\n\n');
}
async function uniqueSlug(base) {
let slug = roomsLib.slugify(base), n = 1;
while (true) {
const { rows } = await db.query(`SELECT 1 FROM guides WHERE slug=$1`, [slug]);
if (!rows.length) return slug;
slug = `${roomsLib.slugify(base)}-${++n}`;
}
}
async function genOne(i) {
const combo = await pickCombo();
console.log(`[guide-gen] #${i + 1} combo: ${combo.style} ${combo.room}`);
const roomOut = await generateRoom({ ...combo, log: (m) => console.log(' ' + m) });
const S = cap(roomOut.style || combo.style), R = cap(roomOut.room);
const title = TITLE_SHAPES[Math.floor(Math.random() * TITLE_SHAPES.length)](S, R);
const dek = `A complete ${S.toLowerCase()} ${R.toLowerCase()}, rendered as one real shoppable scene — the exact ${roomOut.products.length} pieces, why each one works, and the layout rules that make them read as a designed room.`;
let body, author = 'ollama:' + OLLAMA_MODEL;
try {
body = await ollamaBody({ title, style: roomOut.style || combo.style, room: roomOut.room, products: roomOut.products });
} catch (e) {
console.log(` ! ollama unavailable (${e.message}) — using template body`);
body = templateBody({ style: roomOut.style || combo.style, room: roomOut.room, products: roomOut.products });
author = 'template';
}
body += `\n\n## See the room\n\n[Step inside the shoppable room this guide is built on →](/room/${roomOut.slug}) — every hotspot in the scene is one of the pieces below.`;
const slug = await uniqueSlug(title);
await db.query(
`INSERT INTO guides (slug, title, dek, hero_image, body_md, product_ids, room, style, published)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,TRUE)`,
[slug, title, dek, roomOut.scene_image, body, roomOut.products.map((p) => p.id), roomOut.room, roomOut.style || combo.style]);
console.log(`[guide-gen] published /guides/${slug} (body: ${author}, ${body.length} chars) | run cost $${roomOut.cost.toFixed(3)} (Gemini) + $0 (local text)`);
return roomOut.cost;
}
(async () => {
let total = 0;
for (let i = 0; i < COUNT; i++) total += await genOne(i);
console.log(`[guide-gen] done — ${COUNT} guide(s), total cost $${total.toFixed(3)}`);
process.exit(0);
})().catch((e) => { console.error('[guide-gen]', e.message); process.exit(1); });