[object Object]

← 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

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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 →