[object Object]

← 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

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