[object Object]

← back to Ventura Corridor

iter 104+105+106+107+108: regen-thin job + sitemap + robots + random + RSS + friendly 404 — src/jobs/regen_thin.ts calls /api/magazine/:id/regen for any feature with editorial<200 chars (default 50/run); /sitemap.xml lists /issue + per-vertical /issue/:cat + every published /magazine/:id with lastmod; /robots.txt blocks all crawlers (loopback build); /api/magazine/random?cat= returns one published feature for fleet-tv-style rotators; /issue/feed.xml + /issue/:cat/feed.xml RSS 2.0 with content:encoded full editorial; magazine-themed 404 ('Not in this issue') with Cormorant 84px italic + suggested nav (issue/search/scoreboard/words/rates)

fa94a30bdb3e26f7165d1e02091daa24f81f933d · 2026-05-06 17:37:32 -0700 · SteveStudio2

Files touched

Diff

commit fa94a30bdb3e26f7165d1e02091daa24f81f933d
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 17:37:32 2026 -0700

    iter 104+105+106+107+108: regen-thin job + sitemap + robots + random + RSS + friendly 404 — src/jobs/regen_thin.ts calls /api/magazine/:id/regen for any feature with editorial<200 chars (default 50/run); /sitemap.xml lists /issue + per-vertical /issue/:cat + every published /magazine/:id with lastmod; /robots.txt blocks all crawlers (loopback build); /api/magazine/random?cat= returns one published feature for fleet-tv-style rotators; /issue/feed.xml + /issue/:cat/feed.xml RSS 2.0 with content:encoded full editorial; magazine-themed 404 ('Not in this issue') with Cormorant 84px italic + suggested nav (issue/search/scoreboard/words/rates)
---
 package.json           |   3 +-
 src/jobs/regen_thin.ts |  45 ++++++++++++++
 src/server/index.ts    | 155 +++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 202 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 4b74d38..530e840 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,8 @@
     "magazine:watchdog": "tsx src/jobs/magazine_watchdog.ts",
     "magazine:normalize": "tsx src/jobs/normalize_categories.ts",
     "magazine:morning-email": "tsx src/jobs/morning_feature_email.ts",
