← back to Ventura Corridor
feat(news): /business/:id/news per-business news firehose page
5afdd26bd2a239456273d38ba24e35aa89957747 · 2026-05-07 15:29:45 -0700 · SteveStudio2
Drill-down view from any news rail. When a viewer clicks the news badge
on /chains, /buildings, the map detail panel, or /news.html, they can
land on a dedicated per-business page showing every article scraped
from that business website (full body excerpts, dates, source domain,
qwen3-summary tag when present).
Routes:
GET /business/:id/news inline-rendered HTML, no template, paper-noir
style matching /news.html. 404 on bad id,
graceful empty state when news_items is zero
("nightly 03:15 scrape will check again").
Hardening:
- safeUrl() scheme-validates http(s) before <a href> interpolation
- esc() applied to all biz-attribute values (carry-over hardening
pattern from tick 43 XSS fix)
Discovery wired:
- public/index.html showPanel news rail: "all -->" link in header
- public/buildings.html badge: now points at /business/<id>/news
(was /news.html#biz-<id> which was a non-existent anchor)
Path whitelisted in ADMIN_PATHS regex; smoke confirms 200 with content,
404 on bogus id, empty-state on real id with no news.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M public/buildings.htmlM public/index.htmlM src/server/index.ts
Diff
commit 5afdd26bd2a239456273d38ba24e35aa89957747
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Thu May 7 15:29:45 2026 -0700
feat(news): /business/:id/news per-business news firehose page
Drill-down view from any news rail. When a viewer clicks the news badge
on /chains, /buildings, the map detail panel, or /news.html, they can
land on a dedicated per-business page showing every article scraped
from that business website (full body excerpts, dates, source domain,
qwen3-summary tag when present).
Routes:
GET /business/:id/news inline-rendered HTML, no template, paper-noir
style matching /news.html. 404 on bad id,
graceful empty state when news_items is zero
("nightly 03:15 scrape will check again").
Hardening:
- safeUrl() scheme-validates http(s) before <a href> interpolation
- esc() applied to all biz-attribute values (carry-over hardening
pattern from tick 43 XSS fix)
Discovery wired:
- public/index.html showPanel news rail: "all -->" link in header
- public/buildings.html badge: now points at /business/<id>/news
(was /news.html#biz-<id> which was a non-existent anchor)
Path whitelisted in ADMIN_PATHS regex; smoke confirms 200 with content,
404 on bogus id, empty-state on real id with no news.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/buildings.html | 2 +-
public/index.html | 5 ++-
src/server/index.ts | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 96 insertions(+), 2 deletions(-)
diff --git a/public/buildings.html b/public/buildings.html
index c1aefa7..5efbd7d 100644
--- a/public/buildings.html
+++ b/public/buildings.html
@@ -357,7 +357,7 @@ async function toggleBldg(addrEl) {
${rows.map(r => `
<tr>
<td class="suite">${escapeHtml(r.suite || '')}</td>
- <td class="name">${escapeHtml(r.name)}${dupeBadge(r.business_id)}${r.news_count > 0 ? `<span class="pill" style="color:var(--metal-glow);border-color:var(--metal-glow);font-size:7px;margin-left:6px" title="${r.news_count} scraped news/blog post${r.news_count === 1 ? '' : 's'}"><a href="/news.html#biz-${r.business_id}" style="color:inherit;text-decoration:none">📰 ${r.news_count}</a></span>` : ''}</td>
+ <td class="name">${escapeHtml(r.name)}${dupeBadge(r.business_id)}${r.news_count > 0 ? `<span class="pill" style="color:var(--metal-glow);border-color:var(--metal-glow);font-size:7px;margin-left:6px" title="${r.news_count} scraped news/blog post${r.news_count === 1 ? '' : 's'}"><a href="/business/${r.business_id}/news" style="color:inherit;text-decoration:none">📰 ${r.news_count}</a></span>` : ''}</td>
<td class="naics">${escapeHtml((r.naics || '').slice(0, 50))}</td>
<td>${r.pitch_id
? `<span class="pill ${r.status}">${escapeHtml(r.status)}</span>`
diff --git a/public/index.html b/public/index.html
index 01c9313..9277400 100644
--- a/public/index.html
+++ b/public/index.html
@@ -337,7 +337,10 @@ async function showPanel(id) {
}).join('');
return `
<div style="margin:14px 0 0;padding:10px 12px;background:rgba(184,153,104,0.08);border:1px solid var(--rule)">
- <div style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal);margin-bottom:6px">📰 News from their site · ${newsItems.length}</div>
+ <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
+ <div style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal)">📰 News from their site · ${newsItems.length}</div>
+ <a href="/business/${id}/news" style="font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--metal-glow);text-decoration:none;border-bottom:1px dotted var(--metal-glow)">all →</a>
+ </div>
${items}
</div>`;
})() : '';
diff --git a/src/server/index.ts b/src/server/index.ts
index 50baca8..0bd59d5 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -114,6 +114,7 @@ const ADMIN_PATHS = [
/^\/api\/buildings(\.csv|\/.*)?$/i,
/^\/news(\.html)?\/?$/i,
/^\/news\/feed\.xml\/?$/i,
+ /^\/business\/\d+\/news\/?$/i,
/^\/this-week(\.html)?\/?$/i,
/^\/api\/news(\/.*)?$/i,
/^\/pitch\/\d+\/?$/i,
@@ -5016,6 +5017,96 @@ app.get('/api/news/recent', async (req, res) => {
}
});
+// Per-business news page. When a viewer clicks the news badge from /chains,
+// /buildings, the map detail panel, or /news.html, they can drill into the
+// full firehose for that single business instead of getting the boulevard
+// digest. Renders inline HTML to keep this self-contained (no .ejs).
+app.get('/business/:id/news', async (req, res) => {
+ try {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id)) return res.status(400).send('bad id');
+ const biz = await query(`SELECT id, name, city, website, category FROM businesses WHERE id = $1`, [id]);
+ if (!biz.rowCount) return res.status(404).send('not found');
+ const b = biz.rows[0];
+ const items = await query(`
+ SELECT id, source_url, title, excerpt, summary, published_guess, fetched_at
+ FROM news_items WHERE business_id = $1
+ ORDER BY COALESCE(published_guess, fetched_at::date) DESC,
+ fetched_at DESC
+ `, [id]);
+ const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
+ const safeUrl = (raw: string) => {
+ try {
+ const u = new URL(String(raw || ''));
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return '#';
+ return esc(u.toString());
+ } catch { return '#'; }
+ };
+ const fmt = (d: any) => d ? new Date(d).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) : '';
+ const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } };
+ const itemsHtml = items.rows.map((n: any) => {
+ const text = n.summary || n.excerpt || '';
+ return `
+ <article class="ni">
+ <h3 class="ni-title"><a href="${safeUrl(n.source_url)}" target="_blank" rel="noopener noreferrer">${esc((n.title || '(untitled)').slice(0, 200))}</a></h3>
+ <p class="ni-body">${esc(text.slice(0, 500))}${text.length > 500 ? '…' : ''}</p>
+ <div class="ni-meta">
+ <span>${esc(fmt(n.published_guess || n.fetched_at))}</span>
+ <span>·</span>
+ <span>${esc(host(n.source_url))}</span>
+ ${n.summary ? '<span class="ni-tag">qwen3 summary</span>' : '<span class="ni-tag muted">awaiting summary</span>'}
+ </div>
+ </article>`;
+ }).join('');
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.send(`<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8">
+<title>${esc(b.name)} — News · Ventura Corridor</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@300;400;500&family=JetBrains+Mono:wght@200;300&display=swap');
+:root{--noir:#0a0a0c;--noir-rise:#131316;--ink:#f0ece2;--ink-mute:#888475;--metal:#b89968;--metal-glow:#d4b683;--rule:#2a2622}
+*{box-sizing:border-box}body{margin:0;background:var(--noir);color:var(--ink);font-family:Inter,sans-serif;font-weight:300}
+header.crumb{padding:14px 32px;border-bottom:1px solid var(--rule);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)}
+header.crumb a{color:var(--metal);text-decoration:none}
+.hero{padding:48px 32px 32px;border-bottom:1px solid var(--rule);max-width:920px;margin:0 auto}
+.hero .kicker{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);margin-bottom:8px}
+.hero h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:48px;line-height:1.05;margin:0;color:var(--ink)}
+.hero h1 em{font-style:normal;color:var(--metal-glow)}
+.hero .meta{margin-top:14px;font-size:13px;color:var(--ink-mute)}
+.hero .meta a{color:var(--metal);text-decoration:none}
+main{max-width:920px;margin:0 auto;padding:32px}
+.empty{padding:80px 0;text-align:center;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:18px}
+.ni{padding:24px 0;border-bottom:1px dotted var(--rule);display:flex;flex-direction:column;gap:8px}
+.ni-title{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:24px;line-height:1.2;margin:0}
+.ni-title a{color:var(--ink);text-decoration:none}
+.ni-title a:hover{color:var(--metal-glow)}
+.ni-body{font-size:14px;line-height:1.55;color:var(--ink-mute);margin:0}
+.ni-meta{font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.18em;color:var(--ink-mute);text-transform:uppercase;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
+.ni-tag{padding:2px 6px;border:1px solid var(--rule);color:var(--metal-glow)}
+.ni-tag.muted{color:var(--ink-mute)}
+</style>
+</head><body>
+<header class="crumb">
+ <a href="/news.html">← all corridor news</a>
+</header>
+<section class="hero">
+ <p class="kicker">${esc(b.category || 'corridor')}${b.city ? ' · ' + esc(b.city) : ''}</p>
+ <h1>News from <em>${esc(b.name)}</em></h1>
+ <p class="meta">${items.rowCount} article${items.rowCount === 1 ? '' : 's'} scraped from
+ ${b.website ? `<a href="${safeUrl(b.website)}" target="_blank" rel="noopener noreferrer">${esc(host(b.website) || b.website)}</a>` : 'their website'}
+ </p>
+</section>
+<main>
+ ${items.rowCount === 0 ? '<div class="empty">No news scraped yet. The nightly 03:15 scrape will check their site again.</div>' : itemsHtml}
+</main>
+</body></html>`);
+ } catch (e: any) {
+ res.status(500).send('error: ' + e.message);
+ }
+});
+
// RSS feed of scraped news_items. Steve plugs the URL into NetNewsWire /
// Feedbin / any RSS reader and gets every fresh corridor news item without
// opening the magazine. 50-item cap, sorted by fetched_at DESC. Each item's
← b329ecf feat(news): RSS feed at /news/feed.xml + auto-discovery on /
·
back to Ventura Corridor
·
feat(news): /api/news/by-vertical + heatmap on /coverage.htm ec89429 →