← back to Rentv
auto-save: 2026-08-04T16:35:00 (5 files) — data/markets.json data/news.json data/ticker.json server.js public/admin/pr-intelligence/content-studio.html
761151eb13c8b48d041fafd4ae13ac4b8239623c · 2026-08-04 16:35:01 -0700 · Steve Abrams
Files touched
M data/markets.jsonM data/news.jsonM data/ticker.jsonA public/admin/pr-intelligence/content-studio.htmlM server.js
Diff
commit 761151eb13c8b48d041fafd4ae13ac4b8239623c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 4 16:35:01 2026 -0700
auto-save: 2026-08-04T16:35:00 (5 files) — data/markets.json data/news.json data/ticker.json server.js public/admin/pr-intelligence/content-studio.html
---
data/markets.json | 2 +-
data/news.json | 2 +-
data/ticker.json | 2 +-
public/admin/pr-intelligence/content-studio.html | 168 +++++++++++++++++++++++
server.js | 59 +++++++-
5 files changed, 229 insertions(+), 4 deletions(-)
diff --git a/data/markets.json b/data/markets.json
index 2e8ea5e7..2ba22962 100644
--- a/data/markets.json
+++ b/data/markets.json
@@ -1,5 +1,5 @@
{
- "fetched_at": "2026-08-04T22:53:17.784Z",
+ "fetched_at": "2026-08-04T23:33:19.728Z",
"source": "Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)",
"curve": [
{
diff --git a/data/news.json b/data/news.json
index ec27b8e3..0ce24954 100644
--- a/data/news.json
+++ b/data/news.json
@@ -1,6 +1,6 @@
{
"source": "rentv.com — live",
- "fetched_at": "2026-08-04T22:53:17.538Z",
+ "fetched_at": "2026-08-04T23:33:19.260Z",
"count": 30,
"items": [
{
diff --git a/data/ticker.json b/data/ticker.json
index d50c7f51..2145cbd1 100644
--- a/data/ticker.json
+++ b/data/ticker.json
@@ -1,5 +1,5 @@
{
- "fetched_at": "2026-08-04T22:53:17.758Z",
+ "fetched_at": "2026-08-04T23:33:19.702Z",
"la_office_vac": {
"label": "LA OFFICE VAC",
"value": "23.1%",
diff --git a/public/admin/pr-intelligence/content-studio.html b/public/admin/pr-intelligence/content-studio.html
new file mode 100644
index 00000000..58b9c79f
--- /dev/null
+++ b/public/admin/pr-intelligence/content-studio.html
@@ -0,0 +1,168 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CRE PR Intelligence — Content Studio</title>
+<link rel="stylesheet" href="/admin/pr-intelligence/pr-shared.css">
+<style>
+ .cs-wrap{display:grid;grid-template-columns:360px 1fr;gap:20px;align-items:start}
+ @media(max-width:960px){.cs-wrap{grid-template-columns:1fr}}
+ .cs-card{border:1px solid var(--line,#e4e7ea);border-radius:10px;background:var(--card,#fff);padding:16px 18px;margin:0 0 16px}
+ .cs-card h3{margin:0 0 4px;font-size:14px} .cs-card .sub{font-size:12px;color:var(--muted,#5b636b);margin:0 0 10px}
+ .cs-list{max-height:520px;overflow:auto;margin:0 -6px}
+ .cs-art{display:flex;gap:10px;padding:8px;border-radius:8px;cursor:pointer;border:1px solid transparent}
+ .cs-art:hover{background:var(--wash,#f5f6f8)} .cs-art.on{background:#eef5ff;border-color:#b9d4ff}
+ .cs-art img{width:56px;height:42px;object-fit:cover;border-radius:5px;background:#e6ebf0;flex:0 0 auto}
+ .cs-art .t{font-size:12.5px;line-height:1.35;font-weight:600} .cs-art .c{font-size:11px;color:var(--muted,#5b636b);margin-top:2px}
+ .cs-src{font-size:12.5px;line-height:1.55;max-height:170px;overflow:auto;color:var(--ink,#222);white-space:pre-wrap;background:var(--wash,#f7f8fa);border-radius:8px;padding:10px 12px}
+ .cs-field{margin:0 0 12px} .cs-field label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted,#5b636b);margin:0 0 4px}
+ .cs-field input,.cs-field select,.cs-field textarea{width:100%;box-sizing:border-box;font:inherit;font-size:13px;padding:8px 10px;border:1px solid var(--line,#d7dde3);border-radius:8px;background:#fff}
+ .cs-field textarea{resize:vertical} .cs-body{min-height:200px} .cs-social{min-height:96px}
+ .cs-row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
+ .cs-meta{font-size:12px;color:var(--muted,#5b636b)} .cs-ok{color:#1c7a3e;font-weight:600}
+ .cs-cost{display:inline-block;background:#e7f6ec;color:#1c7a3e;border-radius:6px;padding:2px 8px;font-size:11px;font-weight:700}
+ .cc{font-size:11px;color:var(--muted,#5b636b);margin-top:4px}
+ .spin{display:inline-block;width:13px;height:13px;border:2px solid #c9d3dd;border-top-color:#0a6cff;border-radius:50%;animation:sp .7s linear infinite;vertical-align:-2px}
+ @keyframes sp{to{transform:rotate(360deg)}}
+</style>
+</head>
+<body>
+<script src="/admin/pr-intelligence/pr-shared.js"></script>
+<script>
+(async function () {
+ const c = PR.frame('content-studio', 'Content Studio',
+ 'Pull from all RENTV news, then spin any story into an original blog entry + social posts — drafted by the local model ($0). Blog publishes live to /blog on your click; social saves as drafts.');
+
+ c.insertAdjacentHTML('beforeend', '<div class="cs-wrap"><div id="left"></div><div id="right"></div></div>');
+ const left = document.getElementById('left'), right = document.getElementById('right');
+
+ // ── Left: all news articles ────────────────────────────────────────────────
+ left.innerHTML = `<div class="cs-card">
+ <h3>All news <span class="cs-meta" id="ncount"></span></h3>
+ <div class="sub">Every article in the live RENTV feed. Click one to load it.</div>
+ <input id="q" placeholder="Filter headlines…" style="width:100%;box-sizing:border-box;font:inherit;font-size:13px;padding:7px 10px;border:1px solid var(--line,#d7dde3);border-radius:8px;margin-bottom:8px">
+ <div class="cs-list" id="list"></div>
+ </div>
+ <div class="cs-card" id="srcCard" style="display:none">
+ <h3>Source</h3><div class="sub" id="srcMeta"></div><div class="cs-src" id="src">Loading…</div>
+ </div>`;
+
+ let articles = [], selected = null;
+ try { const j = await fetch('/api/news').then(r => r.json()); articles = (j.items || j || []); } catch {}
+ document.getElementById('ncount').textContent = '· ' + articles.length;
+ const listEl = document.getElementById('list'), qEl = document.getElementById('q');
+ function paintList() {
+ const q = qEl.value.trim().toLowerCase();
+ const rows = articles.filter(a => !q || (a.title || '').toLowerCase().includes(q));
+ listEl.innerHTML = rows.length ? rows.map(a => {
+ const img = a.image ? (a.image.startsWith('http') ? a.image : location.origin + a.image) : '';
+ return `<div class="cs-art${selected && selected.id === a.id ? ' on' : ''}" data-id="${PR.esc(a.id)}">
+ ${img ? `<img src="${PR.esc(img)}" onerror="this.style.visibility='hidden'">` : '<div style="width:56px"></div>'}
+ <div><div class="t">${PR.esc(a.title || '')}</div><div class="c">${PR.esc(a.cat || 'RENTV')}</div></div></div>`;
+ }).join('') : '<div class="empty">No matches</div>';
+ listEl.querySelectorAll('[data-id]').forEach(el => el.addEventListener('click', () => selectArticle(el.dataset.id)));
+ }
+ qEl.addEventListener('input', paintList); paintList();
+
+ // ── Right: source + generated content ──────────────────────────────────────
+ right.innerHTML = `
+ <div class="cs-card">
+ <div class="cs-row" style="justify-content:space-between">
+ <div><h3 style="margin:0">Generate</h3><div class="sub" style="margin:0">Local model · $0. Original rewrite — never copies the source.</div></div>
+ <button class="btn" id="gen" disabled>✨ Generate blog + social</button>
+ </div>
+ <div class="cc" id="genmeta"></div>
+ </div>
+ <div class="cs-card" id="blogCard" style="display:none">
+ <h3>Blog entry</h3><div class="sub">Publishing writes it live to <code>/blog</code> immediately.</div>
+ <div class="cs-field"><label>Headline</label><input id="b_title"></div>
+ <div class="cs-row">
+ <div class="cs-field" style="flex:1;min-width:180px"><label>Category</label>
+ <select id="b_cat">${['Sales','Leases','Financing','Development','Multifamily','Office','Retail','Industrial','People','Markets','RENTV'].map(x=>`<option>${x}</option>`).join('')}</select></div>
+ <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>Body</label><textarea id="b_body" class="cs-body"></textarea></div>
+ <div class="cs-row">
+ <button class="btn" id="publish">🚀 Publish to blog</button>
+ <span class="cs-meta" id="pubmeta"></span>
+ </div>
+ </div>
+ <div class="cs-card" id="socialCard" style="display:none">
+ <h3>Social</h3><div class="sub">Saves to social drafts (feeds the LinkedIn Posting composer). Nothing auto-posts.</div>
+ <div class="cs-field"><label>LinkedIn</label><textarea id="s_li" class="cs-social"></textarea>
+ <div class="cs-row" style="margin-top:6px"><button class="btn sm" id="save_li">💾 Save LinkedIn draft</button><span class="cs-meta" id="li_meta"></span></div></div>
+ <div class="cs-field"><label>X / Twitter</label><textarea id="s_x" class="cs-social"></textarea>
+ <div class="cs-row" style="margin-top:6px"><button class="btn sm" id="save_x">💾 Save X draft</button><span class="cs-meta" id="x_meta"></span></div></div>
+ </div>`;
+
+ const gen = document.getElementById('gen');
+ const $ = id => document.getElementById(id);
+
+ async function selectArticle(id) {
+ selected = articles.find(a => String(a.id) === String(id)); if (!selected) return;
+ paintList();
+ document.getElementById('srcCard').style.display = '';
+ document.getElementById('srcMeta').textContent = (selected.cat || 'RENTV') + ' · #' + selected.id;
+ const srcEl = document.getElementById('src'); srcEl.textContent = 'Loading full article…';
+ gen.disabled = false;
+ // preload image into the blog field
+ $('b_image').value = selected.image ? (selected.image.startsWith('http') ? selected.image : location.origin + selected.image) : '';
+ try {
+ const art = await fetch('/api/article/' + encodeURIComponent(String(id).replace(/[^0-9]/g,''))).then(r => r.json());
+ const body = Array.isArray(art.body) ? art.body.join('\n\n') : String(art.body || '');
+ srcEl.textContent = body || '(no body text extracted — generation will work from the headline)';
+ selected._body = body;
+ } catch { srcEl.textContent = '(could not load full article — generation will use the headline)'; }
+ }
+
+ gen.addEventListener('click', () => PR.guard(async () => {
+ if (!selected) return;
+ gen.disabled = true; $('genmeta').innerHTML = '<span class="spin"></span> Drafting with the local model… (~20–40s)';
+ try {
+ const j = await PR.api('/../content/generate', { method: 'POST', body: { article_id: selected.id, title: selected.title, body: selected._body || '', cat: selected.cat || '' } });
+ $('genmeta').innerHTML = `<span class="cs-cost">${PR.esc(j.cost || '$0 (local)')}</span> · ${PR.esc(j.model || '')} · ${(j.ms/1000).toFixed(1)}s`;
+ const bl = j.blog || {};
+ $('b_title').value = bl.title || selected.title || '';
+ $('b_cat').value = bl.cat || selected.cat || 'RENTV';
+ $('b_dek').value = bl.dek || '';
+ $('b_body').value = bl.body || '';
+ $('s_li').value = j.linkedin || ''; $('s_x').value = j.x || '';
+ document.getElementById('blogCard').style.display = '';
+ document.getElementById('socialCard').style.display = '';
+ } finally { gen.disabled = false; }
+ }));
+
+ // ── Publish blog (live) ────────────────────────────────────────────────────
+ $('publish').addEventListener('click', () => PR.guard(async () => {
+ const title = $('b_title').value.trim();
+ if (!title) { PR.toast('Headline required', true); return; }
+ if (!confirm('Publish this entry LIVE to /blog now?')) return;
+ $('publish').disabled = true;
+ try {
+ 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: 'published',
+ } });
+ const url = '/post/' + r.post.id;
+ $('pubmeta').innerHTML = `<span class="cs-ok">✅ Published</span> — <a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
+ PR.toast('Published to blog');
+ } finally { $('publish').disabled = false; }
+ }, null));
+
+ // ── Save social drafts ─────────────────────────────────────────────────────
+ function saveDraft(platform, textId, metaId) {
+ return PR.guard(async () => {
+ const text = $(textId).value.trim();
+ if (!text) { PR.toast('Nothing to save', true); return; }
+ const d = await PR.api('/../social/draft', { method: 'POST', body: { platform, text, topic: ($('b_title').value || selected?.title || '').slice(0,120) } });
+ $(metaId).innerHTML = `<span class="cs-ok">✅ Saved</span> · ${PR.esc(d.draft.id)}`;
+ }, null);
+ }
+ $('save_li').addEventListener('click', () => saveDraft('LinkedIn', 's_li', 'li_meta'));
+ $('save_x').addEventListener('click', () => saveDraft('X', 's_x', 'x_meta'));
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index ddac8652..28acb615 100644
--- a/server.js
+++ b/server.js
@@ -295,11 +295,16 @@ async function fetchArticle(id) {
let region = html.slice(start);
const endM = region.search(/Return to|class="header-column"|Comment on this|Source:\s|id="footer"|<!--\s*footer/i);
if (endM > 0) region = region.slice(0, endM);
+ // Publish date: RENTV stamps each story with a leading M/D/YYYY at the top of the body.
+ // Grab it before it's stripped from the paragraphs; normalize to an ISO date (no time-of-day at source).
+ let date = null;
+ const dm = region.slice(0, 400).match(/\b(0?[1-9]|1[0-2])\/(0?[1-9]|[12]\d|3[01])\/(20\d{2})\b/);
+ if (dm) date = `${dm[3]}-${String(dm[1]).padStart(2, '0')}-${String(dm[2]).padStart(2, '0')}`;
const paras = region.split(/<br\s*\/?>\s*(?: \s*)?<br\s*\/?>/i)
.map(chunk => stripTags(chunk).replace(/^\d{1,2}\/\d{1,2}\/\d{2,4}\s*/, '').trim())
.filter(t => t.length >= 40 && !/^(©|copyright|all rights|advertise|subscribe|follow us|return to)/i.test(t));
const body = [...new Set(paras)].slice(0, 40);
- return { id, url, title, image, body, fetched_at: new Date().toISOString() };
+ return { id, url, title, image, body, date, fetched_at: new Date().toISOString() };
}
app.get('/api/article/:id', async (req, res) => {
@@ -318,6 +323,58 @@ app.get('/api/article/:id', async (req, res) => {
}
});
+// ── Content Studio: turn a news item into an ORIGINAL blog + social drafts ──────
+// Uses the LOCAL Ollama model ($0/local, no paid API). Admin-only, generation only —
+// publishing the blog and saving social drafts are separate explicit actions in the UI.
+const CONTENT_LLM = process.env.CONTENT_LLM_MODEL || 'gemma3:12b';
+const OLLAMA_URL = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
+app.post('/api/content/generate', adminOnly, async (req, res) => {
+ const b = req.body || {};
+ let title = clean(b.title, 300), body = clean(b.body, 12000), cat = clean(b.cat, 40);
+ 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 { /* proceed with whatever we have */ }
+ if (!title) return res.status(400).json({ ok: false, error: 'need an article (title/body or article_id)' });
+ const prompt = `You are the senior editor at RENTV, a commercial real estate news brand covering the Western U.S.
+From the SOURCE news item below, produce ORIGINAL content in RENTV's own voice — rewrite and add editorial context; do NOT copy sentences verbatim; never mention, cite, or link any other news outlet.
+Return STRICTLY this JSON shape and nothing else:
+{"highlights":["3-6 short bullet highlights pulled from the story: parties, dollar amount, property/asset, location, key numbers"],"blog":{"title":"punchy original headline","dek":"one-sentence standfirst","cat":"one of: Sales, Leases, Financing, Development, Multifamily, Office, Retail, Industrial, People, Markets","body":"3-5 short plain-text paragraphs separated by blank lines, no markdown"},"linkedin":"a professional LinkedIn post, 2 short paragraphs plus 2-3 hashtags","x":"a punchy post under 270 characters with 1-2 hashtags"}
+
+SOURCE TITLE: ${title}
+SOURCE CATEGORY: ${cat || 'CRE'}
+SOURCE BODY:
+${body || '(no body available — work from the title)'}`;
+ const started = Date.now();
+ try {
+ const r = await fetch(OLLAMA_URL + '/api/generate', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: CONTENT_LLM, prompt, stream: false, format: 'json', options: { temperature: 0.7 } }),
+ signal: AbortSignal.timeout(120000),
+ });
+ if (!r.ok) return res.status(502).json({ ok: false, error: 'local LLM error ' + r.status });
+ const j = await r.json();
+ let out; try { out = JSON.parse(j.response); } catch { return res.status(502).json({ ok: false, error: 'LLM returned non-JSON', raw: String(j.response || '').slice(0, 300) }); }
+ res.json({ ok: true, model: CONTENT_LLM, ms: Date.now() - started, cost: '$0 (local Ollama)', highlights: Array.isArray(out.highlights) ? out.highlights : [], blog: out.blog || {}, linkedin: out.linkedin || '', x: out.x || '' });
+ } catch (e) {
+ res.status(502).json({ ok: false, error: String(e.message || e).slice(0, 200) });
+ }
+});
+
+// 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).
+app.get('/api/news/archive', adminOnly, (req, res) => {
+ const raw = readJSON('news-archive.json', []);
+ const items = Array.isArray(raw) ? raw : (raw.items || []);
+ items.sort((a, b) => String(b.date || b.first_seen || '').localeCompare(String(a.date || a.first_seen || '')));
+ const limit = Math.min(Number(req.query.limit) || 500, 2000);
+ res.json({ count: items.length, items: items.slice(0, limit) });
+});
+
// ── Topical-authority hubs (SEO basis: re-props CRE keyword library) ─────────────
// Upgrades the legacy /section filters into REAL indexable hub landing pages
// (article list + JSON-LD + sibling interlinks). SSOT = data/hubs.json. A hub is
← 9bd88e88 auto-save: 2026-08-04T16:04:49 (3 files) — data/markets.json
·
back to Rentv
·
CRM Content Studio: deep multi-source dated news feed → orig 8c9901f9 →