← 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
M public/news.htmlM src/server/index.ts
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →