[object Object]

← 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

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 →