-    "magazine:auto-publish": "tsx src/jobs/auto_publish_top.ts"
+    "magazine:auto-publish": "tsx src/jobs/auto_publish_top.ts",
+    "magazine:regen-thin": "tsx src/jobs/regen_thin.ts"
   },
   "dependencies": {
     "dotenv": "^16.4.5",
diff --git a/src/jobs/regen_thin.ts b/src/jobs/regen_thin.ts
new file mode 100644
index 0000000..1d0ae64
--- /dev/null
+++ b/src/jobs/regen_thin.ts
@@ -0,0 +1,45 @@
+/**
+ * Identify magazine_features with thin editorial (<MIN_LEN chars) and
+ * call /api/magazine/:id/regen to refresh them via Mac1 Ollama.
+ *
+ *   $ npx tsx src/jobs/regen_thin.ts          # rescue all thin features
+ *   $ npx tsx src/jobs/regen_thin.ts 20       # max 20
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const N_MAX = parseInt(process.argv.find(a => /^\d+$/.test(a)) || '50', 10);
+const MIN_LEN = parseInt(process.env.MIN_LEN || '200', 10);
+const SERVER = process.env.VC_URL || 'http://127.0.0.1:9780';
+const AUTH = 'Basic ' + Buffer.from('admin:DWSecure2024!').toString('base64');
+
+async function main() {
+  const r = await query(
+    `SELECT id, length(editorial) AS len, headline FROM magazine_features
+     WHERE editorial IS NULL OR length(editorial) < $1
+     ORDER BY generated_at ASC
+     LIMIT $2`,
+    [MIN_LEN, N_MAX]
+  );
+  console.log(`[regen_thin] ${r.rowCount} features below ${MIN_LEN} chars to refresh`);
+  let ok = 0, fail = 0;
+  for (const row of r.rows) {
+    try {
+      const t0 = Date.now();
+      const resp = await fetch(`${SERVER}/api/magazine/${row.id}/regen`, {
+        method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: AUTH }
+      });
+      if (!resp.ok) throw new Error(`${resp.status}: ${(await resp.text()).slice(0, 100)}`);
+      const j = await resp.json();
+      console.log(`  ✓ ${row.id} (${row.len || 0}ch → fresh, ${Date.now() - t0}ms) "${(j.headline || '').slice(0, 50)}"`);
+      ok++;
+    } catch (e: any) {
+      console.log(`  ✕ ${row.id} (${row.len || 0}ch) — ${e.message.slice(0, 80)}`);
+      fail++;
+    }
+  }
+  console.log(`[regen_thin] ok=${ok} fail=${fail}`);
+  await pool.end();
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/src/server/index.ts b/src/server/index.ts
index adcadff..5670946 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -78,6 +78,9 @@ const ADMIN_PATHS = [
   /^\/issue\.pdf\/?$/i,
   /^\/search(\.html)?\/?$/i,
   /^\/words\/?$/i,
+  /^\/sitemap\.xml\/?$/i,
+  /^\/robots\.txt\/?$/i,
+  /^\/issue(\/[\w-]+)?\/feed\.xml\/?$/i,
   /^\/scoreboard(\.html)?\/?$/i,
   /^\/rate-card(\.html)?\/?$/i,
   /^\/sponsor(\.html)?\/?$/i,
@@ -1793,6 +1796,28 @@ app.get('/api/magazine/feature-of-the-day', async (_req, res) => {
   }
 });
 
+// Random published feature — for fleet-tv-style rotators or sidebar widgets
+app.get('/api/magazine/random', async (req, res) => {
+  try {
+    const cat = String(req.query.cat || '');
+    const where: string[] = [`status = 'published'`, `length(editorial) > 100`];
+    const params: any[] = [];
+    if (cat) { params.push(cat); where.push(`category_tag = $${params.length}`); }
+    const r = await query(
+      `SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote, mf.category_tag,
+              b.name AS biz_name, b.address AS biz_address, b.city
+       FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
+       WHERE ${where.join(' AND ')}
+       ORDER BY random() LIMIT 1`,
+      params
+    );
+    if (r.rowCount === 0) return res.json({ feature: null });
+    res.json({ feature: r.rows[0], permalink: `/magazine/${r.rows[0].id}` });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 app.get('/api/magazine/scoreboard', async (_req, res) => {
   try {
     const [topViewed, hotVerticals, latest, longest, sponsors] = await Promise.all([
@@ -2050,6 +2075,98 @@ rows.map((f: any) => `
 </body></html>`);
 });
 
+// /sitemap.xml — every published feature + every per-vertical issue page
+app.get('/sitemap.xml', async (_req, res) => {
+  try {
+    const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
+    const r = await query(`
+      SELECT id, COALESCE(published_at, generated_at) AS ts
+      FROM magazine_features
+      WHERE status = 'published'
+      ORDER BY published_at DESC
+    `);
+    const cats = await query(`
+      SELECT DISTINCT category_tag FROM magazine_features WHERE status='published' AND category_tag IS NOT NULL
+    `);
+    const fmt = (d: any) => new Date(d).toISOString();
+    const urls: string[] = [
+      `<url><loc>${baseUrl}/issue</loc><changefreq>daily</changefreq><priority>1.0</priority></url>`,
+      `<url><loc>${baseUrl}/scoreboard.html</loc><changefreq>daily</changefreq><priority>0.6</priority></url>`,
+      `<url><loc>${baseUrl}/words</loc><changefreq>weekly</changefreq><priority>0.5</priority></url>`,
+      `<url><loc>${baseUrl}/rate-card.html</loc><changefreq>monthly</changefreq><priority>0.7</priority></url>`,
+    ];
+    for (const c of cats.rows) {
+      urls.push(`<url><loc>${baseUrl}/issue/${c.category_tag}</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>`);
+    }
+    for (const f of r.rows) {
+      urls.push(`<url><loc>${baseUrl}/magazine/${f.id}</loc><lastmod>${fmt(f.ts)}</lastmod><changefreq>monthly</changefreq><priority>0.6</priority></url>`);
+    }
+    res.setHeader('Content-Type', 'application/xml; charset=utf-8');
+    res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
+${urls.join('\n')}
+</urlset>`);
+  } catch (e: any) {
+    res.status(500).send('error: ' + e.message);
+  }
+});
+
+// /issue/feed.xml or /issue/:cat/feed.xml — RSS for the (vertical-) issue
+app.get(['/issue/feed.xml', '/issue/:cat/feed.xml'], async (req, res) => {
+  try {
+    const cat = (req.params as any).cat || null;
+    const baseUrl = process.env.PUBLIC_BASE_URL || 'http://127.0.0.1:9780';
+    const where: string[] = [`status = 'published'`];
+    const params: any[] = [];
+    if (cat) { params.push(cat); where.push(`category_tag = $${params.length}`); }
+    const r = await query(
+      `SELECT mf.id, mf.headline, mf.subhead, mf.editorial, mf.published_at, mf.category_tag,
+              b.name AS biz_name
+       FROM magazine_features mf JOIN businesses b ON b.id = mf.business_id
+       WHERE ${where.join(' AND ')}
+       ORDER BY published_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((f: any) => `
+    <item>
+      <title>${esc(f.headline || f.biz_name)}</title>
+      <link>${baseUrl}/magazine/${f.id}</link>
+      <guid isPermaLink="true">${baseUrl}/magazine/${f.id}</guid>
+      <pubDate>${new Date(f.published_at || Date.now()).toUTCString()}</pubDate>
+      <category>${esc(f.category_tag || 'feature')}</category>
+      <description>${esc(f.subhead || '')} — ${esc(f.biz_name)}</description>
+      <content:encoded><![CDATA[${esc(f.editorial || '')}]]></content:encoded>
+    </item>`).join('');
+    const title = cat ? `The Corridor — ${cat}` : 'The Corridor';
+    res.setHeader('Content-Type', 'application/rss+xml; charset=utf-8');
+    res.send(`<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
+  <channel>
+    <title>${esc(title)}</title>
+    <link>${baseUrl}${cat ? '/issue/' + esc(cat) : '/issue'}</link>
+    <description>A magazine of who's here on Ventura Boulevard.${cat ? ' Vertical: ' + esc(cat) + '.' : ''}</description>
+    <language>en-us</language>
+    <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>${items}
+  </channel>
+</rss>`);
+  } catch (e: any) {
+    res.status(500).send('error: ' + e.message);
+  }
+});
+
+app.get('/robots.txt', (_req, res) => {
+  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
+  // Loopback-only: discourage crawl entirely until Steve goes public.
+  res.send(`User-agent: *
+Disallow: /
+
+# The Corridor magazine — local development build.
+# When this site goes public, swap Disallow back to Allow and uncomment Sitemap.
+# Sitemap: /sitemap.xml
+`);
+});
+
 // /words — what words does The Corridor desk reach for most? frequency analysis
 // of all editorial bodies, minus a stop-word list. Renders a typographic word
 // cloud sized by frequency.
@@ -3023,6 +3140,44 @@ app.use('/screenshots', express.static(path.resolve(__dirname, '../../data/scree
 // ─── Static viewer ─────────────────────────────────────────────────────
 app.use('/', express.static(path.resolve(__dirname, '../../public')));
 
+// Friendly 404 — magazine-flavored, suggests likely routes
+app.use((req, res) => {
+  if (req.accepts('html')) {
+    res.status(404).type('html').send(`<!doctype html><html><head><meta charset="utf-8">
+<title>Not in this issue · The Corridor</title>
+<style>
+@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,400;1,500&family=Inter:wght@300;400;500&display=swap');
+*{margin:0;padding:0;box-sizing:border-box}
+html,body{background:#faf6ee;color:#1a1815;font-family:'Inter',sans-serif;font-weight:300;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
+.card{max-width:520px;text-align:center}
+.kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:#8a6d3b;font-weight:500}
+h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:84px;line-height:.95;margin:14px 0 12px;letter-spacing:-0.02em}
+h1 em{font-style:normal;color:#8a6d3b}
+.deck{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:18px;color:#6e6356;line-height:1.4;margin-bottom:32px}
+.path{font-family:'JetBrains Mono',monospace;font-size:12px;color:#888;background:rgba(184,153,104,0.08);padding:6px 12px;display:inline-block;margin-bottom:24px}
+nav{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;margin-top:16px}
+nav a{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:#8a6d3b;text-decoration:none;border:1px solid #d8cdb8;padding:8px 16px}
+nav a:hover{color:#6a3a1a;border-color:#6a3a1a}
+</style></head><body>
+<div class="card">
+  <div class="kicker">The Corridor · 404</div>
+  <h1>Not in <em>this issue</em>.</h1>
+  <div class="deck">The page you were looking for didn't make the cut. Try one of these.</div>
+  <div class="path">${req.path.slice(0, 80)}</div>
+  <nav>
+    <a href="/issue">Read the issue</a>
+    <a href="/search.html">Search</a>
+    <a href="/scoreboard.html">Scoreboard</a>
+    <a href="/words">Words</a>
+    <a href="/rate-card.html">Rates</a>
+  </nav>
+</div>
+</body></html>`);
+  } else {
+    res.status(404).type('json').send({ error: 'not found', path: req.path });
+  }
+});
+
 // 127.0.0.1 only — directory data stays local per the lawyer/doctor precedent.
 app.listen(PORT, '127.0.0.1', () => {
   console.log(`[ventura-corridor] listening on http://127.0.0.1:${PORT}`);

← 3bb691b iter 102+103: nightly auto-publish + /words page — src/jobs/  ·  back to Ventura Corridor  ·  fix: move RSS route ahead of /issue/:cat handler — Express w a45aad1 →