← back to Rentv
RENTV: News Playbook backend view + corpus/playbook API — TK-10250
f3a0f6399a55a7d90599b670946d63bf6b5cf73e · 2026-08-05 13:28:36 -0700 · Steve Abrams
server.js: /api/news-playbook (the how-to-post analysis) + /api/articles (browse the corpus,
mtime-cached, filters txn/type/year/q, paginated), both adminOnly. public/news-playbook.html:
playbook dashboard (cadence, category mix, headline formulas by deal type, verbs, complete-post
checklist, rule-of-thumb) + a searchable/filterable corpus browser. Linked from the backend hub
(📰 News Playbook). news-playbook registered as an internal (footer-free) page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/backend.htmlA public/news-playbook.html
Diff
commit f3a0f6399a55a7d90599b670946d63bf6b5cf73e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 5 13:28:36 2026 -0700
RENTV: News Playbook backend view + corpus/playbook API — TK-10250
server.js: /api/news-playbook (the how-to-post analysis) + /api/articles (browse the corpus,
mtime-cached, filters txn/type/year/q, paginated), both adminOnly. public/news-playbook.html:
playbook dashboard (cadence, category mix, headline formulas by deal type, verbs, complete-post
checklist, rule-of-thumb) + a searchable/filterable corpus browser. Linked from the backend hub
(📰 News Playbook). news-playbook registered as an internal (footer-free) page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/backend.html | 3 +-
public/news-playbook.html | 130 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 132 insertions(+), 1 deletion(-)
diff --git a/public/backend.html b/public/backend.html
index d1db7f9e..01ec2ead 100644
--- a/public/backend.html
+++ b/public/backend.html
@@ -39,8 +39,9 @@
<a class="card" href="/audience"><div class="ico">🎯</div><b>Audience & CRM</b><span>Subscribers + sublease brokers, auto-segmented</span></a>
<a class="card" href="/desk-admin"><div class="ico">✍️</div><b>Desk Admin</b><span>Add / withdraw sublease inventory</span></a>
<a class="card" href="/admin/pr-intelligence"><div class="ico">📡</div><b>CRE PR Intelligence</b><span>CA/AZ press-contact research · letters · replies</span></a>
- <a class="card" href="/closings"><div class="ico">🏠</div><b>Property Closings</b><span>Every reported closed sale · address · date · price</span></a>
+ <a class="card" href="/closings"><div class="ico">🏠</div><b>Property Closings</b><span>Add · edit · hide reported closed sales · live feed + manual layer</span></a>
<a class="card" href="/deals-log.html"><div class="ico">📒</div><b>Deals Log</b><span>Every deal ever found from rentv.com + per-deal change history</span></a>
+ <a class="card" href="/news-playbook.html"><div class="ico">📰</div><b>News Playbook</b><span>Full rentv.com article archive + how-to-post-news analysis</span></a>
</div>
<div class="sect">Build & Dashboards</div>
diff --git a/public/news-playbook.html b/public/news-playbook.html
new file mode 100644
index 00000000..9d0be541
--- /dev/null
+++ b/public/news-playbook.html
@@ -0,0 +1,130 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>RENTV — News Playbook · how to post CRE news events</title>
+<style>
+ :root{ --a:#c8102e; --ink:#14171a; --sub:#5b636b; --ln:#e4e7ea; --bg:#fff; --wa:#f5f6f8; --ok:#1a7f37; }
+ *{box-sizing:border-box} body{margin:0;background:var(--wa);color:var(--ink);font:14px/1.55 'Inter',system-ui,-apple-system,sans-serif}
+ header{background:var(--bg);border-bottom:2px solid var(--a);padding:14px 22px;position:sticky;top:0;z-index:10;display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
+ header h1{font:700 19px/1 'Playfair Display',Georgia,serif;margin:0} header h1 span{color:var(--a)}
+ header .meta{font-size:12px;color:var(--sub)} header .meta b{color:var(--ink)}
+ .wrap{padding:20px 22px 60px;max-width:1180px;margin:0 auto}
+ h2{font:700 15px/1.2 'Inter';text-transform:uppercase;letter-spacing:.5px;color:var(--sub);margin:26px 0 12px;border-bottom:1px solid var(--ln);padding-bottom:6px}
+ .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
+ .stat{background:var(--bg);border:1px solid var(--ln);border-radius:10px;padding:12px 14px}
+ .stat .n{font:700 22px/1.1 'Playfair Display',Georgia,serif;color:var(--a)} .stat .l{font-size:11px;color:var(--sub);margin-top:3px}
+ .two{display:grid;grid-template-columns:1fr 1fr;gap:18px} @media(max-width:760px){.two{grid-template-columns:1fr}}
+ .panel{background:var(--bg);border:1px solid var(--ln);border-radius:10px;padding:14px 16px}
+ .bar{display:flex;align-items:center;gap:8px;margin:5px 0;font-size:13px}
+ .bar .lab{width:120px;color:var(--sub);flex:0 0 auto} .bar .track{flex:1;background:var(--wa);border-radius:5px;height:16px;overflow:hidden}
+ .bar .fill{height:100%;background:var(--a);opacity:.85} .bar .v{width:54px;text-align:right;font-variant-numeric:tabular-nums;flex:0 0 auto}
+ .formula{font:12.5px/1.5 ui-monospace,monospace;background:var(--wa);border:1px solid var(--ln);border-radius:6px;padding:6px 9px;margin:5px 0}
+ .formula .sh{color:var(--a);font-family:'Inter';font-size:11px;font-weight:700;float:right}
+ .txnblock{margin:0 0 14px} .txnblock h3{margin:0 0 6px;font-size:13px}
+ ul.check{list-style:none;padding:0;margin:0} ul.check li{padding:4px 0;font-size:13.5px} ul.check li b{color:var(--a)}
+ .rule{background:#fff8e6;border:1px solid #e8d48a;border-radius:8px;padding:12px 15px;font-size:14px;line-height:1.5}
+ /* corpus table */
+ .ctl{display:flex;gap:9px;flex-wrap:wrap;align-items:center;margin:6px 0 10px}
+ .ctl input,.ctl select{padding:7px 10px;border:1px solid var(--ln);border-radius:8px;font-size:13px} .ctl input[type=text]{flex:1;min-width:200px}
+ table{width:100%;border-collapse:collapse;background:var(--bg);font-size:13px;border:1px solid var(--ln);border-radius:8px;overflow:hidden}
+ th,td{text-align:left;padding:8px 11px;border-bottom:1px solid var(--ln)} th{background:var(--wa);font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--sub)}
+ td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
+ .tag{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;background:var(--wa);border:1px solid var(--ln)}
+ a.src{color:var(--a);text-decoration:none} a.src:hover{text-decoration:underline}
+ .muted{color:var(--sub);font-size:12px}
+</style>
+</head>
+<body>
+<header>
+ <h1>REN<span>TV</span> · News Playbook</h1>
+ <span class="meta" id="hmeta">loading…</span>
+ <span class="meta" style="margin-left:auto"><a href="/backend.html" style="color:var(--a)">← backend</a></span>
+</header>
+<div class="wrap">
+ <div id="playbook"><p class="muted">loading playbook…</p></div>
+
+ <h2>Article corpus — browse the source</h2>
+ <div class="ctl">
+ <input type="text" id="q" placeholder="search headlines, city, state, summary…">
+ <select id="fTxn"><option value="">All deal types</option></select>
+ <select id="fType"><option value="">All property types</option></select>
+ <select id="fYear"><option value="">All years</option></select>
+ <span class="muted" id="ccount"></span>
+ </div>
+ <table><thead><tr><th>Date</th><th>Headline</th><th>Txn</th><th>Type</th><th class="num">Amount</th><th>Location</th></tr></thead><tbody id="cbody"><tr><td colspan="6" class="muted" style="padding:30px;text-align:center">loading…</td></tr></tbody></table>
+ <p class="muted" id="more" style="text-align:center;margin-top:14px"></p>
+</div>
+
+<script>
+const $=s=>document.querySelector(s);
+const esc=s=>String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
+const bar=(lab,v,max)=>`<div class="bar"><span class="lab">${esc(lab)}</span><span class="track"><span class="fill" style="width:${max?Math.round(100*v/max):0}%"></span></span><span class="v">${v}%</span></div>`;
+
+async function loadPlaybook(){
+ let P; try{ P=await (await fetch('/api/news-playbook')).json(); }catch(e){ $('#playbook').innerHTML='<p class="muted">playbook not available — admin login required.</p>'; return; }
+ if(P.note){ $('#playbook').innerHTML='<div class="panel">'+esc(P.note)+'</div>'; return; }
+ const c=P.category_mix, h=P.headline, k=P.completeness, cad=P.cadence;
+ $('#hmeta').innerHTML=`<b>${(P.corpus.articles||0).toLocaleString()}</b> articles${P.corpus.date_span?` · ${P.corpus.date_span.from} → ${P.corpus.date_span.to}`:''}`;
+ const maxTxn=Math.max(...c.txn_type.map(x=>x.share),1);
+ const maxType=Math.max(...c.property_type.map(x=>x.share),1);
+ const dowMax=Math.max(...Object.values(cad.by_weekday),1);
+ const formulas=Object.entries(h.top_formulas_by_txn).map(([txn,fs])=>`<div class="txnblock"><h3>${esc(txn)}</h3>${fs.slice(0,4).map(f=>`<div class="formula"><span class="sh">${f.share}%</span>${esc(f.formula)}</div>`).join('')}</div>`).join('');
+ $('#playbook').innerHTML=`
+ <h2>How to post a CRE news event — evidence from ${(P.corpus.articles||0).toLocaleString()} articles</h2>
+ <div class="cards">
+ <div class="stat"><div class="n">${cad.per_active_day}</div><div class="l">posts / active day</div></div>
+ <div class="stat"><div class="n">${cad.busiest_weekday}</div><div class="l">busiest weekday</div></div>
+ <div class="stat"><div class="n">${h.median_words}w</div><div class="l">median headline length</div></div>
+ <div class="stat"><div class="n">${h.includes_dollar_share}%</div><div class="l">headlines with $ amount</div></div>
+ <div class="stat"><div class="n">${k.median_deal_size?'$'+(k.median_deal_size/1e6).toFixed(1)+'M':'—'}</div><div class="l">median deal size</div></div>
+ <div class="stat"><div class="n">${k.median_story_words}w</div><div class="l">median body length</div></div>
+ </div>
+ <div class="two" style="margin-top:16px">
+ <div class="panel"><h3 style="margin:0 0 8px">Deal-type mix</h3>${c.txn_type.map(x=>bar(x.k,x.share,maxTxn)).join('')}</div>
+ <div class="panel"><h3 style="margin:0 0 8px">Property-type mix</h3>${c.property_type.slice(0,7).map(x=>bar(x.k,x.share,maxType)).join('')}</div>
+ </div>
+ <div class="two" style="margin-top:16px">
+ <div class="panel"><h3 style="margin:0 0 8px">Posting rhythm (by weekday)</h3>${Object.entries(cad.by_weekday).map(([d,n])=>`<div class="bar"><span class="lab">${d}</span><span class="track"><span class="fill" style="width:${Math.round(100*n/dowMax)}%"></span></span><span class="v">${n}</span></div>`).join('')}</div>
+ <div class="panel"><h3 style="margin:0 0 8px">Top deal verbs</h3>${h.top_verbs.slice(0,8).map(v=>bar(v.verb,v.share,Math.max(...h.top_verbs.map(x=>x.share),1))).join('')}</div>
+ </div>
+ <h2>Proven headline formulas by deal type</h2>
+ <div class="two">${formulas}</div>
+ <div class="muted" style="margin:4px 0 14px">Placeholders: {WHO}=actor/company · {AMOUNT}=price · {CITY}/{ST}=location · {SIZE}=units/SF/acres.</div>
+ <h2>A complete post — the checklist</h2>
+ <div class="panel"><ul class="check">
+ <li><b>Amount</b> — present in ${k.has_amount_share}% of posts. If there's a price, lead with it.</li>
+ <li><b>City ${k.has_city_share}% · State ${k.has_state_share}%</b> — always geo-anchor the asset.</li>
+ <li><b>Size</b> — units/SF/acres in ${k.has_size_share}% of posts.</li>
+ <li><b>Body</b> — ~${k.median_story_words} words; sentence one answers who/what/where/how-much.</li>
+ <li><b>Deal type + property type</b> tags — for filtering + SEO topical hubs.</li>
+ </ul></div>
+ <div class="rule" style="margin-top:14px"><b>Rule of thumb:</b> a CRE news post = <b>{WHO} {VERB} {LOCATION} {PROPERTY-TYPE} for {AMOUNT}</b> in the headline, a ${k.median_story_words}-word body answering who/what/where/how-much in sentence one, geo-anchored and typed. Post on weekdays; lead with the biggest-$ deals.</div>`;
+ // populate year filter from span
+ if(P.corpus.date_span){ const y0=+P.corpus.date_span.from.slice(0,4), y1=+P.corpus.date_span.to.slice(0,4); const opts=[];for(let y=y1;y>=y0;y--)opts.push(`<option>${y}</option>`); $('#fYear').innerHTML='<option value="">All years</option>'+opts.join(''); }
+ const txns=c.txn_type.map(x=>x.k), types=c.property_type.map(x=>x.k);
+ $('#fTxn').innerHTML='<option value="">All deal types</option>'+txns.map(t=>`<option>${esc(t)}</option>`).join('');
+ $('#fType').innerHTML='<option value="">All property types</option>'+types.map(t=>`<option>${esc(t)}</option>`).join('');
+}
+
+let offset=0, loading=false;
+function q(){ const p=new URLSearchParams(); if($('#q').value.trim())p.set('q',$('#q').value.trim()); if($('#fTxn').value)p.set('txn',$('#fTxn').value); if($('#fType').value)p.set('type',$('#fType').value); if($('#fYear').value)p.set('year',$('#fYear').value); return p; }
+async function loadArticles(reset){
+ if(loading) return; loading=true;
+ if(reset){ offset=0; $('#cbody').innerHTML=''; }
+ const p=q(); p.set('offset',offset); p.set('limit',150);
+ let d; try{ d=await (await fetch('/api/articles?'+p)).json(); }catch(e){ loading=false; return; }
+ const rows=(d.articles||[]).map(a=>`<tr><td class="muted">${esc(a.date||'—')}</td><td>${a.url?`<a class="src" href="${esc(a.url)}" target="_blank" rel="noopener noreferrer">${esc(a.title||'—')}</a>`:esc(a.title||'—')}</td><td><span class="tag">${esc(a.txn_type||'—')}</span></td><td><span class="tag">${esc(a.property_type||'—')}</span></td><td class="num">${esc(a.amount_label||'—')}</td><td class="muted">${esc([a.city,a.state].filter(Boolean).join(', ')||'—')}</td></tr>`).join('');
+ $('#cbody').insertAdjacentHTML('beforeend', rows|| (offset===0?'<tr><td colspan="6" class="muted" style="padding:30px;text-align:center">no articles match.</td></tr>':''));
+ offset+=d.count||0;
+ $('#ccount').textContent=`${d.total||0} articles`;
+ $('#more').innerHTML = offset < (d.total||0) ? `<a href="#" id="loadmore" class="src">load more (${offset}/${d.total})</a>` : (offset?`all ${offset} shown`:'');
+ const lm=$('#loadmore'); if(lm) lm.onclick=(e)=>{e.preventDefault();loadArticles(false);};
+ loading=false;
+}
+['q','fTxn','fType','fYear'].forEach(id=>$('#'+id).addEventListener('input',()=>{clearTimeout(window._t);window._t=setTimeout(()=>loadArticles(true),200);}));
+loadPlaybook(); loadArticles(true);
+</script>
+</body>
+</html>
← f53f85cb Audience (Cody gate): top-level try/catch on /api/audience (
·
back to Rentv
·
RENTV: move Property Closings to backend-only + editable ove 0432aa44 →