← back to Ventura Corridor
feat(news): /api/news/recent + /news.html magazine surface
e8b2f732595c2b5fae4b87a3209a00954d1a6282 · 2026-05-07 10:04:31 -0700 · SteveStudio2
Closes the news-feed loop end-to-end:
scraper -> news_items -> /api/news/recent -> /news.html magazine rail
Routes:
GET /api/news/recent?limit=N most-recent first across all businesses
GET /api/news/by-business/:id per-business article list
UI (/news.html):
- Noir editorial style matching /calendar.html and /coverage.html
- 4-stat header (articles, businesses, summarized, last 7 days)
- Live filter + sort (recent / biz A-Z / title A-Z)
- Card grid w/ source link, title, excerpt, host, date
- "awaiting qwen3 summary..." placeholder when summary missing
Both paths whitelisted in ADMIN_PATHS so basic-auth gates them like the
rest of the corridor admin tools.
Background scrape across 153 sites: 76 articles inserted across 46
distinct businesses (50 percent biz hit rate, up from 26 percent in tick 36 smoke).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A public/news.htmlM src/server/index.ts
Diff
commit e8b2f732595c2b5fae4b87a3209a00954d1a6282
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Thu May 7 10:04:31 2026 -0700
feat(news): /api/news/recent + /news.html magazine surface
Closes the news-feed loop end-to-end:
scraper -> news_items -> /api/news/recent -> /news.html magazine rail
Routes:
GET /api/news/recent?limit=N most-recent first across all businesses
GET /api/news/by-business/:id per-business article list
UI (/news.html):
- Noir editorial style matching /calendar.html and /coverage.html
- 4-stat header (articles, businesses, summarized, last 7 days)
- Live filter + sort (recent / biz A-Z / title A-Z)
- Card grid w/ source link, title, excerpt, host, date
- "awaiting qwen3 summary..." placeholder when summary missing
Both paths whitelisted in ADMIN_PATHS so basic-auth gates them like the
rest of the corridor admin tools.
Background scrape across 153 sites: 76 articles inserted across 46
distinct businesses (50 percent biz hit rate, up from 26 percent in tick 36 smoke).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/news.html | 168 ++++++++++++++++++++++++++++++++++++++++++++++++++++
src/server/index.ts | 43 ++++++++++++++
2 files changed, 211 insertions(+)
diff --git a/public/news.html b/public/news.html
new file mode 100644
index 0000000..54b627a
--- /dev/null
+++ b/public/news.html
@@ -0,0 +1,168 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>News · The 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@200;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;--green:#6a9b73;--rule:#2a2622}
+*{box-sizing:border-box}body{margin:0;background:var(--noir);color:var(--ink);font-family:Inter,sans-serif;font-weight:300}
+header{padding:30px 32px 16px;border-bottom:1px solid var(--rule)}
+header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:400;font-size:32px;margin:0}
+header h1 em{color:var(--metal)}
+header .sub{font-size:11px;letter-spacing:.26em;text-transform:uppercase;color:var(--ink-mute);margin-top:4px}
+nav{padding:10px 32px;border-bottom:1px solid var(--rule);display:flex;gap:12px;flex-wrap:wrap;font-size:9px;letter-spacing:.26em;text-transform:uppercase}
+nav a{color:var(--ink-mute);text-decoration:none;padding:5px 10px;border:1px solid var(--rule)}
+nav a:hover, nav a.active{color:var(--metal);border-color:var(--metal)}
+
+main{padding:32px;max-width:1280px;margin:0 auto}
+
+.summary{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-bottom:32px}
+.stat{background:var(--noir-rise);border:1px solid var(--rule);padding:24px}
+.stat .v{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:42px;line-height:1;color:var(--metal-glow);font-weight:500}
+.stat .l{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--ink-mute);margin-top:8px}
+
+.controls{display:flex;gap:14px;align-items:center;justify-content:space-between;margin-bottom:18px;flex-wrap:wrap}
+.controls .left{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
+input[type=search]{background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);padding:9px 14px;font-family:inherit;font-size:13px;font-weight:300;min-width:280px;letter-spacing:.04em}
+input[type=search]:focus{outline:none;border-color:var(--metal)}
+select{background:var(--noir-rise);color:var(--ink);border:1px solid var(--rule);padding:9px 12px;font-family:inherit;font-size:11px;letter-spacing:.18em;text-transform:uppercase}
+
+.list{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:18px}
+.card{background:var(--noir-rise);border:1px solid var(--rule);padding:22px 22px 18px;display:flex;flex-direction:column;gap:10px;transition:border-color .15s,transform .15s}
+.card:hover{border-color:var(--metal)}
+.card .biz{font-size:10px;letter-spacing:.26em;text-transform:uppercase;color:var(--metal);font-weight:400}
+.card .title{font-family:'Cormorant Garamond',serif;font-weight:500;font-size:22px;line-height:1.25;margin:0;color:var(--ink)}
+.card .summary-text{font-size:13px;line-height:1.5;color:var(--ink-mute);margin:0;font-weight:300}
+.card .meta{font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--ink-mute);display:flex;gap:14px;justify-content:space-between;margin-top:auto;padding-top:10px;border-top:1px solid var(--rule)}
+.card .meta a{color:var(--metal);text-decoration:none;border-bottom:1px dotted var(--metal)}
+.card .meta a:hover{color:var(--metal-glow)}
+.card .needs-summary{color:#7a6;font-style:italic;font-size:11px}
+
+.empty{padding:80px 0;text-align:center;color:var(--ink-mute);font-style:italic;font-family:'Cormorant Garamond',serif;font-size:18px}
+</style>
+</head>
+<body>
+<header>
+ <h1><em>News</em> from the boulevard</h1>
+ <div class="sub">scraped from every vendor's own website · qwen3 summaries</div>
+</header>
+<nav>
+ <a href="/">Map</a>
+ <a href="/magazine.html">Magazine</a>
+ <a href="/news.html" class="active">News</a>
+ <a href="/calendar.html">Calendar</a>
+ <a href="/buildings.html">Buildings</a>
+ <a href="/coverage.html">Coverage</a>
+</nav>
+
+<main>
+ <div class="summary" id="summary">
+ <div class="stat"><div class="v" id="s-items">·</div><div class="l">articles</div></div>
+ <div class="stat"><div class="v" id="s-biz">·</div><div class="l">businesses</div></div>
+ <div class="stat"><div class="v" id="s-summarized">·</div><div class="l">summarized</div></div>
+ <div class="stat"><div class="v" id="s-fresh">·</div><div class="l">last 7 days</div></div>
+ </div>
+
+ <div class="controls">
+ <div class="left">
+ <input type="search" id="q" placeholder="Filter by business, title, or text…" autocomplete="off">
+ <select id="sort">
+ <option value="recent">Most recent</option>
+ <option value="biz">By business A→Z</option>
+ <option value="title">By title A→Z</option>
+ </select>
+ </div>
+ <div style="font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--ink-mute)" id="status">loading…</div>
+ </div>
+
+ <div id="list" class="list"></div>
+</main>
+
+<script>
+let ROWS = [];
+const fmtDate = (s) => {
+ if (!s) return '';
+ const d = new Date(s);
+ if (isNaN(d.getTime())) return '';
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+};
+const escHtml = (s) => String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
+const truncate = (s, n) => {
+ if (!s) return '';
+ return s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, '') + '…' : s;
+};
+
+function render() {
+ const q = (document.getElementById('q').value || '').toLowerCase().trim();
+ const sort = document.getElementById('sort').value;
+ let rows = ROWS;
+ if (q) {
+ rows = rows.filter(r =>
+ (r.business_name||'').toLowerCase().includes(q) ||
+ (r.title||'').toLowerCase().includes(q) ||
+ (r.summary||r.excerpt||'').toLowerCase().includes(q)
+ );
+ }
+ rows = rows.slice();
+ if (sort === 'biz') {
+ rows.sort((a, b) => (a.business_name||'').localeCompare(b.business_name||''));
+ } else if (sort === 'title') {
+ rows.sort((a, b) => (a.title||'').localeCompare(b.title||''));
+ }
+ // 'recent' is the server's natural order
+
+ const list = document.getElementById('list');
+ if (!rows.length) {
+ list.innerHTML = '<div class="empty">No articles match.</div>';
+ return;
+ }
+ list.innerHTML = rows.map(r => {
+ const text = r.summary || r.excerpt || '';
+ const needsSummary = !r.summary;
+ const date = r.published_guess || r.fetched_at;
+ let host = '';
+ try { host = new URL(r.source_url).hostname.replace(/^www\./, ''); } catch(e) {}
+ return [
+ '<a href="', escHtml(r.source_url), '" target="_blank" rel="noopener noreferrer" class="card">',
+ '<div class="biz">', escHtml(r.business_name || ''), '</div>',
+ '<h3 class="title">', escHtml(truncate(r.title || '(untitled)', 100)), '</h3>',
+ '<p class="summary-text">', escHtml(truncate(text, 280)), '</p>',
+ needsSummary ? '<div class="needs-summary">awaiting qwen3 summary…</div>' : '',
+ '<div class="meta">',
+ '<span>', escHtml(fmtDate(date)), '</span>',
+ '<span>', escHtml(host), '</span>',
+ '</div>',
+ '</a>'
+ ].join('');
+ }).join('');
+}
+
+async function load() {
+ try {
+ const r = await fetch('/api/news/recent?limit=200');
+ const d = await r.json();
+ ROWS = d.rows || [];
+ document.getElementById('s-items').textContent = ROWS.length;
+ const biz = new Set(ROWS.map(r => r.business_id));
+ document.getElementById('s-biz').textContent = biz.size;
+ const summarized = ROWS.filter(r => r.summary).length;
+ document.getElementById('s-summarized').textContent = summarized;
+ const week = Date.now() - 7 * 86400 * 1000;
+ const fresh = ROWS.filter(r => new Date(r.fetched_at).getTime() > week).length;
+ document.getElementById('s-fresh').textContent = fresh;
+ document.getElementById('status').textContent = ROWS.length + ' total';
+ render();
+ } catch (e) {
+ document.getElementById('status').textContent = 'load error';
+ document.getElementById('list').innerHTML = '<div class="empty">Failed to load news. Is the server up?</div>';
+ }
+}
+
+document.getElementById('q').addEventListener('input', () => render());
+document.getElementById('sort').addEventListener('change', () => render());
+load();
+</script>
+</body>
+</html>
diff --git a/src/server/index.ts b/src/server/index.ts
index b5e2de8..424a436 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -112,6 +112,8 @@ const ADMIN_PATHS = [
/^\/buildings(\.html)?\/?$/i,
/^\/buildings\/[^/]+\/issue\/?$/i,
/^\/api\/buildings(\.csv|\/.*)?$/i,
+ /^\/news(\.html)?\/?$/i,
+ /^\/api\/news(\/.*)?$/i,
/^\/pitch\/\d+\/?$/i,
];
app.use((req, res, next) => {
@@ -4963,6 +4965,47 @@ app.get('/api/tongues', async (_req, res) => {
}
});
+// ─── News feed (scraped per-business news/blog/press posts) ──────────
+// Reads news_items populated by src/crawl/news_scraper.ts. Magazine surface;
+// gated like the rest of the corridor admin tools.
+app.get('/api/news/recent', async (req, res) => {
+ try {
+ const limit = Math.min(parseInt(String(req.query.limit || '40'), 10) || 40, 200);
+ const r = await query(`
+ SELECT n.id, n.business_id, n.source_url, n.title, n.excerpt, n.summary,
+ n.published_guess, n.fetched_at,
+ b.name AS business_name, b.category, b.city, b.website
+ FROM news_items n
+ JOIN businesses b ON b.id = n.business_id
+ ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
+ n.fetched_at DESC
+ LIMIT $1
+ `, [limit]);
+ res.set('Cache-Control', 'public, max-age=120');
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.get('/api/news/by-business/:id', async (req, res) => {
+ try {
+ const id = parseInt(req.params.id, 10);
+ if (!id) return res.status(400).json({ error: 'bad_id' });
+ const r = await query(`
+ SELECT n.id, n.source_url, n.title, n.excerpt, n.summary,
+ n.published_guess, n.fetched_at
+ FROM news_items n
+ WHERE n.business_id = $1
+ ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
+ n.fetched_at DESC
+ `, [id]);
+ res.json({ count: r.rowCount, business_id: id, rows: r.rows });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// ─── Boulevard H1 Mural (extracted headlines per business) ───────────
// Public — editorial / cultural surface, not competitive intel.
app.get('/api/headlines', async (_req, res) => {
← 2422375 feat(news): per-business news/blog/press scraper + qwen3 sum
·
back to Ventura Corridor
·
feat(news): nightly launchd jobs + patient summarizer backof 0a78f9e →