← back to Rentv 2026
auto-save: 2026-08-04T18:05:34 (5 files) — data/markets.json data/news.json data/ticker.json public/admin/pr-intelligence/content-studio.html server.js
d924c4489c31693f304a0ab3224bc2bcc5ef65f3 · 2026-08-04 18:05:36 -0700 · Steve Abrams
Files touched
M data/markets.jsonM data/news.jsonM data/ticker.jsonM public/admin/pr-intelligence/content-studio.htmlM server.js
Diff
commit d924c4489c31693f304a0ab3224bc2bcc5ef65f3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 4 18:05:36 2026 -0700
auto-save: 2026-08-04T18:05:34 (5 files) — data/markets.json data/news.json data/ticker.json public/admin/pr-intelligence/content-studio.html server.js
---
data/markets.json | 2 +-
data/news.json | 2 +-
data/ticker.json | 2 +-
public/admin/pr-intelligence/content-studio.html | 37 +++++++++++++++++++-----
server.js | 7 +++++
5 files changed, 39 insertions(+), 11 deletions(-)
diff --git a/data/markets.json b/data/markets.json
index 4f474911..a6f9daa1 100644
--- a/data/markets.json
+++ b/data/markets.json
@@ -1,5 +1,5 @@
{
- "fetched_at": "2026-08-05T00:33:22.381Z",
+ "fetched_at": "2026-08-05T00:53:23.545Z",
"source": "Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)",
"curve": [
{
diff --git a/data/news.json b/data/news.json
index d9d43d36..77c73d7e 100644
--- a/data/news.json
+++ b/data/news.json
@@ -1,6 +1,6 @@
{
"source": "rentv.com — live",
- "fetched_at": "2026-08-05T00:33:22.098Z",
+ "fetched_at": "2026-08-05T00:53:22.960Z",
"count": 30,
"items": [
{
diff --git a/data/ticker.json b/data/ticker.json
index b83e5bf8..9dd68547 100644
--- a/data/ticker.json
+++ b/data/ticker.json
@@ -1,5 +1,5 @@
{
- "fetched_at": "2026-08-05T00:33:22.350Z",
+ "fetched_at": "2026-08-05T00:53:23.514Z",
"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
index def1f708..d6e22619 100644
--- a/public/admin/pr-intelligence/content-studio.html
+++ b/public/admin/pr-intelligence/content-studio.html
@@ -52,7 +52,8 @@
left.innerHTML = `<div class="cs-card">
<h3>News feed <span class="cs-meta" id="ncount"></span></h3>
<div class="sub" id="nsub">Newest first. Deepens as the archive back-fills.</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">
+ <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:6px">
+ <label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted,#5b636b);margin:0 0 8px;cursor:pointer"><input type="checkbox" id="showdups"> Show all (incl. cross-source duplicates)</label>
<div class="cs-list" id="list"></div>
</div>
<div class="cs-card" id="srcCard" style="display:none">
@@ -60,17 +61,24 @@
</div>`;
let articles = [], selected = null;
- async function loadFeed() {
+ async function loadFeed(dedup = true) {
// Prefer the deep archive; fall back to the live 30-item feed if the archive is empty.
try {
- const j = await PR.api('/../news/archive?limit=800');
- if (j.items && j.items.length) { articles = j.items; document.getElementById('nsub').textContent = `Deep archive · newest first · deepens as it back-fills.`; return; }
+ const j = await PR.api('/../news/archive?limit=800&dedup=' + (dedup ? '1' : '0'));
+ if (j.items && j.items.length) {
+ articles = j.items;
+ const collapsed = (j.raw_count || 0) - (j.count || 0);
+ document.getElementById('nsub').textContent = dedup && collapsed > 0
+ ? `Deep archive · ${collapsed} cross-source duplicate${collapsed > 1 ? 's' : ''} collapsed.`
+ : 'Deep archive · newest first.';
+ return;
+ }
} catch {}
const n = await fetch('/api/news').then(r => r.json()).catch(() => ({ items: [] }));
articles = (n.items || n || []);
document.getElementById('nsub').textContent = 'Live feed (archive still seeding…).';
}
- await loadFeed();
+ await loadFeed(true);
document.getElementById('ncount').textContent = '· ' + articles.length;
const listEl = document.getElementById('list'), qEl = document.getElementById('q');
function paintList() {
@@ -86,13 +94,25 @@
listEl.querySelectorAll('[data-id]').forEach(el => el.addEventListener('click', () => selectArticle(el.dataset.id)));
}
qEl.addEventListener('input', paintList); paintList();
+ document.getElementById('showdups').addEventListener('change', async (e) => {
+ await loadFeed(!e.target.checked); // checked = show raw (dedup off)
+ document.getElementById('ncount').textContent = '· ' + articles.length;
+ selected = null; paintList();
+ });
// ── Right: source + highlights + 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. Pulls highlights + drafts an original rewrite.</div></div>
- <button class="btn" id="gen" disabled>✨ Pull highlights + draft</button>
+ <div class="cs-row">
+ <select id="tone" title="Writing tone / angle" style="font:inherit;font-size:12.5px;padding:7px 9px;border:1px solid var(--line,#d7dde3);border-radius:8px;background:#fff">
+ <option value="news">📰 Straight news</option>
+ <option value="analysis">🧭 Analysis</option>
+ <option value="punchy">⚡ Punchy</option>
+ </select>
+ <button class="btn" id="gen" disabled>✨ Pull highlights + draft</button>
+ </div>
</div>
<div class="cc" id="genmeta"></div>
</div>
@@ -134,7 +154,7 @@
const src = selected.source || 'RENTV';
document.getElementById('srcMeta').textContent = src + ' · ' + (selected.cat || 'RENTV') + ' · ' + dayLabel(selected);
const srcEl = document.getElementById('src');
- gen.disabled = false;
+ gen.disabled = false; gen.textContent = '✨ Pull highlights + draft';
$('b_image').value = selected.image ? (selected.image.startsWith('http') ? selected.image : location.origin + selected.image) : '';
// RENTV stories (numeric id) get a full-body fetch; external sources use their RSS summary.
const isRentv = /^\d+$/.test(String(selected.id)) && src === 'RENTV';
@@ -157,7 +177,8 @@
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 || '', source: selected.source || '' } });
+ const j = await PR.api('/../content/generate', { method: 'POST', body: { article_id: selected.id, title: selected.title, body: selected._body || '', cat: selected.cat || '', source: selected.source || '', tone: document.getElementById('tone').value } });
+ gen.textContent = '🔄 Regenerate';
$('genmeta').innerHTML = `<span class="cs-cost">${PR.esc(j.cost || '$0 (local)')}</span> · ${PR.esc(j.model || '')} · ${(j.ms/1000).toFixed(1)}s`;
const hls = j.highlights || [];
$('hl').innerHTML = hls.length ? hls.map(h => `<li>${PR.esc(h)}</li>`).join('') : '<li>(none extracted)</li>';
diff --git a/server.js b/server.js
index 2e3eaade..76fa2178 100644
--- a/server.js
+++ b/server.js
@@ -366,6 +366,12 @@ 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);
const source = clean(b.source, 80); // the outside wire this came from — must NOT surface in output
+ const TONES = {
+ news: 'TONE: straight hard-news — inverted pyramid, lead with the facts (who/what/how much/where), neutral and authoritative.',
+ analysis: 'TONE: analytical — explain WHY the deal matters, the market context, and what it signals for the sector; add informed perspective.',
+ punchy: 'TONE: punchy and promotional — energetic and confident, built to grab attention and drive clicks, while staying accurate.',
+ };
+ const toneGuide = TONES[clean(b.tone, 20)] || 'TONE: RENTV\'s professional CRE-news voice.';
try {
if ((!body || !title) && b.article_id) {
const art = await fetchArticle(String(b.article_id).replace(/[^0-9]/g, ''));
@@ -377,6 +383,7 @@ app.post('/api/content/generate', adminOnly, async (req, res) => {
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.
CRITICAL: Write it as RENTV's OWN reporting. Do NOT mention, name, cite, credit, or link ANY other news outlet, wire service, newspaper, blog, or publication (including "${source || 'the original source'}"). Never write "according to", "as reported by", "via", or attribute the news to anyone else. Include no URLs. The reader must believe this is RENTV's original story.
+${toneGuide}
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"}
← 9f00a2c9 auto-save: 2026-08-04T17:35:24 (3 files) — data/markets.json
·
back to Rentv 2026
·
Content Studio UX: tone/angle selector (news/analysis/punchy e100458d →