← back to Rentv 2026
Content Studio: track building photo from credited broker/developer (Gemini-grounded) + photo credit + Email-to-Boom via George; Bloom→Boom fix; retire rentv-website
14ea3125e41b490f6183e5b586e4b02f24f595d5 · 2026-08-05 07:52:55 -0700 · steve
Files touched
M public/admin/pr-intelligence/content-studio.htmlM public/post.htmlM server.js
Diff
commit 14ea3125e41b490f6183e5b586e4b02f24f595d5
Author: steve <steve@designerwallcoverings.com>
Date: Wed Aug 5 07:52:55 2026 -0700
Content Studio: track building photo from credited broker/developer (Gemini-grounded) + photo credit + Email-to-Boom via George; Bloom→Boom fix; retire rentv-website
---
public/admin/pr-intelligence/content-studio.html | 44 ++++++++++-
public/post.html | 5 +-
server.js | 98 +++++++++++++++++++++++-
3 files changed, 141 insertions(+), 6 deletions(-)
diff --git a/public/admin/pr-intelligence/content-studio.html b/public/admin/pr-intelligence/content-studio.html
index eb1cac42..62050d2e 100644
--- a/public/admin/pr-intelligence/content-studio.html
+++ b/public/admin/pr-intelligence/content-studio.html
@@ -138,10 +138,14 @@
<div class="cs-field" style="flex:2;min-width:220px"><label>Author</label><input id="b_author" value="RENTV Staff"></div>
</div>
<div class="cs-field"><label>Standfirst (dek)</label><input id="b_dek"></div>
- <div class="cs-field"><label>Image URL</label><input id="b_image"></div>
+ <div class="cs-field"><label>Image URL <a class="cs-copy" id="findimg" title="News-site photos aren't allowed — pull the building photo from the credited broker/developer's own site">🏢 find building photo (credited)</a></label><input id="b_image"></div>
+ <div class="cc" id="imgmeta"></div>
+ <div id="imgcands" style="display:flex;flex-wrap:wrap;gap:8px;margin:0 0 10px"></div>
+ <div class="cs-field"><label>Photo credit (the firm the image came from)</label><input id="b_credit" placeholder="e.g. CBRE"></div>
<div class="cs-field"><label>Body <a class="cs-copy" data-copy="b_body">📋 copy</a></label><textarea id="b_body" class="cs-body"></textarea></div>
<div class="cs-row">
<button class="btn" id="publish">🚀 Publish to blog</button>
+ <button class="btn" id="emailboom">📧 Email to Boom</button>
<span class="cs-meta" id="pubmeta"></span>
</div>
</div>
@@ -165,6 +169,7 @@
const srcEl = document.getElementById('src');
gen.disabled = false; gen.textContent = '✨ Pull highlights + draft';
$('b_image').value = selected.image ? (selected.image.startsWith('http') ? selected.image : location.origin + selected.image) : '';
+ $('b_credit').value = ''; $('imgcands').innerHTML = ''; $('imgmeta').innerHTML = '';
// RENTV stories (numeric id) get a full-body fetch; external sources use their RSS summary.
const isRentv = /^\d+$/.test(String(selected.id)) && src === 'RENTV';
if (isRentv) {
@@ -222,7 +227,7 @@
const r = await PR.api('/../posts', { method: 'POST', body: {
title, cat: $('b_cat').value, author: $('b_author').value.trim() || 'RENTV Staff',
dek: $('b_dek').value.trim(), image: $('b_image').value.trim(), body: $('b_body').value, status,
- source: selected ? (selected.source || 'RENTV') : 'RENTV',
+ photo_credit: $('b_credit').value.trim(), source: selected ? (selected.source || 'RENTV') : 'RENTV',
} });
const url = '/post/' + r.post.id;
$('pubmeta').innerHTML = isRentv
@@ -244,6 +249,41 @@
$('save_li').addEventListener('click', () => saveDraft('LinkedIn', 's_li', 'li_meta'));
$('save_x').addEventListener('click', () => saveDraft('X', 's_x', 'x_meta'));
+ // ── Track down the building photo from the CREDITED broker/developer (not the news site) ──
+ $('findimg').addEventListener('click', () => PR.guard(async () => {
+ if (!selected) { PR.toast('Pick an article first', true); return; }
+ $('imgmeta').innerHTML = '<span class="spin"></span> Naming the deal parties + searching their sites…';
+ $('imgcands').innerHTML = '';
+ const j = await PR.api('/../content/find-image', { method: 'POST', body: { article_id: selected.id, title: selected.title, body: selected._body || '', source: selected.source || '' } });
+ $('imgmeta').innerHTML = `<span class="cs-cost">${PR.esc(j.cost || '')}</span> · parties: ${PR.esc((j.entities || []).join(', ') || '—')} · ${(j.candidates || []).length} photo${j.candidates && j.candidates.length !== 1 ? 's' : ''} found`;
+ const cands = (j.candidates || []).map((c, i) => `<a href="#" data-cand="${i}" title="Credit: ${PR.esc(c.credit)}\n${PR.esc(c.source_page)}" style="display:block;width:120px"><img src="${PR.esc(c.image_url)}" style="width:120px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--line,#ddd)" onerror="this.closest('a').style.display='none'"><span style="font-size:10px;color:var(--muted,#5b636b);display:block">📷 ${PR.esc(c.credit)}</span></a>`).join('');
+ const links = (j.pages || []).map(p => `<a href="${PR.esc(p.url)}" target="_blank" rel="noopener noreferrer" class="cs-srclink" data-credit="${PR.esc(p.credit)}" style="display:block;font-size:12px;margin:2px 0">↗ ${PR.esc(p.title || p.url.slice(0, 60))} <span style="color:var(--muted,#5b636b)">— credit ${PR.esc(p.credit)}</span></a>`).join('');
+ $('imgcands').innerHTML =
+ (cands ? `<div style="display:flex;gap:8px;flex-wrap:wrap;width:100%">${cands}</div>` : '')
+ + (links ? `<div style="width:100%;margin-top:8px"><div class="cs-meta" style="margin-bottom:4px">Open a credited source page, grab the building photo, paste its URL above (many CRE sites block auto-pull). Clicking a link pre-fills the credit:</div>${links}</div>` : '')
+ + (!cands && !links ? '<span class="cs-meta">No credited source found — paste an image URL from the broker/developer site and set the credit manually.</span>' : '');
+ $('imgcands').querySelectorAll('[data-cand]').forEach(a => a.addEventListener('click', (e) => {
+ e.preventDefault(); const c = j.candidates[+a.getAttribute('data-cand')];
+ $('b_image').value = c.image_url; $('b_credit').value = c.credit; PR.toast('Photo + credit set (' + c.credit + ')');
+ }));
+ $('imgcands').querySelectorAll('.cs-srclink').forEach(a => a.addEventListener('click', () => { if (!$('b_credit').value.trim()) $('b_credit').value = a.getAttribute('data-credit'); }));
+ }, null));
+
+ // ── Email the full assembled post to Boom ──────────────────────────────────
+ $('emailboom').addEventListener('click', () => PR.guard(async () => {
+ if (!$('b_title').value.trim()) { PR.toast('Generate a post first', true); return; }
+ if (!confirm('Email this full post to Boom (sbloom@rentv.com)?')) return;
+ $('emailboom').disabled = true;
+ try {
+ const r = await PR.api('/../content/email', { method: 'POST', body: {
+ title: $('b_title').value.trim(), dek: $('b_dek').value.trim(), image: $('b_image').value.trim(),
+ photo_credit: $('b_credit').value.trim(), body: $('b_body').value, linkedin: $('s_li').value, x: $('s_x').value,
+ } });
+ $('pubmeta').innerHTML = `<span class="cs-ok">✅ Emailed to ${PR.esc(r.to)}</span>`;
+ PR.toast('Emailed to Boom');
+ } finally { $('emailboom').disabled = false; }
+ }, null));
+
// Copy-to-clipboard on blog body + social drafts (the hand-off step).
document.addEventListener('click', (e) => {
const a = e.target.closest('.cs-copy'); if (!a) return;
diff --git a/public/post.html b/public/post.html
index 0b378373..3508c107 100644
--- a/public/post.html
+++ b/public/post.html
@@ -13,7 +13,8 @@ a{color:inherit;text-decoration:none}.wrap{max-width:760px;margin:0 auto;padding
.orig{background:var(--red);color:#fff;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;padding:2px 8px;border-radius:10px;margin-left:8px}
h1{font-family:'Playfair Display',serif;font-size:clamp(28px,4vw,42px);line-height:1.12;margin:8px 0 8px}
.dek{font-size:19px;color:var(--sub);margin-bottom:12px}.byl{font-size:13px;color:var(--sub);border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:20px}
-.hero{aspect-ratio:16/8;border-radius:8px;overflow:hidden;margin-bottom:22px;background:linear-gradient(135deg,#1f2937,#c8102e)}.hero img{width:100%;height:100%;object-fit:cover;display:block}
+.hero{aspect-ratio:16/8;border-radius:8px;overflow:hidden;margin-bottom:6px;background:linear-gradient(135deg,#1f2937,#c8102e)}.hero img{width:100%;height:100%;object-fit:cover;display:block}
+.pcredit{font-size:12px;color:var(--sub);margin:0 0 22px;text-align:right;font-style:italic}
.body p{font-size:18px;margin-bottom:18px}.body p:first-of-type::first-letter{font-family:'Playfair Display',serif;font-size:54px;font-weight:700;float:left;line-height:.8;padding:6px 10px 0 0;color:var(--red)}
.back{display:inline-block;margin-top:14px;color:var(--red);font-weight:700;font-size:13px}
.err{color:var(--sub);padding:30px 0}.err a{color:var(--red);font-weight:700}
@@ -31,7 +32,7 @@ fetch('/api/posts/'+encodeURIComponent(id)).then(function(r){return r.ok?r.json(
var a=document.getElementById('art');
if(!p){a.innerHTML='<p class="err">This story isn\'t available. <a href="/blog">← Back to the blog</a></p>';return;}
document.title=p.title+' — RENTV.com';
- var hero=p.image?'<div class="hero"><img src="'+esc(p.image)+'" alt="" onerror="this.parentNode.style.display=\'none\'"></div>':'';
+ var hero=p.image?'<div class="hero"><img src="'+esc(p.image)+'" alt="" onerror="this.parentNode.style.display=\'none\'"></div>'+(p.photo_credit?'<div class="pcredit">Photo: '+esc(p.photo_credit)+'</div>':''):'';
var body=(p.body||'').split(/\n\s*\n/).map(function(x){return x.trim();}).filter(Boolean).map(function(x){return '<p>'+esc(x)+'</p>';}).join('')||'<p>'+esc(p.dek||'')+'</p>';
a.innerHTML='<div class="crumb"><a href="/">Home</a> › <a href="/blog">Blog</a> › <span>'+esc(p.cat)+'</span></div>'+
'<span class="chip">'+esc(p.cat)+'</span><span class="orig">RENTV Original</span><h1>'+esc(p.title)+'</h1>'+
diff --git a/server.js b/server.js
index 5873a0e3..3adba62f 100644
--- a/server.js
+++ b/server.js
@@ -256,7 +256,7 @@ app.post('/api/posts', adminOnly, async (req, res) => {
const post = {
id: 'rp' + Date.now().toString(36),
title: clean(b.title, 200), cat: clean(b.cat, 40) || 'RENTV', author: clean(b.author, 80) || 'RENTV Staff',
- dek: clean(b.dek, 300), image, source: src,
+ dek: clean(b.dek, 300), image, source: src, photo_credit: clean(b.photo_credit, 160),
body: clean(b.body, 40000), status,
created_at: now, updated_at: now,
};
@@ -267,7 +267,7 @@ app.post('/api/posts', adminOnly, async (req, res) => {
app.put('/api/posts/:id', adminOnly, async (req, res) => {
const a = readPosts(), i = a.findIndex(x => x.id === req.params.id);
if (i < 0) return res.status(404).json({ ok: false, error: 'not found' });
- const b = req.body || {}, f = ['title', 'cat', 'author', 'dek', 'image', 'body'];
+ const b = req.body || {}, f = ['title', 'cat', 'author', 'dek', 'image', 'body', 'photo_credit'];
for (const k of f) {
if (b[k] == null) continue;
let v = clean(b[k], k === 'body' ? 40000 : 500);
@@ -430,6 +430,100 @@ ${body || '(no body available — work from the title)'}`;
}
});
+// ── Content Studio: track down the BUILDING PHOTO from the CREDITED party ───────
+// The property image must NOT come from the news site — it must come from the PR firm /
+// developer / broker credited with the deal. Local LLM ($0) names the parties; Gemini
+// (Google-Search grounded, paid) finds their OWN page; we pull its og:image + credit them.
+const GEMINI_KEY = process.env.GEMINI_API_KEY || '';
+const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.5-flash';
+async function ogImage(pageUrl) {
+ try {
+ const r = await fetch(pageUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9' }, redirect: 'follow', signal: AbortSignal.timeout(12000) });
+ if (!r.ok) return null;
+ const html = (await r.text()).slice(0, 250000);
+ const m = html.match(/<meta[^>]+property=["']og:image(?::secure_url)?["'][^>]+content=["']([^"']+)["']/i)
+ || html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)
+ || html.match(/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i);
+ let img = m ? m[1] : null;
+ if (img && img.startsWith('//')) img = 'https:' + img;
+ if (img && img.startsWith('/')) { try { img = new URL(img, r.url || pageUrl).href; } catch {} }
+ return (img && /^https?:/i.test(img)) ? { image: img, page: r.url || pageUrl } : null;
+ } catch { return null; }
+}
+async function geminiFindPages(query) {
+ if (!GEMINI_KEY) return [];
+ try {
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ contents: [{ parts: [{ text: query }] }], tools: [{ google_search: {} }] }),
+ signal: AbortSignal.timeout(30000),
+ });
+ if (!r.ok) return [];
+ const j = await r.json();
+ const chunks = j.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
+ const seen = new Set(), out = [];
+ for (const c of chunks) { const u = c.web && c.web.uri; if (u && !seen.has(u)) { seen.add(u); out.push({ uri: u, title: (c.web.title || '').slice(0, 80) }); } }
+ return out.slice(0, 6);
+ } catch { return []; }
+}
+app.post('/api/content/find-image', adminOnly, async (req, res) => {
+ const b = req.body || {};
+ let title = clean(b.title, 300), body = clean(b.body, 10000);
+ try { if ((!body || !title) && b.article_id) { const art = await fetchArticle(String(b.article_id).replace(/[^0-9]/g, '')); title = title || art.title; body = body || (Array.isArray(art.body) ? art.body.join('\n\n') : String(art.body || '')); } } catch {}
+ if (!title) return res.status(400).json({ ok: false, error: 'need an article' });
+ if (!GEMINI_KEY) return res.status(503).json({ ok: false, error: 'photo search not configured (GEMINI_API_KEY missing)' });
+ // 1) local LLM ($0) names the deal parties (broker + developer/owner) + a property descriptor
+ let parties = { property: '', broker: '', developer: '' };
+ try {
+ const pr = await fetch(OLLAMA_URL + '/api/generate', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: CONTENT_LLM, format: 'json', stream: false, options: { temperature: 0.1 },
+ prompt: `From this commercial real estate news item return JSON {"property":"the building NAME or street address","broker":"the brokerage firm credited or empty","developer":"the developer/owner/seller firm or empty"}. Real firm names only.\n\nTITLE: ${title}\nBODY: ${body.slice(0, 2000)}` }),
+ signal: AbortSignal.timeout(60000),
+ });
+ if (pr.ok) { const pj = await pr.json(); try { parties = { ...parties, ...JSON.parse(pj.response) }; } catch {} }
+ } catch {}
+ const property = clean(parties.property, 120) || title;
+ const entities = [parties.developer, parties.broker].map(x => clean(x, 80)).filter(Boolean);
+ // 2) Gemini (grounded) finds each entity's OWN page for the property; 3) pull its og:image
+ // ALWAYS returns the credited source links too (many CRE sites 403 our fetch → you click through).
+ const started = Date.now(); let searches = 0; const candidates = [], pages = [];
+ for (const ent of (entities.length ? entities : [property])) {
+ searches++;
+ const found = await geminiFindPages(`Find the official web page on ${ent}'s OWN website that shows a photo of the commercial property "${property}". Prefer ${ent}'s listing or project page. Do NOT return a news site. Give the single most relevant URL.`);
+ for (let i = 0; i < found.length; i++) {
+ pages.push({ url: found[i].uri, title: found[i].title, credit: ent });
+ if (candidates.length < 6 && i < 3) { const og = await ogImage(found[i].uri); if (og && og.image) candidates.push({ image_url: og.image, credit: ent, source_page: og.page }); }
+ }
+ }
+ res.json({ ok: true, property, entities, candidates, pages: pages.slice(0, 10), cost: `$${(searches * 0.005).toFixed(3)} (Gemini ${searches} search${searches !== 1 ? 'es' : ''})`, ms: Date.now() - started });
+});
+
+// ── Content Studio: email the full assembled post to Boom via George ────────────
+const GEORGE_URL = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const GEORGE_BASIC_AUTH = process.env.GEORGE_BASIC_AUTH || '';
+const BOOM_EMAIL = process.env.BOOM_EMAIL || 'sbloom@rentv.com';
+app.post('/api/content/email', adminOnly, async (req, res) => {
+ const b = req.body || {};
+ if (!GEORGE_BASIC_AUTH) return res.status(503).json({ ok: false, error: 'email not configured (GEORGE_BASIC_AUTH missing)' });
+ const title = clean(b.title, 200) || 'RENTV post';
+ const html = `<h1>${clean(b.title, 200)}</h1>${b.dek ? `<p><em>${clean(b.dek, 300)}</em></p>` : ''}`
+ + (b.image ? `<p><img src="${clean(b.image, 500)}" style="max-width:100%"><br><small>Photo: ${clean(b.photo_credit, 160) || '—'}</small></p>` : '')
+ + `<div>${String(b.body || '').split(/\n\n+/).map(p => `<p>${clean(p, 4000)}</p>`).join('')}</div>`
+ + (b.linkedin ? `<hr><h3>LinkedIn</h3><p>${clean(b.linkedin, 3000)}</p>` : '')
+ + (b.x ? `<h3>X</h3><p>${clean(b.x, 600)}</p>` : '');
+ try {
+ const r = await fetch(GEORGE_URL + '/api/send', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Basic ' + GEORGE_BASIC_AUTH },
+ body: JSON.stringify({ account: 'steve-office', to: BOOM_EMAIL, subject: 'RENTV draft: ' + title, body: html }),
+ signal: AbortSignal.timeout(25000),
+ });
+ const j = await r.json().catch(() => ({}));
+ if (j.success) return res.json({ ok: true, to: BOOM_EMAIL });
+ return res.status(502).json({ ok: false, error: (j.error || 'send failed'), to: BOOM_EMAIL });
+ } catch (e) { return res.status(502).json({ ok: false, error: String(e.message || e).slice(0, 160) }); }
+});
+
// Deep, date-ordered news archive — accumulates FAR beyond the rolling 30-item live feed.
// SSOT = data/news-archive.json (seeded by scripts/build-news-archive.mjs, grown by pull-news).
// Sorted newest-first by real publish date (falls back to first_seen). Admin-only (content tool).
← 88f6724f auto-save: 2026-08-05T07:40:04 (8 files) — data/markets.json
·
back to Rentv 2026
·
Content Studio email: use raw GEORGE_AUTH token for local Ge 70045f71 →