[object Object]

← back to Ventura Corridor

feat(news): RSS feed at /news/feed.xml + auto-discovery on /news.html

b329ecfca40f29dd7fb37fef0c0e23c0849547ab · 2026-05-07 14:57:21 -0700 · SteveStudio2

Steve plugs the URL into NetNewsWire / Feedbin / any feed reader and
gets every fresh corridor news item without opening the magazine.

Routes:
  GET /news/feed.xml      RSS 2.0, 50 most-recent items, 5-min cache
                          Item description prefers qwen3 summary, falls
                          back to body excerpt; pubDate prefers
                          published_guess, falls back to fetched_at.
                          Source URL scheme-validated (http/https only)
                          before going into <link>; rejects javascript:
                          falls back to /news.html.

Discovery:
  public/news.html <head>  +link rel=alternate type=application/rss+xml
                            so feed readers auto-detect when subscribed
                            to /news.html.

Path whitelisted in ADMIN_PATHS regex list. xmllint validates clean,
50 items flowing on first smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b329ecfca40f29dd7fb37fef0c0e23c0849547ab
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 14:57:21 2026 -0700

    feat(news): RSS feed at /news/feed.xml + auto-discovery on /news.html
    
    Steve plugs the URL into NetNewsWire / Feedbin / any feed reader and
    gets every fresh corridor news item without opening the magazine.
    
    Routes:
      GET /news/feed.xml      RSS 2.0, 50 most-recent items, 5-min cache
                              Item description prefers qwen3 summary, falls
                              back to body excerpt; pubDate prefers
                              published_guess, falls back to fetched_at.
                              Source URL scheme-validated (http/https only)
                              before going into <link>; rejects javascript:
                              falls back to /news.html.
    
    Discovery:
      public/news.html <head>  +link rel=alternate type=application/rss+xml
                                so feed readers auto-detect when subscribed
                                to /news.html.
    
    Path whitelisted in ADMIN_PATHS regex list. xmllint validates clean,
    50 items flowing on first smoke.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/news.html    |  1 +
 src/server/index.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 58 insertions(+)

diff --git a/public/news.html b/public/news.html
index 54b627a..fee111b 100644
--- a/public/news.html
+++ b/public/news.html
@@ -4,6 +4,7 @@
 <meta charset="utf-8">
 <title>News · The Corridor</title>
 <meta name="viewport" content="width=device-width,initial-scale=1">
+<link rel="alternate" type="application/rss+xml" title="Ventura Corridor — News from the Boulevard" href="/news/feed.xml">
 <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}
diff --git a/src/server/index.ts b/src/server/index.ts
index e4e38ff..50baca8 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -113,6 +113,7 @@ const ADMIN_PATHS = [
   /^\/buildings\/[^/]+\/issue\/?$/i,
   /^\/api\/buildings(\.csv|\/.*)?$/i,
   /^\/news(\.html)?\/?$/i,
+  /^\/news\/feed\.xml\/?$/i,
   /^\/this-week(\.html)?\/?$/i,
   /^\/api\/news(\/.*)?$/i,
   /^\/pitch\/\d+\/?$/i,
@@ -5015,6 +5016,62 @@ app.get('/api/news/recent', async (req, res) => {
   }
 });
 
+// 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
+// description prefers the qwen3 summary when available, else the body excerpt.
+app.get('/news/feed.xml', async (_req, res) => {
+  try {
+    const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
+    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.city
+        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 50
+    `);
+    const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
+    const items = r.rows.map((n: any) => {
+      const desc = n.summary || n.excerpt || '';
+      const dt = n.published_guess || n.fetched_at;
+      const linkBase = (() => {
+        try {
+          const u = new URL(String(n.source_url || ''));
+          if (u.protocol === 'http:' || u.protocol === 'https:') return u.toString();
+        } catch {}
+        return baseUrl + '/news.html';
+      })();
+      return `
+    <item>
+      <title>${esc(n.business_name || '')} — ${esc((n.title || '(untitled)').slice(0, 200))}</title>
+      <link>${esc(linkBase)}</link>
+      <guid isPermaLink="false">vc-news-${n.id}</guid>
+      <pubDate>${new Date(dt || Date.now()).toUTCString()}</pubDate>
+      <category>${esc(n.city || 'corridor')}</category>
+      <description>${esc(desc.slice(0, 600))}</description>
+      <source url="${baseUrl}/news.html">Ventura Corridor news</source>
+    </item>`;
+    }).join('');
+    res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8');
+    res.setHeader('Cache-Control', 'public, max-age=300');
+    res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>Ventura Corridor — News from the Boulevard</title>
+    <link>${baseUrl}/news.html</link>
+    <description>Scraped news, blog, and press posts from every business on Ventura Bl with a website.</description>
+    <language>en-us</language>
+    <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${items}
+  </channel>
+</rss>`);
+  } catch (e: any) {
+    res.status(500).send('error: ' + e.message);
+  }
+});
+
 // Editorial digest — curated "this week on the boulevard" structure.
 // Lead 3 = newest items with a body of 200+ chars (anything shorter is a
 // thin hit-and-miss home-page slug). Tail = rest, sorted recent-first.

← 4cada62 feat(news): news_count badge on /chains.html bar rows  ·  back to Ventura Corridor  ·  feat(news): /business/:id/news per-business news firehose pa 5afdd26 →