← back to Rentv
Audience (Cody gate): top-level try/catch on /api/audience (safe empty payload, never blank-500); empty-state names all 3 sources incl CRM
f53f85cb3a6a4b7dbeea73d348da6c5d9bf22e0a · 2026-08-05 13:27:37 -0700 · Steve Abrams
Files touched
M public/audience.htmlM server.js
Diff
commit f53f85cb3a6a4b7dbeea73d348da6c5d9bf22e0a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 5 13:27:37 2026 -0700
Audience (Cody gate): top-level try/catch on /api/audience (safe empty payload, never blank-500); empty-state names all 3 sources incl CRM
---
public/audience.html | 2 +-
server.js | 137 ++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 132 insertions(+), 7 deletions(-)
diff --git a/public/audience.html b/public/audience.html
index 9ac63533..86719e1f 100644
--- a/public/audience.html
+++ b/public/audience.html
@@ -204,7 +204,7 @@ function prospects(){
draw();
}
function emptyState(){
- return `<div class="empty">No contacts yet.<br><br>This CRM fills automatically from two live streams:<br>• <b style="color:#e7ecf1">Newsletter signups</b> (the subscribe form across the site)<br>• <b style="color:#e7ecf1">Sublease listing brokers</b> (added on the <a class="field-link" href="/desk-admin">Sublease Desk</a>)<br><br>Every contact is auto-segmented by interest, source, and recency.</div>`;
+ return `<div class="empty">No contacts yet.<br><br>This CRM fills automatically from three live streams:<br>• <b style="color:#e7ecf1">PR Intelligence CRM</b> (orgs & contacts on the <a class="field-link" href="/admin/pr-intelligence/organizations">Organizations</a> page)<br>• <b style="color:#e7ecf1">Newsletter signups</b> (the subscribe form across the site)<br>• <b style="color:#e7ecf1">Sublease listing brokers</b> (added on the <a class="field-link" href="/desk-admin">Sublease Desk</a>)<br><br>Every contact is auto-segmented by category, interest, source, and recency.</div>`;
}
const debounce=(fn,ms)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms);};};
// ── 2. Audience Segments (grid of cards, grouped) ──
diff --git a/server.js b/server.js
index c3058f13..ad2de583 100644
--- a/server.js
+++ b/server.js
@@ -94,7 +94,7 @@ footer:not(#rentv-footer){display:none!important}
<div class="rf-bar"><span>© 2026 RENTV.com — Commercial Real Estate News across the Western U.S.</span><span>All rights reserved.</span></div>
</footer>`;
// Internal/admin shells never get the customer footer.
-const INTERNAL_PAGE = /(^|\/)(admin|versions|consulting|press|desk|desk-admin|audience|social|backend|deals-log|closings|summit-leads)(\.html)?$|\/(admin|versions|consulting|press)\//i;
+const INTERNAL_PAGE = /(^|\/)(admin|versions|consulting|press|desk|desk-admin|audience|social|backend|deals-log|closings|news-playbook|summit-leads)(\.html)?$|\/(admin|versions|consulting|press)\//i;
function sendPage(res, absFile) {
let html;
try { html = fs.readFileSync(absFile, 'utf8'); }
@@ -218,6 +218,126 @@ app.get('/api/deals/:id/history', adminOnly, (req, r) => {
seen_count: e.seen_count || 1, revisions: e.revisions || 1, history: e.history || [] });
});
+// ── NEWS CORPUS + POSTING PLAYBOOK (TK-10250, Steve 2026-08-05). The full rentv.com article
+// archive (data/articles-corpus.jsonl, backfilled by scripts/pull-archive.mjs) + the derived
+// "how to post a CRE news event" playbook (analyze-news-posts.mjs). Backend-only. ──
+app.get('/api/news-playbook', adminOnly, (_q, r) => {
+ r.json(readJSON('news-posting-playbook.json', { note: 'playbook not built yet — run scripts/analyze-news-posts.mjs' }));
+});
+// Browse the article corpus. Reads the JSONL (cached by mtime). Filters: ?txn= ?type= ?year= ?q= ?limit ?offset.
+let _corpusCache = { mtime: 0, rows: [] };
+function corpus() {
+ const f = path.join(DATA, 'articles-corpus.jsonl');
+ try {
+ const st = fs.statSync(f);
+ if (st.mtimeMs !== _corpusCache.mtime) {
+ const rows = fs.readFileSync(f, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+ _corpusCache = { mtime: st.mtimeMs, rows };
+ }
+ } catch { _corpusCache = { mtime: 0, rows: [] }; }
+ return _corpusCache.rows;
+}
+app.get('/api/articles', adminOnly, (req, r) => {
+ let rows = corpus();
+ const { txn, type, year, q } = req.query;
+ if (txn) rows = rows.filter((a) => (a.txn_type || '').toLowerCase() === String(txn).toLowerCase());
+ if (type) rows = rows.filter((a) => (a.property_type || '').toLowerCase() === String(type).toLowerCase());
+ if (year) rows = rows.filter((a) => String(a.date || '').startsWith(String(year)));
+ if (q) { const n = String(q).toLowerCase(); rows = rows.filter((a) => `${a.title} ${a.city} ${a.state} ${a.summary}`.toLowerCase().includes(n)); }
+ rows.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')));
+ const total = rows.length;
+ const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
+ const limit = Math.min(parseInt(req.query.limit, 10) || 200, 1000);
+ r.json({ total, offset, count: Math.min(limit, total - offset), articles: rows.slice(offset, offset + limit) });
+});
+
+// ── PROPERTY CLOSINGS: editable override layer over the live Sale feed (TK-10251, Steve
+// 2026-08-05). BACKEND-ONLY (all adminOnly). The base list is deals.json filtered to
+// txn_type=Sale (the auto-pull keeps running). On top of it we persist a manual layer in
+// data/closings-overrides.json: { adds:[…full deal-shaped rows…], edits:{ id:{fields} },
+// hides:[ id… ] }. GET merges them; the auto-pull NEVER clobbers a manual edit/add/hide. ──
+const CLOSINGS_OV = 'closings-overrides.json';
+const CLOSE_FIELDS = ['title', 'address', 'city', 'state', 'property_type', 'amount_label', 'size_label', 'year_built', 'close_date', 'image', 'summary'];
+const readCloseOv = () => readJSON(CLOSINGS_OV, { adds: [], edits: {}, hides: [], updated_at: null });
+const writeCloseOv = (o) => fs.writeFileSync(path.join(DATA, CLOSINGS_OV), JSON.stringify(o, null, 1));
+
+// Merged closings feed. ?include_hidden=1 keeps hidden feed rows (flagged _hidden) so the
+// editor can offer Restore. Manual adds are flagged _manual; edited feed rows _edited.
+app.get('/api/closings', adminOnly, (req, r) => {
+ const feed = readJSON('deals.json', { deals: [], fetched_at: null });
+ const ov = readCloseOv();
+ const hides = new Set((ov.hides || []).map(String));
+ const edits = ov.edits || {};
+ const includeHidden = req.query.include_hidden === '1' || req.query.include_hidden === 'true';
+ const base = (feed.deals || []).filter((d) => String(d.txn_type || '').toLowerCase() === 'sale');
+ const feedRows = base.map((d) => {
+ const e = edits[String(d.id)];
+ const row = e ? { ...d, ...e, _edited: true } : { ...d };
+ row._hidden = hides.has(String(d.id));
+ return row;
+ }).filter((d) => includeHidden || !d._hidden);
+ const adds = (ov.adds || []).map((a) => ({ ...a, _manual: true }));
+ const deals = [...adds, ...feedRows];
+ r.json({ source: 'feed+overrides', fetched_at: feed.fetched_at, updated_at: ov.updated_at,
+ manual: adds.length, hidden: (ov.hides || []).length, count: deals.length, deals });
+});
+
+// Add a manual closing the scraper missed. Server-stamps a stable id (mc…) + Sale txn.
+app.post('/api/closings', adminOnly, (req, res) => {
+ const b = req.body || {};
+ if (!clean(b.address) && !clean(b.title)) return res.status(400).json({ ok: false, error: 'address or title required' });
+ const now = new Date().toISOString();
+ const item = { id: 'mc' + Date.now().toString(36), txn_type: 'Sale', url: '', manual: true, created_at: now, updated_at: now };
+ for (const k of CLOSE_FIELDS) item[k] = clean(b[k], k === 'summary' ? 2000 : 500);
+ if (!item.title) item.title = item.address;
+ const ov = readCloseOv(); ov.adds = ov.adds || []; ov.adds.push(item); ov.updated_at = now;
+ try { writeCloseOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ res.json({ ok: true, item });
+});
+
+// Edit a closing — a manual add is edited in place; a feed row stores a sticky field override.
+app.put('/api/closings/:id', adminOnly, (req, res) => {
+ const id = String(req.params.id), b = req.body || {}, now = new Date().toISOString();
+ const ov = readCloseOv();
+ const ai = (ov.adds || []).findIndex((a) => String(a.id) === id);
+ if (ai >= 0) {
+ for (const k of CLOSE_FIELDS) if (b[k] != null) ov.adds[ai][k] = clean(b[k], k === 'summary' ? 2000 : 500);
+ ov.adds[ai].updated_at = now; ov.updated_at = now;
+ try { writeCloseOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ return res.json({ ok: true, item: ov.adds[ai] });
+ }
+ ov.edits = ov.edits || {}; const e = ov.edits[id] || {};
+ for (const k of CLOSE_FIELDS) if (b[k] != null) e[k] = clean(b[k], k === 'summary' ? 2000 : 500);
+ e._updated_at = now; ov.edits[id] = e; ov.updated_at = now;
+ try { writeCloseOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ res.json({ ok: true, edit: e });
+});
+
+// Remove a closing — a manual add is deleted outright; a feed row is HIDDEN (reversible).
+app.delete('/api/closings/:id', adminOnly, (req, res) => {
+ const id = String(req.params.id), now = new Date().toISOString();
+ const ov = readCloseOv();
+ const before = (ov.adds || []).length;
+ ov.adds = (ov.adds || []).filter((a) => String(a.id) !== id);
+ if (ov.adds.length < before) {
+ ov.updated_at = now;
+ try { writeCloseOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ return res.json({ ok: true, removed: true });
+ }
+ ov.hides = Array.from(new Set([...(ov.hides || []).map(String), id])); ov.updated_at = now;
+ try { writeCloseOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ res.json({ ok: true, hidden: true });
+});
+
+// Un-hide a previously hidden feed closing.
+app.post('/api/closings/:id/restore', adminOnly, (req, res) => {
+ const id = String(req.params.id), now = new Date().toISOString();
+ const ov = readCloseOv();
+ ov.hides = (ov.hides || []).map(String).filter((h) => h !== id); ov.updated_at = now;
+ try { writeCloseOv(ov); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ res.json({ ok: true, restored: true });
+});
+
// ── Newsletter capture: append-only local JSONL (NO external send-to-list). ──
const SUBS = path.join(DATA, 'subscribers.jsonl');
const MAX_SUBS = 50000; // hard ceiling — guards against disk-fill / append abuse
@@ -1189,11 +1309,16 @@ function buildAudience(subs, subleaseRows, crmPeople) {
return { generated_at: new Date().toISOString(), stats, segments, contacts };
}
app.get('/api/audience', adminOnly, async (_q, res) => {
- let subs = [];
- try { subs = fs.readFileSync(SUBS, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { /* none */ }
- const [subleaseRows, crmPeople] = await Promise.all([fetchSubleaseRows(), fetchCrmContacts()]);
- res.set('Cache-Control', 'private, max-age=60');
- res.json(buildAudience(subs, subleaseRows, crmPeople));
+ try {
+ let subs = [];
+ try { subs = fs.readFileSync(SUBS, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { /* none */ }
+ const [subleaseRows, crmPeople] = await Promise.all([fetchSubleaseRows(), fetchCrmContacts()]);
+ res.set('Cache-Control', 'private, max-age=60');
+ res.json(buildAudience(subs, subleaseRows, crmPeople));
+ } catch (e) {
+ // Never 500 the page with an empty body — return a safe, well-formed empty payload.
+ res.json({ generated_at: new Date().toISOString(), stats: {}, segments: [], contacts: [], error: 'audience unavailable' });
+ }
});
app.get('/audience', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'audience.html')));
← f5984832 RENTV: full rentv.com article corpus crawler + news-posting
·
back to Rentv
·
RENTV: News Playbook backend view + corpus/playbook API — TK f3a0f639 →