[object Object]

← back to Ventura Corridor

feat(news): /api/news/sources + source-domain bar chart on /coverage.html

888783be7abb38950e415637c9b00df325bc3194 · 2026-05-07 20:58:53 -0700 · SteveStudio2

Editorial signal: which platforms host the corridor's published news?
40 distinct source domains currently flowing — solarunlimited.com,
bluespa.com, encinomed.org, planetfitness.com, target.com, vbs.org etc.

Routes:
  GET /api/news/sources    {host, articles, biz_count} per unique
                           hostname extracted from news_items.source_url.
                           Strips www., orders by articles DESC.
                           Cap 40, 5-min cache.

UI:
  /coverage.html           new "Source domains" bar-section above the
                           Top publishers leaderboard. Green-toned bars
                           (vs gold heatmap, metal-glow leaderboard) so
                           the three sections visually differentiate.
                           Hostnames link out in new tab.

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

Files touched

Diff

commit 888783be7abb38950e415637c9b00df325bc3194
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu May 7 20:58:53 2026 -0700

    feat(news): /api/news/sources + source-domain bar chart on /coverage.html
    
    Editorial signal: which platforms host the corridor's published news?
    40 distinct source domains currently flowing — solarunlimited.com,
    bluespa.com, encinomed.org, planetfitness.com, target.com, vbs.org etc.
    
    Routes:
      GET /api/news/sources    {host, articles, biz_count} per unique
                               hostname extracted from news_items.source_url.
                               Strips www., orders by articles DESC.
                               Cap 40, 5-min cache.
    
    UI:
      /coverage.html           new "Source domains" bar-section above the
                               Top publishers leaderboard. Green-toned bars
                               (vs gold heatmap, metal-glow leaderboard) so
                               the three sections visually differentiate.
                               Hostnames link out in new tab.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/coverage.html | 30 ++++++++++++++++++++++++++++++
 src/server/index.ts  | 31 +++++++++++++++++++++++++++++++
 2 files changed, 61 insertions(+)

diff --git a/public/coverage.html b/public/coverage.html
index 2b82ed0..9ebcc3e 100644
--- a/public/coverage.html
+++ b/public/coverage.html
@@ -90,6 +90,12 @@ main{padding:32px;max-width:1200px;margin:0 auto}
   <div id="news-heat"></div>
 </div>
 
+<div class="bar-section">
+  <h3>Source domains · what platforms host corridor news</h3>
+  <p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 14px">Hostnames the scraper actually pulled articles from — reveals which platforms (WordPress, Squarespace, custom CMS, etc.) corridor businesses publish on.</p>
+  <div id="news-sources"></div>
+</div>
+
 <div class="bar-section">
   <h3>Top publishers · fresh in the last 7 days</h3>
   <p style="font-family:'Cormorant Garamond',serif;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 14px">Most active corridor publishers — sorted by article count in the last 7 days, then 30 days, then all-time. The 🔥 marker matches the map's "Hot" recency badge (3+ in 7 days).</p>
@@ -192,6 +198,30 @@ async function load() {
     document.getElementById('news-board').innerHTML = '<p style="color:#c47b7b">leaderboard unavailable</p>';
   }
 
+  // Source domains
+  try {
+    const sd = await fetch('/api/news/sources').then(r => r.json());
+    const sdRows = sd.rows || [];
+    const elS = document.getElementById('news-sources');
+    if (!sdRows.length) {
+      elS.innerHTML = '<p style="color:var(--ink-mute);font-style:italic;font-family:Cormorant Garamond,serif">No source data yet.</p>';
+    } else {
+      const max = Math.max(1, ...sdRows.map(r => Number(r.articles)));
+      elS.innerHTML = sdRows.map(r => {
+        const fillW = (Number(r.articles) / max * 100).toFixed(1);
+        return `<div class="bar-row">
+          <span class="name"><a href="https://${escHtml(r.host)}" target="_blank" rel="noopener noreferrer" style="color:inherit;text-decoration:none">${escHtml(r.host)}</a></span>
+          <div class="track" style="width:${fillW}%;border-color:var(--rule)">
+            <div class="filled" style="width:100%;background:linear-gradient(90deg,#3a5a40,#7a9b73)"></div>
+          </div>
+          <span class="v"><b>${r.articles}</b> · <span style="color:var(--metal)">${r.biz_count} biz</span></span>
+        </div>`;
+      }).join('');
+    }
+  } catch (e) {
+    document.getElementById('news-sources').innerHTML = '<p style="color:#c47b7b">source breakdown 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 c8aad64..36dce70 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -5081,6 +5081,37 @@ app.get('/api/news/recent', async (req, res) => {
   }
 });
 
+// Source-domain breakdown — what platforms are corridor businesses publishing
+// on? wordpress.com, squarespace, custom, etc. Useful editorial signal:
+// "are these mostly self-hosted blogs vs aggregator-pages?"
+app.get('/api/news/sources', async (_req, res) => {
+  try {
+    const r = await query(`
+      WITH parsed AS (
+        SELECT n.id, n.business_id,
+               regexp_replace(
+                 substring(n.source_url FROM 'https?://(?:www\\.)?([^/]+)'),
+                 '^www\\.', ''
+               ) AS host
+          FROM news_items n
+         WHERE n.source_url ~ '^https?://'
+      )
+      SELECT host,
+             COUNT(*)::int AS articles,
+             COUNT(DISTINCT business_id)::int AS biz_count
+        FROM parsed
+       WHERE host IS NOT NULL
+       GROUP BY host
+       ORDER BY articles DESC, host ASC
+       LIMIT 40
+    `);
+    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 });
+  }
+});
+
 // Top publishers by recency. Editors use this to spot active vendors
 // (whose blogs are actually maintained) vs dormant ones. Default sort
 // "fresh_7d desc, fresh_30d desc, total desc" so this week's most active

← 4b74df3 feat(news): summarizer pre-flight + 4-hour poll launchd  ·  back to Ventura Corridor  ·  feat(news): /api/news/archive + /news/archive.html weekly hi ebcb5c8 →