← back to Ventura Corridor
feat(news): per-vertical RSS feeds via /news/feed.xml?vertical=...
52178dfa0e2e64aacdf95240f678b965686d406f · 2026-05-07 17:56:09 -0700 · SteveStudio2
Same RSS handler now optionally filters by OSM category. Steve plugs
distinct URLs into NetNewsWire / Feedbin and gets a feed-per-vertical
without polluting his all-corridor stream.
URL forms accepted (all normalized server-side):
?vertical=amenity top-cat: matches all amenity:*
?vertical=amenity:restaurant exact subcategory
?vertical=amenity-restaurant hyphen alternative
?vertical=amenity:fast_food underscore allowed
?vertical=amenity:fast%20food space allowed (percent-encoded)
Tokenization: split on [:-_\s], single token = top-cat ILIKE 'cat: %',
two-or-more tokens = exact match against "cat: rest of name".
Channel title gets a (vertical) suffix when filtered so feed-reader
panes show the right feed name.
Discovery: 7 <link rel=alternate type=application/rss+xml> tags added
to /news.html <head> for the popular categories so RSS readers
auto-detect every flavor when subscribed to /news.html.
Real numbers: amenity 41, fast_food 9, lawyers 8, restaurants 5,
shop 17, dentists 5, etc. xmllint validates clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M public/news.htmlM src/server/index.ts
Diff
commit 52178dfa0e2e64aacdf95240f678b965686d406f
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Thu May 7 17:56:09 2026 -0700
feat(news): per-vertical RSS feeds via /news/feed.xml?vertical=...
Same RSS handler now optionally filters by OSM category. Steve plugs
distinct URLs into NetNewsWire / Feedbin and gets a feed-per-vertical
without polluting his all-corridor stream.
URL forms accepted (all normalized server-side):
?vertical=amenity top-cat: matches all amenity:*
?vertical=amenity:restaurant exact subcategory
?vertical=amenity-restaurant hyphen alternative
?vertical=amenity:fast_food underscore allowed
?vertical=amenity:fast%20food space allowed (percent-encoded)
Tokenization: split on [:-_\s], single token = top-cat ILIKE 'cat: %',
two-or-more tokens = exact match against "cat: rest of name".
Channel title gets a (vertical) suffix when filtered so feed-reader
panes show the right feed name.
Discovery: 7 <link rel=alternate type=application/rss+xml> tags added
to /news.html <head> for the popular categories so RSS readers
auto-detect every flavor when subscribed to /news.html.
Real numbers: amenity 41, fast_food 9, lawyers 8, restaurants 5,
shop 17, dentists 5, etc. xmllint validates clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/news.html | 7 +++++++
src/server/index.ts | 33 +++++++++++++++++++++++++++++----
2 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/public/news.html b/public/news.html
index fee111b..e078bd6 100644
--- a/public/news.html
+++ b/public/news.html
@@ -5,6 +5,13 @@
<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">
+<link rel="alternate" type="application/rss+xml" title="Restaurants" href="/news/feed.xml?vertical=amenity:restaurant">
+<link rel="alternate" type="application/rss+xml" title="Cafes" href="/news/feed.xml?vertical=amenity:cafe">
+<link rel="alternate" type="application/rss+xml" title="Clinics" href="/news/feed.xml?vertical=amenity:clinic">
+<link rel="alternate" type="application/rss+xml" title="Dentists" href="/news/feed.xml?vertical=amenity:dentist">
+<link rel="alternate" type="application/rss+xml" title="Fast food" href="/news/feed.xml?vertical=amenity:fast_food">
+<link rel="alternate" type="application/rss+xml" title="Lawyers" href="/news/feed.xml?vertical=office:lawyer">
+<link rel="alternate" type="application/rss+xml" title="Retail (shop)" href="/news/feed.xml?vertical=shop">
<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 0fb8bb8..075420b 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -5196,20 +5196,43 @@ main{max-width:920px;margin:0 auto;padding:32px}
// 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) => {
+app.get('/news/feed.xml', async (req, res) => {
try {
const _esc0 = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
const baseUrl = _esc0(process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780');
+ // ?vertical=amenity:restaurant or ?vertical=shop yields a category-filtered
+ // feed. We normalize colons-in-URL by also accepting a hyphen form
+ // (?vertical=amenity-restaurant -> "amenity: restaurant"). Empty / absent
+ // value returns the firehose.
+ // Accept any of: "amenity", "amenity:restaurant", "amenity: restaurant",
+ // "amenity-restaurant", "amenity_restaurant". Normalize to a SQL pattern
+ // that matches either "amenity: restaurant" exactly or any "amenity: %"
+ // child when only the top-cat is given.
+ let vertical = String(req.query.vertical || '').trim().slice(0, 60).toLowerCase();
+ const params: any[] = [];
+ let where = '';
+ if (vertical) {
+ const tokens = vertical.split(/[:\-_\s]+/).filter(Boolean);
+ if (tokens.length === 1) {
+ params.push(tokens[0] + ': %');
+ where = `WHERE b.category ILIKE $1`;
+ } else if (tokens.length >= 2) {
+ const exact = tokens[0] + ': ' + tokens.slice(1).join(' ');
+ params.push(exact);
+ where = `WHERE b.category ILIKE $1`;
+ }
+ }
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
+ ${where}
ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
n.fetched_at DESC
LIMIT 50
- `);
+ `, params);
const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));
const items = r.rows.map((n: any) => {
const desc = n.summary || n.excerpt || '';
@@ -5234,12 +5257,14 @@ app.get('/news/feed.xml', async (_req, res) => {
}).join('');
res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=300');
+ const titleSuffix = vertical ? ` (${_esc0(vertical)})` : '';
+ const descExtra = vertical ? ` Filtered to ${_esc0(vertical)}.` : '';
res.send(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
- <title>Ventura Corridor — News from the Boulevard</title>
+ <title>Ventura Corridor — News from the Boulevard${titleSuffix}</title>
<link>${baseUrl}/news.html</link>
- <description>Scraped news, blog, and press posts from every business on Ventura Bl with a website.</description>
+ <description>Scraped news, blog, and press posts from every business on Ventura Bl with a website.${descExtra}</description>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${items}
</channel>
← 840b1a4 fix(news): hardenings caught by codex-2way round 1 audit
·
back to Ventura Corridor
·
feat(news): per-business RSS via /news/feed.xml?business=N 3b4d523 →