← back to Ventura Corridor
feat(news): /this-week.html editorial digest + /api/news/digest
e45334ec6190d307d2bdeff81d2594fa6acacf40 · 2026-05-07 11:59:49 -0700 · SteveStudio2
Editorial front-door for the news pipeline. Where /news.html is the
firehose, /this-week.html is the curated read — 3 lead stories at the
top with serif/italic display type matching magazine.html, then a
two-column "also this week" tail of up to 30 more dispatches.
Routes:
GET /api/news/digest {lead:[3], tail:[<=30], generated_at}
5-min cache, lead = articles with body >= 200
chars + non-trivial title; tail = remainder
most-recent-first
GET /this-week.html paper aesthetic, double-rule masthead, mono
kickers, Cormorant Garamond italic display
Both paths whitelisted in ADMIN_PATHS so basic-auth gates them
consistent with the rest of the corridor admin tools.
Pulls from the same news_items table populated by the news scraper,
so it works today on the 76 articles already in DB and grows
automatically as the nightly 03:15 scrape lands more.
No external API deps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A public/this-week.htmlM src/server/index.ts
Diff
commit e45334ec6190d307d2bdeff81d2594fa6acacf40
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Thu May 7 11:59:49 2026 -0700
feat(news): /this-week.html editorial digest + /api/news/digest
Editorial front-door for the news pipeline. Where /news.html is the
firehose, /this-week.html is the curated read — 3 lead stories at the
top with serif/italic display type matching magazine.html, then a
two-column "also this week" tail of up to 30 more dispatches.
Routes:
GET /api/news/digest {lead:[3], tail:[<=30], generated_at}
5-min cache, lead = articles with body >= 200
chars + non-trivial title; tail = remainder
most-recent-first
GET /this-week.html paper aesthetic, double-rule masthead, mono
kickers, Cormorant Garamond italic display
Both paths whitelisted in ADMIN_PATHS so basic-auth gates them
consistent with the rest of the corridor admin tools.
Pulls from the same news_items table populated by the news scraper,
so it works today on the 76 articles already in DB and grows
automatically as the nightly 03:15 scrape lands more.
No external API deps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/this-week.html | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++
src/server/index.ts | 37 ++++++++++++
2 files changed, 190 insertions(+)
diff --git a/public/this-week.html b/public/this-week.html
new file mode 100644
index 0000000..bc7ca56
--- /dev/null
+++ b/public/this-week.html
@@ -0,0 +1,153 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>This week on the Boulevard · Ventura Corridor</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>
+@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500&family=Inter:wght@200;300;400;500;600&family=JetBrains+Mono:wght@200;300;400&display=swap');
+:root {
+ --paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;
+ --metal-glow:#b89968;--accent:#6a3a1a;--rule:#d8cdb8;
+ --serif:'Cormorant Garamond',Georgia,serif;--sans:'Inter',sans-serif;--mono:'JetBrains Mono',monospace;
+}
+html,body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:300}
+*{box-sizing:border-box}
+a{color:var(--ink);text-decoration:none}
+a:hover{color:var(--accent)}
+
+header.masthead{text-align:center;padding:56px 24px 32px;border-bottom:4px double var(--ink)}
+.masthead .kicker{font-weight:500;font-size:10px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);margin-bottom:14px}
+.masthead h1{font-family:var(--serif);font-style:italic;font-weight:500;font-size:64px;line-height:.95;margin:0;letter-spacing:-.02em}
+.masthead h1 em{font-style:normal;color:var(--metal)}
+.masthead .deck{font-family:var(--serif);font-weight:300;font-style:italic;font-size:17px;color:var(--ink-mute);margin-top:14px;max-width:600px;margin-left:auto;margin-right:auto;line-height:1.5}
+.masthead .issue{margin-top:20px;font-family:var(--mono);font-size:9px;letter-spacing:.3em;text-transform:uppercase;color:var(--ink-mute)}
+nav.crossnav{padding:14px 32px;border-bottom:1px solid var(--rule);display:flex;gap:14px;flex-wrap:wrap;justify-content:center;font-family:var(--mono);font-size:9px;letter-spacing:.28em;text-transform:uppercase}
+nav.crossnav a{color:var(--ink-mute);padding:5px 12px;border:1px solid var(--rule)}
+nav.crossnav a:hover,nav.crossnav a.active{color:var(--metal);border-color:var(--metal)}
+
+main{max-width:1100px;margin:0 auto;padding:48px 32px}
+
+.lead-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:32px;margin-bottom:64px;padding-bottom:48px;border-bottom:1px solid var(--rule)}
+@media (max-width:840px){.lead-grid{grid-template-columns:1fr}}
+.lead{display:flex;flex-direction:column;gap:12px}
+.lead .biz{font-family:var(--mono);font-size:9px;letter-spacing:.28em;text-transform:uppercase;color:var(--metal)}
+.lead h2{font-family:var(--serif);font-style:italic;font-weight:500;font-size:30px;line-height:1.15;margin:0;color:var(--ink)}
+.lead .body{font-family:var(--serif);font-size:15px;line-height:1.55;color:var(--ink-mute);margin:0}
+.lead .body em{color:var(--accent);font-style:italic}
+.lead .meta{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute);margin-top:auto;padding-top:14px;border-top:1px solid var(--rule);display:flex;justify-content:space-between;gap:14px}
+.lead .meta a{color:var(--metal);border-bottom:1px dotted var(--metal)}
+.lead a.read-more{color:var(--accent);font-family:var(--sans);font-weight:500;font-size:12px;letter-spacing:.08em;text-transform:uppercase}
+
+.tail-section h3{font-family:var(--serif);font-style:italic;font-weight:500;font-size:22px;margin:0 0 6px;color:var(--ink)}
+.tail-section .deck-small{font-family:var(--serif);font-weight:300;font-style:italic;font-size:13px;color:var(--ink-mute);margin:0 0 24px}
+.tail{display:grid;grid-template-columns:repeat(2,1fr);gap:18px 32px}
+@media (max-width:840px){.tail{grid-template-columns:1fr}}
+.tail-item{padding:14px 0;border-bottom:1px solid var(--rule);display:flex;flex-direction:column;gap:6px}
+.tail-item .biz{font-family:var(--mono);font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--metal)}
+.tail-item h4{font-family:var(--serif);font-weight:500;font-size:18px;line-height:1.25;margin:0;color:var(--ink)}
+.tail-item .meta{font-family:var(--mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute)}
+
+.empty{padding:80px 0;text-align:center;color:var(--ink-mute);font-family:var(--serif);font-style:italic;font-size:20px}
+
+footer.colophon{text-align:center;padding:48px 24px 64px;border-top:4px double var(--ink);color:var(--ink-mute);font-family:var(--serif);font-style:italic;font-size:13px}
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <p class="kicker">Ventura Corridor · Issue dispatched</p>
+ <h1>This <em>week</em> on the Boulevard</h1>
+ <p class="deck">Fresh signals from every storefront with a website on Ventura Bl — what was announced, posted, opened, or changed in the last seven days, served straight from the source.</p>
+ <p class="issue" id="issue-line">loading…</p>
+</header>
+
+<nav class="crossnav">
+ <a href="/">Map</a>
+ <a href="/magazine.html">Magazine</a>
+ <a href="/this-week.html" class="active">This week</a>
+ <a href="/news.html">News</a>
+ <a href="/calendar.html">Calendar</a>
+ <a href="/coverage.html">Coverage</a>
+</nav>
+
+<main>
+ <div id="content">
+ <div class="empty">Loading the boulevard's week…</div>
+ </div>
+</main>
+
+<footer class="colophon">
+ Edited by qwen3:14b on a Mac in Sherman Oaks · printed at <span id="now">…</span>
+</footer>
+
+<script>
+const escHtml = s => String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
+const fmt = s => {
+ if (!s) return '';
+ const d = new Date(s);
+ if (isNaN(d.getTime())) return '';
+ return d.toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
+};
+const truncate = (s, n) => {
+ if (!s) return '';
+ return s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, '') + '…' : s;
+};
+function hostOf(u) { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return ''; } }
+
+function renderLead(r) {
+ const text = r.summary || r.excerpt || '';
+ const date = r.published_guess || r.fetched_at;
+ return [
+ '<article class="lead">',
+ '<p class="biz">', escHtml(r.business_name||''), r.city ? ' · ' + escHtml(r.city) : '', '</p>',
+ '<h2>', escHtml(truncate(r.title || '(untitled)', 90)), '</h2>',
+ '<p class="body">', escHtml(truncate(text, 320)), '</p>',
+ '<div class="meta"><span>', escHtml(fmt(date)), '</span>',
+ '<a href="', escHtml(r.source_url), '" target="_blank" rel="noopener noreferrer">', escHtml(hostOf(r.source_url)), ' ↗</a>',
+ '</div>',
+ '</article>'
+ ].join('');
+}
+function renderTail(r) {
+ const date = r.published_guess || r.fetched_at;
+ return [
+ '<a class="tail-item" href="', escHtml(r.source_url), '" target="_blank" rel="noopener noreferrer">',
+ '<span class="biz">', escHtml(r.business_name||''), '</span>',
+ '<h4>', escHtml(truncate(r.title || '(untitled)', 100)), '</h4>',
+ '<span class="meta">', escHtml(fmt(date)), ' · ', escHtml(hostOf(r.source_url)), '</span>',
+ '</a>'
+ ].join('');
+}
+
+async function load() {
+ try {
+ const r = await fetch('/api/news/digest');
+ const d = await r.json();
+ const content = document.getElementById('content');
+ if (!d.lead || !d.lead.length) {
+ content.innerHTML = '<div class="empty">No fresh news yet. Check back after tonight\'s scrape.</div>';
+ return;
+ }
+ const total = (d.lead.length || 0) + (d.tail.length || 0);
+ document.getElementById('issue-line').textContent = total + ' stories · ' + fmt(d.generated_at);
+
+ let html = '<div class="lead-grid">' + d.lead.map(renderLead).join('') + '</div>';
+ if (d.tail.length) {
+ html += '<section class="tail-section">';
+ html += '<h3>Also this week</h3>';
+ html += '<p class="deck-small">' + d.tail.length + ' more dispatches from the corridor.</p>';
+ html += '<div class="tail">' + d.tail.map(renderTail).join('') + '</div>';
+ html += '</section>';
+ }
+ content.innerHTML = html;
+ } catch (e) {
+ document.getElementById('content').innerHTML = '<div class="empty">Could not load. Is the server up?</div>';
+ }
+}
+
+document.getElementById('now').textContent = new Date().toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
+load();
+</script>
+</body>
+</html>
diff --git a/src/server/index.ts b/src/server/index.ts
index 424a436..3e644ec 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,
+ /^\/this-week(\.html)?\/?$/i,
/^\/api\/news(\/.*)?$/i,
/^\/pitch\/\d+\/?$/i,
];
@@ -4988,6 +4989,42 @@ app.get('/api/news/recent', async (req, res) => {
}
});
+// 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.
+app.get('/api/news/digest', async (_req, res) => {
+ try {
+ const lead = 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.category, b.city
+ FROM news_items n
+ JOIN businesses b ON b.id = n.business_id
+ WHERE length(n.body) > 200
+ AND n.title IS NOT NULL AND length(n.title) > 6
+ ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
+ n.fetched_at DESC
+ LIMIT 3
+ `);
+ const tailIds = lead.rows.map((r: any) => r.id);
+ const tail = 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.category, b.city
+ FROM news_items n
+ JOIN businesses b ON b.id = n.business_id
+ WHERE n.id NOT IN (${tailIds.length ? tailIds.map((_: any, i: number) => '$' + (i + 1)).join(',') : 'NULL'})
+ ORDER BY COALESCE(n.published_guess, n.fetched_at::date) DESC,
+ n.fetched_at DESC
+ LIMIT 30
+ `, tailIds);
+ res.set('Cache-Control', 'public, max-age=300');
+ res.json({ lead: lead.rows, tail: tail.rows, generated_at: new Date().toISOString() });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
app.get('/api/news/by-business/:id', async (req, res) => {
try {
const id = parseInt(req.params.id, 10);
← 828ab33 feat(enrich): website-discovery resilience + category priori
·
back to Ventura Corridor
·
fix(generate_features): forbid 'multi-tenant building' boile e7c9465 →