← back to Rentv Website
server.js
124 lines
'use strict';
// rentv-v1 — full CRE-news website rebuild on LIVE rentv.com data.
// One live-data engine (news.json, refreshed by cron) + an on-demand article
// reader proxy (/api/article/:id) so stories are read INSIDE our modern shell.
const fs = require('fs');
const path = require('path');
const express = require('express');
const app = express();
const PORT = process.env.PORT || 9705;
const DATA = path.join(__dirname, 'data');
const PUB = path.join(__dirname, 'public');
const readJSON = (f, fb) => { try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); } catch { return fb; } };
// ── Basic Auth gate (unified + client logins). Health stays open for smoke-tests. ──
const CREDS = ['admin:DW2024!', 'Boomer:Rentv2024!']
.concat((process.env.BASIC_AUTH_EXTRA || '').split(',').map(s => s.trim()).filter(Boolean));
const ACCEPTED = new Set(CREDS.map(c => 'Basic ' + Buffer.from(c).toString('base64')));
app.get('/api/health', (_q, r) => r.json({ ok: true, at: new Date().toISOString() }));
app.use((req, res, next) => {
if (ACCEPTED.has(req.headers.authorization || '')) return next();
res.set('WWW-Authenticate', 'Basic realm="RENTV"');
return res.status(401).send('Authentication required');
});
// ── Live news feed (served from the cron-refreshed cache) ──
app.get('/api/news', (_q, r) => {
r.set('Cache-Control', 'public, max-age=120');
r.json(readJSON('news.json', { items: [] }));
});
// ── Live market ticker (10-YR + CRE index live via Yahoo; fundamentals quarterly) ──
app.get('/api/ticker', (_q, r) => {
r.set('Cache-Control', 'public, max-age=120');
r.json(readJSON('ticker.json', {}));
});
// ── The REview: live CRE video catalog (synced from rentvreview.com by cron) ──
// RENTV Originals (self-hosted promos) live in originals.json and are merged in
// on READ so the video-sync cron that rewrites videos.json never wipes them.
app.get('/api/videos', (_q, r) => {
r.set('Cache-Control', 'public, max-age=300');
const base = readJSON('videos.json', { items: [], cats: [] });
const orig = readJSON('originals.json', { cat: null, items: [] });
const items = Array.isArray(orig.items) ? orig.items : [];
if (items.length && orig.cat) {
base.items = [...items, ...(base.items || []).filter(x => !String(x.id).startsWith('orig-'))];
base.cats = [orig.cat, ...(base.cats || []).filter(c => c !== orig.cat)];
base.count = base.items.length;
}
r.json(base);
});
// ── LA Commercial: parcels/assessed-value/broker digest from the usrealestate backend
// (refreshed by `node pull-la-commercial.mjs`). Property-level LA CRE intelligence. ──
app.get('/api/la-commercial', (_q, r) => {
r.set('Cache-Control', 'public, max-age=300');
r.json(readJSON('la-commercial.json', { marquee: [], recent: [], firms: [] }));
});
// ── Article reader proxy: fetch a single rentv.com story on demand + cache it. ──
const artCache = new Map(); // id -> { at, data }
const ART_TTL = 15 * 60 * 1000;
const decode = (t) => (t || '')
.replace(/&/g, '&').replace(/�?39;/g, "'").replace(/’/g, "'").replace(/‘/g, "'")
.replace(/"/g, '"').replace(/“/g, '"').replace(/”/g, '"')
.replace(/ /g, ' ').replace(/�?45;/g, '-').replace(/—/g, '—').replace(/–/g, '–')
.replace(/&/g, '&').replace(/\s+/g, ' ').trim();
const stripTags = (h) => decode(h.replace(/<[^>]+>/g, ' '));
async function fetchArticle(id) {
const url = 'https://www.rentv.com/content/homepage/mainnews/news/' + encodeURIComponent(id);
const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 RENTV-reader' }, signal: AbortSignal.timeout(15000) });
const buf = Buffer.from(await r.arrayBuffer());
let html = buf.toString('utf8');
if ((html.match(/�/g) || []).length > 5) html = buf.toString('latin1');
// 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';
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*(?: \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() };
}
app.get('/api/article/:id', async (req, res) => {
const id = String(req.params.id).replace(/[^0-9]/g, '');
if (!id) return res.status(400).json({ error: 'bad id' });
const hit = artCache.get(id);
if (hit && Date.now() - hit.at < ART_TTL) { res.set('Cache-Control', 'public, max-age=300'); return res.json(hit.data); }
try {
const data = await fetchArticle(id);
artCache.set(id, { at: Date.now(), data });
res.set('Cache-Control', 'public, max-age=300');
res.json(data);
} catch (e) {
if (hit) return res.json(hit.data); // serve stale rather than fail
res.status(502).json({ error: 'fetch failed', id, url: 'https://www.rentv.com/content/homepage/mainnews/news/' + id });
}
});
// ── Clean section routes → the one section template (category read client-side from path) ──
const SECTIONS = ['financing', 'leases', 'development', 'retail', 'multifamily', 'industrial', 'sales'];
SECTIONS.forEach(s => app.get('/' + s, (_q, r) => r.sendFile(path.join(PUB, 'section.html'))));
app.get('/news/:id', (_q, r) => r.sendFile(path.join(PUB, 'article.html')));
// The REview — the folded-in video platform, served natively in the RENTV shell
app.get('/review', (_q, r) => r.sendFile(path.join(PUB, 'review.html')));
app.use(express.static(PUB, { extensions: ['html'] }));
app.listen(PORT, () => console.log('rentv-website (independent fork, gated, live data) on ' + PORT));