[object Object]

← back to Rentv

parity with rentv-website: real rentv images + full article-body scraping + mobile hamburger nav + centered masthead

bda422481697cce01f45e2f331b2596625de395e · 2026-07-20 14:54:56 -0700 · Steve Abrams

Files touched

Diff

commit bda422481697cce01f45e2f331b2596625de395e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 20 14:54:56 2026 -0700

    parity with rentv-website: real rentv images + full article-body scraping + mobile hamburger nav + centered masthead
---
 data/markets.json     |  2 +-
 data/news.json        |  2 +-
 data/ticker.json      |  2 +-
 public/article.html   | 23 +++++++++++++++++++----
 public/index.html     | 33 +++++++++++++++++++++++++--------
 public/section.html   | 27 +++++++++++++++++++++------
 scripts/pull-news.mjs |  6 +++++-
 server.js             | 38 ++++++++++++++++++--------------------
 8 files changed, 91 insertions(+), 42 deletions(-)

diff --git a/data/markets.json b/data/markets.json
index 58fc6db9..cdfd6a9d 100644
--- a/data/markets.json
+++ b/data/markets.json
@@ -1,5 +1,5 @@
 {
-  "fetched_at": "2026-07-20T21:27:36.267Z",
+  "fetched_at": "2026-07-20T21:42:23.756Z",
   "source": "Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)",
   "curve": [
     {
diff --git a/data/news.json b/data/news.json
index 70b6092c..9fcea8d0 100644
--- a/data/news.json
+++ b/data/news.json
@@ -1,6 +1,6 @@
 {
  "source": "rentv.com — live",
- "fetched_at": "2026-07-20T21:22:22.127Z",
+ "fetched_at": "2026-07-20T21:42:23.433Z",
  "count": 30,
  "items": [
   {
diff --git a/data/ticker.json b/data/ticker.json
index a7f7274b..88696278 100644
--- a/data/ticker.json
+++ b/data/ticker.json
@@ -1,5 +1,5 @@
 {
-  "fetched_at": "2026-07-20T21:27:36.244Z",
+  "fetched_at": "2026-07-20T21:42:23.730Z",
   "la_office_vac": {
     "label": "LA OFFICE VAC",
     "value": "23.1%",
diff --git a/public/article.html b/public/article.html
index 2a1d3dcf..5b8882b4 100644
--- a/public/article.html
+++ b/public/article.html
@@ -16,14 +16,24 @@
   .util .wrap{display:flex;justify-content:space-between;align-items:center;height:34px}
   .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
   .mast{border-bottom:2px solid var(--ink);padding:18px 0 12px}
-  .mast .wrap{display:flex;align-items:center;justify-content:space-between}
+  .mast .wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:3px}
   .logo{font-family:var(--serif);font-weight:700;font-size:34px;letter-spacing:-1px}.logo span{color:var(--red)}
   .logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
-  .datebox{font-size:12px;color:var(--sub);text-align:right}
+  .datebox{font-size:12px;color:var(--sub);text-align:center}
   nav{border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:10}
-  nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
+  nav .wrap{display:flex;gap:2px;align-items:center;height:46px}
+  .ham{display:none;background:none;border:0;font-size:24px;line-height:1;color:var(--ink);cursor:pointer;padding:8px 12px 8px 0;margin-right:4px}
+  .navlinks{display:flex;gap:2px;align-items:center;overflow-x:auto}
   nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:14px 13px;border-bottom:3px solid transparent;white-space:nowrap}
   nav a:hover,nav a.on{color:var(--red);border-color:var(--red)}
+  @media(max-width:860px){
+    .ham{display:block;order:-1}
+    nav .wrap{height:52px}
+    .navlinks{display:none;position:absolute;top:52px;left:0;right:0;background:#fff;flex-direction:column;align-items:stretch;border-bottom:2px solid var(--ink);box-shadow:0 10px 24px rgba(0,0,0,.14);z-index:30;max-height:78vh;overflow-y:auto}
+    .navlinks.open{display:flex}
+    .navlinks a{padding:15px 20px;border-bottom:1px solid var(--line);border-left:4px solid transparent}
+    .navlinks a:hover{color:var(--red);border-left-color:var(--red)}
+  }
   .art{max-width:760px;margin:0 auto;padding:26px 0 10px}
   .crumb{font-size:12px;color:var(--sub);margin-bottom:16px}.crumb a{color:var(--red);font-weight:700}
   .chip{display:inline-block;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--red);margin-bottom:8px}
@@ -58,7 +68,10 @@
   </div></header>
 
   <nav><div class="wrap">
-    <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/review">The REview</a>
+    <button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
+    <div class="navlinks" id="nav">
+      <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/review">The REview</a>
+    </div>
   </div></nav>
 
   <div class="wrap"><article class="art" id="art">
@@ -118,6 +131,8 @@ async function load(){
     '<div class="src">📰 Republished live from RENTV.com. <a href="'+esc(src)+'" target="_blank" rel="noopener">View the full story on RENTV.com ↗</a></div>'+
     '<a class="back" href="/">← Back to all CRE news</a>';
 }
+const hamBtn=document.getElementById('hamBtn');
+hamBtn.addEventListener('click',()=>{const open=document.getElementById('nav').classList.toggle('open');hamBtn.setAttribute('aria-expanded',open);});
 load();
 </script>
 </body>
diff --git a/public/index.html b/public/index.html
index 4f7dde1f..b405db1c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -17,14 +17,25 @@
   .ticker{display:flex;gap:20px;overflow:hidden;white-space:nowrap}.ticker b{color:#fff}.up{color:#4ade80}.dn{color:#f87171}
   .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
   .mast{border-bottom:2px solid var(--ink);padding:18px 0 12px}
-  .mast .wrap{display:flex;align-items:center;justify-content:space-between}
+  .mast{padding:20px 0 14px}
+  .mast .wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:3px}
   .logo{font-family:var(--serif);font-weight:700;font-size:34px;letter-spacing:-1px}.logo span{color:var(--red)}
   .logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
-  .datebox{font-size:12px;color:var(--sub);text-align:right}
+  .datebox{font-size:12px;color:var(--sub);text-align:center}
   nav{border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:10}
-  nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
+  nav .wrap{display:flex;gap:2px;align-items:center;height:46px}
+  .ham{display:none;background:none;border:0;font-size:24px;line-height:1;color:var(--ink);cursor:pointer;padding:8px 12px 8px 0;margin-right:4px}
+  .navlinks{display:flex;gap:2px;align-items:center;overflow-x:auto}
   nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:14px 13px;border-bottom:3px solid transparent;white-space:nowrap;cursor:pointer}
   nav a:hover,nav a.on{color:var(--red);border-color:var(--red)}
+  @media(max-width:860px){
+    .ham{display:block;order:-1}
+    nav .wrap{height:52px}
+    .navlinks{display:none;position:absolute;top:52px;left:0;right:0;background:#fff;flex-direction:column;align-items:stretch;border-bottom:2px solid var(--ink);box-shadow:0 10px 24px rgba(0,0,0,.14);z-index:30;max-height:78vh;overflow-y:auto}
+    .navlinks.open{display:flex}
+    .navlinks a{padding:15px 20px;border-bottom:1px solid var(--line);border-left:4px solid transparent}
+    .navlinks a:hover,.navlinks a.on{color:var(--red);border-bottom:1px solid var(--line);border-left-color:var(--red)}
+  }
   .breaking{background:var(--red);color:#fff;font-size:13px}
   .breaking .wrap{display:flex;align-items:center;gap:14px;height:40px;overflow:hidden}
   .breaking .tag{background:#fff;color:var(--red);font-weight:800;text-transform:uppercase;font-size:11px;letter-spacing:.08em;padding:3px 9px;border-radius:2px;flex:none}
@@ -81,8 +92,11 @@
     <div class="datebox"><span id="edition">Western U.S. Edition</span><br><span id="today"></span></div>
   </div></header>
 
-  <nav><div class="wrap" id="nav">
-    <a data-cat="all" class="on">Home</a><a data-cat="Sales">Sales</a><a data-cat="Leases">Leases</a><a data-cat="Financing">Financing</a><a data-cat="Development">Development</a><a data-cat="Multifamily">Multifamily</a><a data-cat="Industrial">Industrial</a><a data-cat="Retail">Retail</a><a href="/review">The REview</a><a href="#conf">Conferences</a>
+  <nav><div class="wrap">
+    <button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
+    <div class="navlinks" id="nav">
+      <a data-cat="all" class="on">Home</a><a data-cat="Sales">Sales</a><a data-cat="Leases">Leases</a><a data-cat="Financing">Financing</a><a data-cat="Development">Development</a><a data-cat="Multifamily">Multifamily</a><a data-cat="Industrial">Industrial</a><a data-cat="Retail">Retail</a><a href="/review">The REview</a><a href="#conf">Conferences</a>
+    </div>
   </div></nav>
 
   <div class="breaking"><div class="wrap">
@@ -118,11 +132,12 @@
 document.getElementById('today').textContent = new Date().toLocaleDateString(undefined,{weekday:'long',year:'numeric',month:'long',day:'numeric'});
 let ALL=[], cat='all';
 const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+const bgimg=o=>(o&&o.image)?(' style="background:#12161a url(\''+esc(o.image)+'\') center/cover"'):'';
 function render(){
   const items = cat==='all'?ALL:ALL.filter(x=>x.cat===cat);
   const lead=items[0];
-  if(lead){document.getElementById('lead').innerHTML='<a href="/news/'+encodeURIComponent(lead.id)+'"><div class="img"></div><span class="chip">'+esc(lead.cat)+' · Western U.S.</span><h1>'+esc(lead.title)+'</h1><div class="byl">By RENTV Staff · live from rentv.com</div></a>';}
-  const cards=items.slice(1,13).map(x=>'<a class="card" href="/news/'+encodeURIComponent(x.id)+'"><div class="img"></div><span class="chip">'+esc(x.cat)+'</span><h4>'+esc(x.title)+'</h4><div class="byl">RENTV Staff</div></a>').join('');
+  if(lead){document.getElementById('lead').innerHTML='<a href="/news/'+encodeURIComponent(lead.id)+'"><div class="img"'+bgimg(lead)+'></div><span class="chip">'+esc(lead.cat)+' · Western U.S.</span><h1>'+esc(lead.title)+'</h1><div class="byl">By RENTV Staff · live from rentv.com</div></a>';}
+  const cards=items.slice(1,13).map(x=>'<a class="card" href="/news/'+encodeURIComponent(x.id)+'"><div class="img"'+bgimg(x)+'></div><span class="chip">'+esc(x.cat)+'</span><h4>'+esc(x.title)+'</h4><div class="byl">RENTV Staff</div></a>').join('');
   document.getElementById('cards').innerHTML=cards||'<p style="color:var(--sub)">No items in this section right now.</p>';
   document.getElementById('breaking').innerHTML=ALL.slice(0,6).map(x=>'<span>'+esc(x.title)+'</span>').join(' · ').repeat(2);
   document.getElementById('mostread').innerHTML=ALL.slice(0,5).map(x=>'<li><a href="/news/'+encodeURIComponent(x.id)+'">'+esc(x.title)+'</a></li>').join('');
@@ -138,7 +153,9 @@ async function load(){
     }
   }catch(e){document.getElementById('lead').querySelector('.byl').textContent='Live feed temporarily unavailable — retrying…';}
 }
-document.getElementById('nav').addEventListener('click',e=>{const a=e.target.closest('a[data-cat]');if(!a)return;e.preventDefault();document.querySelectorAll('#nav a').forEach(x=>x.classList.remove('on'));a.classList.add('on');cat=a.dataset.cat;render();window.scrollTo({top:0,behavior:'smooth'});});
+const hamBtn=document.getElementById('hamBtn');
+hamBtn.addEventListener('click',()=>{const open=document.getElementById('nav').classList.toggle('open');hamBtn.setAttribute('aria-expanded',open);});
+document.getElementById('nav').addEventListener('click',e=>{const a=e.target.closest('a');if(!a)return;document.getElementById('nav').classList.remove('open');hamBtn.setAttribute('aria-expanded','false');if(!a.dataset.cat)return;e.preventDefault();document.querySelectorAll('#nav a').forEach(x=>x.classList.remove('on'));a.classList.add('on');cat=a.dataset.cat;render();window.scrollTo({top:0,behavior:'smooth'});});
 document.getElementById('subbtn').onclick=()=>{if((document.getElementById('email').value||'').includes('@')){document.getElementById('subok').style.display='block';}};
 async function loadTicker(){
   try{
diff --git a/public/section.html b/public/section.html
index f2fbb714..f901801b 100644
--- a/public/section.html
+++ b/public/section.html
@@ -16,14 +16,24 @@
   .util .wrap{display:flex;justify-content:space-between;align-items:center;height:34px}
   .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
   .mast{border-bottom:2px solid var(--ink);padding:18px 0 12px}
-  .mast .wrap{display:flex;align-items:center;justify-content:space-between}
+  .mast .wrap{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:3px}
   .logo{font-family:var(--serif);font-weight:700;font-size:34px;letter-spacing:-1px}.logo span{color:var(--red)}
   .logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
-  .datebox{font-size:12px;color:var(--sub);text-align:right}
+  .datebox{font-size:12px;color:var(--sub);text-align:center}
   nav{border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:10}
-  nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
+  nav .wrap{display:flex;gap:2px;align-items:center;height:46px}
+  .ham{display:none;background:none;border:0;font-size:24px;line-height:1;color:var(--ink);cursor:pointer;padding:8px 12px 8px 0;margin-right:4px}
+  .navlinks{display:flex;gap:2px;align-items:center;overflow-x:auto}
   nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:14px 13px;border-bottom:3px solid transparent;white-space:nowrap;cursor:pointer}
   nav a:hover,nav a.on{color:var(--red);border-color:var(--red)}
+  @media(max-width:860px){
+    .ham{display:block;order:-1}
+    nav .wrap{height:52px}
+    .navlinks{display:none;position:absolute;top:52px;left:0;right:0;background:#fff;flex-direction:column;align-items:stretch;border-bottom:2px solid var(--ink);box-shadow:0 10px 24px rgba(0,0,0,.14);z-index:30;max-height:78vh;overflow-y:auto}
+    .navlinks.open{display:flex}
+    .navlinks a{padding:15px 20px;border-bottom:1px solid var(--line);border-left:4px solid transparent}
+    .navlinks a:hover,.navlinks a.on{color:var(--red);border-bottom:1px solid var(--line);border-left-color:var(--red)}
+  }
   .head{padding:30px 0 6px}
   .head .chip{display:inline-block;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--red);margin-bottom:4px}
   .head h1{font-family:var(--serif);font-size:34px;letter-spacing:-.5px}
@@ -60,8 +70,11 @@
     <div class="datebox"><span>Western U.S. Edition</span><br><span id="today"></span></div>
   </div></header>
 
-  <nav><div class="wrap" id="nav">
-    <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/review">The REview</a>
+  <nav><div class="wrap">
+    <button class="ham" id="hamBtn" aria-label="Menu" aria-expanded="false">☰</button>
+    <div class="navlinks" id="nav">
+      <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/review">The REview</a>
+    </div>
   </div></nav>
 
   <div class="wrap">
@@ -97,6 +110,8 @@ document.getElementById('chip').textContent=CAT+' · Western U.S.';
 document.getElementById('crumbcat').textContent=CAT;
 // mark active nav
 document.querySelectorAll('#nav a').forEach(a=>{if(a.getAttribute('href')==='/'+slug)a.classList.add('on');});
+const hamBtn=document.getElementById('hamBtn');
+hamBtn.addEventListener('click',()=>{const open=document.getElementById('nav').classList.toggle('open');hamBtn.setAttribute('aria-expanded',open);});
 async function load(){
   try{
     const d=await (await fetch('/api/news')).json();
@@ -105,7 +120,7 @@ async function load(){
     if(!items.length){box.className='';box.innerHTML='<p class="empty">No '+esc(CAT)+' stories in the live feed right now. <a href="/" style="color:var(--red);font-weight:700">Back to the homepage →</a></p>';return;}
     document.getElementById('count').textContent=items.length+' live '+(items.length===1?'story':'stories');
     box.className='cards';
-    box.innerHTML=items.map(x=>'<a class="card" href="/news/'+encodeURIComponent(x.id)+'"><div class="img"></div><h4>'+esc(x.title)+'</h4><div class="byl">RENTV Staff · read in-shell</div></a>').join('');
+    box.innerHTML=items.map(x=>'<a class="card" href="/news/'+encodeURIComponent(x.id)+'"><div class="img"'+((x.image)?(' style="background:#12161a url(\''+esc(x.image)+'\') center/cover"'):'')+'></div><h4>'+esc(x.title)+'</h4><div class="byl">RENTV Staff · read in-shell</div></a>').join('');
   }catch(e){
     document.getElementById('cards').innerHTML='<p class="empty">Live feed temporarily unavailable — <a href="/" style="color:var(--red);font-weight:700">back to homepage</a>.</p>';
   }
diff --git a/scripts/pull-news.mjs b/scripts/pull-news.mjs
index 5e853f30..4a15bf26 100644
--- a/scripts/pull-news.mjs
+++ b/scripts/pull-news.mjs
@@ -18,6 +18,10 @@ const clean = (t) => t.replace(/&amp;/g,'&').replace(/&#39;/g,"'").replace(/&quo
 
 try {
   const html = await fetchDecoded('https://www.rentv.com/');
+  // Map each story id -> its real property photo (linked-image form: <a href='...news/ID'><img src='.../images/dynamic/news_*.jpg'>)
+  const imgMap = {};
+  const ire = /href=['"][^'"]*\/news\/(\d+)['"]\s*>\s*<img[^>]+src=['"]([^'"]+\/images\/dynamic\/news_[^'"]+\.(?:jpe?g|png))['"]/gi;
+  let im; while ((im = ire.exec(html))) { if (!imgMap[im[1]]) imgMap[im[1]] = im[2].startsWith('http') ? im[2] : 'https://www.rentv.com/' + im[2].replace(/^\//, ''); }
   const re = /<a[^>]+href="([^"]*content\/homepage\/mainnews\/news\/(\d+))"[^>]*>([^<]{20,160})<\/a>/gi;
   const seen = new Set(), items = []; let m;
   while ((m = re.exec(html)) && items.length < 30) {
@@ -32,7 +36,7 @@ try {
       : /res |multifamily|apartment|unit/.test(t) ? 'Multifamily'
       : /industrial|warehouse|logistics/.test(t) ? 'Industrial'
       : 'Sales';
-    items.push({ id, title, url: m[1].startsWith('http') ? m[1] : 'https://www.rentv.com/' + m[1].replace(/^\//,''), cat });
+    items.push({ id, title, url: m[1].startsWith('http') ? m[1] : 'https://www.rentv.com/' + m[1].replace(/^\//,''), cat, image: imgMap[id] || '' });
   }
   const payload = { source: 'rentv.com — live', fetched_at: new Date().toISOString(), count: items.length, items };
   writeFileSync(join(OUT, 'news.json'), JSON.stringify(payload, null, 1));
diff --git a/server.js b/server.js
index 897d92d7..4e2ac351 100644
--- a/server.js
+++ b/server.js
@@ -34,12 +34,6 @@ app.get('/api/ticker', (_q, r) => {
   r.json(readJSON('ticker.json', {}));
 });
 
-// ── Rates & Markets dashboard data (Treasury curve + REIT indices, live via Yahoo) ──
-app.get('/api/markets', (_q, r) => {
-  r.set('Cache-Control', 'public, max-age=120');
-  r.json(readJSON('markets.json', {}));
-});
-
 // ── The REview: live CRE video catalog (synced from rentvreview.com by cron) ──
 app.get('/api/videos', (_q, r) => {
   r.set('Cache-Control', 'public, max-age=300');
@@ -63,22 +57,26 @@ async function fetchArticle(id) {
   let html = buf.toString('utf8');
   if ((html.match(/�/g) || []).length > 5) html = buf.toString('latin1');
 
-  const og = (p) => { const m = html.match(new RegExp('<meta[^>]+property=["\']og:' + p + '["\'][^>]+content=["\']([^"\']+)', 'i')); return m ? decode(m[1]) : ''; };
-  let title = og('title') || (html.match(/<title>([^<]+)<\/title>/i)?.[1] || '').replace(/\s*[|\-–]\s*RENTV.*/i, '');
-  title = decode(title);
-  const image = og('image');
+  // RENTV is a classic server-rendered table site — the story lives in <td class="header"> + following cells,
+  // and the real property photo is a /images/dynamic/news_*.jpg. Extract those directly (no <p> tags exist).
+  const hM = html.match(/<td class="header"[^>]*>([\s\S]*?)<\/td>/i);
+  let title = hM ? stripTags(hM[1]) : (html.match(/<title>([^<]+)<\/title>/i)?.[1] || 'RENTV Story');
+  title = decode(title).replace(/\s*[|\-–]\s*RENTV.*/i, '') || 'RENTV Story';
 
-  // Body: grab the largest cluster of <p> blocks (the article copy).
-  const paras = [];
-  const pre = html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
-  let m; const pRe = /<p[^>]*>([\s\S]*?)<\/p>/gi;
-  while ((m = pRe.exec(pre))) {
-    const txt = stripTags(m[1]);
-    if (txt.length >= 60 && !/^(©|copyright|all rights|advertise|subscribe|follow us)/i.test(txt)) paras.push(txt);
-  }
-  // de-dupe + cap
+  const imM = html.match(/["'](\/?(?:https?:\/\/[^"']*)?\/?images\/dynamic\/news_\d+_[^"']+\.(?:jpe?g|png))["']/i);
+  let image = imM ? imM[1] : '';
+  if (image && !/^https?:/i.test(image)) image = 'https://www.rentv.com/' + image.replace(/^\//, '');
+
+  // Body: take the region from the headline to a trailing marker, split on <br><br> into real paragraphs.
+  const start = hM ? hM.index + hM[0].length : 0;
+  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);
+  const paras = region.split(/<br\s*\/?>\s*(?:&nbsp;\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: title || 'RENTV Story', image, body, fetched_at: new Date().toISOString() };
+  return { id, url, title, image, body, fetched_at: new Date().toISOString() };
 }
 
 app.get('/api/article/:id', async (req, res) => {

← 71395b46 auto-save: 2026-07-20T14:32:39 (2 files) — data/news.json da  ·  back to Rentv  ·  Markets: durable additive components (markets.html + pull-ma e431d2ff →