← back to Ventura Corridor
feat(news): /api/news/by-vertical + heatmap on /coverage.html
ec89429eb9f5a9452ffef8e2736193c0675beda2 · 2026-05-07 16:01:14 -0700 · SteveStudio2
Surfaces which OSM categories are publishing the most blog/news/press
content. New section on /coverage.html below the existing
"By vertical / By city" coverage bars, but reflecting scraped news
volume not magazine-feature coverage.
Routes:
GET /api/news/by-vertical {category, top_cat, news_count, biz_count}
ordered by news_count DESC; CTE joins
news_items + businesses; 5-min cache.
UI changes:
public/coverage.html new "News heatmap" section with bar rows
(gold gradient fill); lazy-loaded after
the existing coverage bars so the page
still renders if news_items is empty;
graceful empty state + error handler.
Real numbers right now: amenity:restaurant 16 articles / 8 biz,
shop:hairdresser 12 / 6, office:lawyer 9 / 4, etc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M public/coverage.htmlM src/server/index.ts
Diff
commit ec89429eb9f5a9452ffef8e2736193c0675beda2
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Thu May 7 16:01:14 2026 -0700
feat(news): /api/news/by-vertical + heatmap on /coverage.html
Surfaces which OSM categories are publishing the most blog/news/press
content. New section on /coverage.html below the existing
"By vertical / By city" coverage bars, but reflecting scraped news
volume not magazine-feature coverage.
Routes:
GET /api/news/by-vertical {category, top_cat, news_count, biz_count}
ordered by news_count DESC; CTE joins
news_items + businesses; 5-min cache.
UI changes:
public/coverage.html new "News heatmap" section with bar rows
(gold gradient fill); lazy-loaded after
the existing coverage bars so the page
still renders if news_items is empty;
graceful empty state + error handler.
Real numbers right now: amenity:restaurant 16 articles / 8 biz,
shop:hairdresser 12 / 6, office:lawyer 9 / 4, etc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/coverage.html | 29 +++++++++++++++++++++++++++++
src/server/index.ts | 30 ++++++++++++++++++++++++++++++
2 files changed, 59 insertions(+)
diff --git a/public/coverage.html b/public/coverage.html
index e84c531..882fedd 100644
--- a/public/coverage.html
+++ b/public/coverage.html
@@ -84,6 +84,12 @@ main{padding:32px;max-width:1200px;margin:0 auto}
<div id="city"></div>
</div>
+<div class="bar-section">
+ <h3>News heatmap · which categories are publishing</h3>
+ <p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 14px">Articles scraped from each business's own website, bucketed by OSM category. Top buckets currently producing the most blog/news/press content.</p>
+ <div id="news-heat"></div>
+</div>
+
</main>
<script>
function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
@@ -130,6 +136,29 @@ async function load() {
renderBars(d.by_vertical, document.getElementById('vert'));
renderBars(d.by_city, document.getElementById('city'));
+ // News heatmap — pulled separately so coverage.html still renders if news data is empty.
+ try {
+ const nv = await fetch('/api/news/by-vertical').then(r => r.json());
+ const rows = nv.rows || [];
+ if (rows.length === 0) {
+ document.getElementById('news-heat').innerHTML = '<p style="color:var(--ink-mute);font-style:italic;font-family:Cormorant Garamond,serif">No scraped news yet. The 03:15 cron checks every site nightly.</p>';
+ } else {
+ const max = Math.max(1, ...rows.map(r => Number(r.news_count)));
+ document.getElementById('news-heat').innerHTML = rows.map(r => {
+ const fillW = (Number(r.news_count) / max * 100).toFixed(1);
+ return `<div class="bar-row">
+ <span class="name">${escHtml(r.category)}</span>
+ <div class="track" style="width:${fillW}%;border-color:var(--rule)">
+ <div class="filled" style="width:100%;background:linear-gradient(90deg,var(--metal),var(--metal-glow))"></div>
+ </div>
+ <span class="v"><b>${r.news_count}</b> · <span style="color:var(--metal)">${r.biz_count} biz</span></span>
+ </div>`;
+ }).join('');
+ }
+ } catch (e) {
+ document.getElementById('news-heat').innerHTML = '<p style="color:#c47b7b">news heatmap unavailable</p>';
+ }
+
// Growth line
const hist = await fetch('/api/coverage/history').then(r => r.json()).catch(() => ({ snapshots: [] }));
const snaps = hist.snapshots || [];
diff --git a/src/server/index.ts b/src/server/index.ts
index 0bd59d5..75e6b45 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -5017,6 +5017,36 @@ app.get('/api/news/recent', async (req, res) => {
}
});
+// News by vertical — heatmap source for /coverage.html. Buckets by full
+// OSM-style category (e.g., "amenity: restaurant", "shop: hairdresser") and
+// reports both raw articles and distinct businesses publishing.
+app.get('/api/news/by-vertical', async (_req, res) => {
+ try {
+ const r = await query(`
+ WITH cat_news AS (
+ SELECT b.category,
+ COUNT(*) AS news_count,
+ COUNT(DISTINCT n.business_id) AS biz_count
+ FROM news_items n
+ JOIN businesses b ON b.id = n.business_id
+ WHERE b.category IS NOT NULL
+ GROUP BY b.category
+ )
+ SELECT category,
+ split_part(category, ':', 1) AS top_cat,
+ news_count::int,
+ biz_count::int
+ FROM cat_news
+ ORDER BY news_count DESC, category ASC
+ LIMIT 60
+ `);
+ res.set('Cache-Control', 'public, max-age=300');
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// Per-business news page. When a viewer clicks the news badge from /chains,
// /buildings, the map detail panel, or /news.html, they can drill into the
// full firehose for that single business instead of getting the boulevard
← 5afdd26 feat(news): /business/:id/news per-business news firehose pa
·
back to Ventura Corridor
·
feat(news): "Latest from their site" section on /magazine/:i 33b09e9 →