[object Object]

← back to Ventura Corridor

iter 102+103: nightly auto-publish + /words page — src/jobs/auto_publish_top.ts picks top-N draft/reviewed features by views (default 10, min editorial 160 chars), bulk-flips status='published' + reviewed_at=NOW(); npm run magazine:auto-publish [N] wired; com.steve.ventura-corridor-auto-publish launchd loaded fires nightly 2:22 AM PT so the public issue grows without manual curation. /words renders typographic word cloud of top-100 most-used words across all editorials minus a 200-token stop-word list (corridor neighborhood names included to avoid 'ventura/sherman/oaks' dominating); top-5 are accent-colored bold (tier-1), 6-20 are metal (tier-2), 21+ are body-color faded; words sized 13-69px by frequency, opacity 0.45-1.0; toolbar links Issue + Scoreboard

3bb691be63f8eb1eb2886397a39c6316ab4edd03 · 2026-05-06 17:34:40 -0700 · SteveStudio2

Files touched

Diff

commit 3bb691be63f8eb1eb2886397a39c6316ab4edd03
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 17:34:40 2026 -0700

    iter 102+103: nightly auto-publish + /words page — src/jobs/auto_publish_top.ts picks top-N draft/reviewed features by views (default 10, min editorial 160 chars), bulk-flips status='published' + reviewed_at=NOW(); npm run magazine:auto-publish [N] wired; com.steve.ventura-corridor-auto-publish launchd loaded fires nightly 2:22 AM PT so the public issue grows without manual curation. /words renders typographic word cloud of top-100 most-used words across all editorials minus a 200-token stop-word list (corridor neighborhood names included to avoid 'ventura/sherman/oaks' dominating); top-5 are accent-colored bold (tier-1), 6-20 are metal (tier-2), 21+ are body-color faded; words sized 13-69px by frequency, opacity 0.45-1.0; toolbar links Issue + Scoreboard
---
 package.json                 |  3 +-
 src/jobs/auto_publish_top.ts | 39 ++++++++++++++++++++++++
 src/server/index.ts          | 72 ++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 113 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 2fb10cb..4b74d38 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,8 @@
     "magazine:photos": "tsx src/jobs/find_photos.ts",
     "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:morning-email": "tsx src/jobs/morning_feature_email.ts",
+    "magazine:auto-publish": "tsx src/jobs/auto_publish_top.ts"
   },
   "dependencies": {
     "dotenv": "^16.4.5",
diff --git a/src/jobs/auto_publish_top.ts b/src/jobs/auto_publish_top.ts
new file mode 100644
index 0000000..8c1eb76
--- /dev/null
+++ b/src/jobs/auto_publish_top.ts
@@ -0,0 +1,39 @@
+/**
+ * Nightly auto-publish: take the top-N most-viewed drafts/reviewed features and
+ * flip them to status='published'. Keeps the public /issue growing without
+ * Steve having to manually curate every feature.
+ *
+ *   $ npx tsx src/jobs/auto_publish_top.ts        # default top 10
+ *   $ npx tsx src/jobs/auto_publish_top.ts 25     # top 25
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const N = parseInt(process.argv.find(a => /^\d+$/.test(a)) || '10', 10);
+const MIN_LEN = parseInt(process.env.MIN_LEN || '160', 10);
+
+async function main() {
+  // Pick: status NOT yet published, editorial decent length, sort by views desc + recency
+  const r = await query(
+    `WITH picks AS (
+       SELECT mf.id
+       FROM magazine_features mf
+       WHERE mf.status IN ('draft', 'reviewed')
+         AND length(mf.editorial) >= $2
+       ORDER BY mf.views DESC, mf.generated_at DESC
+       LIMIT $1
+     )
+     UPDATE magazine_features
+     SET status = 'published', published_at = NOW(), reviewed_at = COALESCE(reviewed_at, NOW())
+     WHERE id IN (SELECT id FROM picks)
+     RETURNING id, headline`,
+    [N, MIN_LEN]
+  );
+  console.log(`[auto_publish_top] published ${r.rowCount} features (top ${N} by views, min editorial ${MIN_LEN} chars)`);
+  for (const row of r.rows) {
+    console.log(`  ★ ${row.id} ${(row.headline || '').slice(0, 60)}`);
+  }
+  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 bea37cc..adcadff 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -77,6 +77,7 @@ const ADMIN_PATHS = [
   /^\/issue(\/[\w-]+)?\/?$/i,
   /^\/issue\.pdf\/?$/i,
   /^\/search(\.html)?\/?$/i,
+  /^\/words\/?$/i,
   /^\/scoreboard(\.html)?\/?$/i,
   /^\/rate-card(\.html)?\/?$/i,
   /^\/sponsor(\.html)?\/?$/i,
@@ -2049,6 +2050,77 @@ rows.map((f: any) => `
 </body></html>`);
 });
 
+// /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.
+app.get('/words', async (_req, res) => {
+  try {
+    const r = await query(`SELECT editorial FROM magazine_features WHERE editorial IS NOT NULL`);
+    const STOP = new Set(`a about above after again all also am an and any are as at be been before being below between both but by came can come could did do does doing down during each each few first for from further get gets going got had has have having he he'd he'll he's her here here's hers herself him himself his how how's i i'd i'll i'm i've if in into is it it's its itself just like make many may me more most much my myself nor no not now of off on once one only or other ought our ours ourselves out over own same she she'd she'll she's should so some such than that that's the their theirs them themselves then there there's these they they'd they'll they're they've this those though through to too under until up upon us very was we we'd we'll we're we've well were what what's when when's where where's which while who who's whom why why's will with won't would you you'd you'll you're you've your yours yourself yourselves new just out way back over still really only also even much because while where here there s t d ll ve re m ventura blvd boulevard sherman oaks encino tarzana studio city woodland hills suite multi tenant building corridor`.split(/\s+/));
+    const counts: Record<string, number> = {};
+    for (const row of r.rows) {
+      const text = (row.editorial || '').toLowerCase();
+      const tokens = text.match(/[a-z][a-z'-]{3,}/g) || [];
+      for (const t of tokens) {
+        if (STOP.has(t)) continue;
+        counts[t] = (counts[t] || 0) + 1;
+      }
+    }
+    const top = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 100);
+    if (top.length === 0) {
+      res.send('<h1>No editorial yet.</h1>');
+      return;
+    }
+    const max = top[0][1];
+    const min = top[top.length - 1][1];
+    const fontFor = (n: number) => 13 + Math.round(((n - min) / Math.max(max - min, 1)) * 56);
+    const opacityFor = (n: number) => 0.45 + ((n - min) / Math.max(max - min, 1)) * 0.55;
+    const esc = (s: any) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
+    res.setHeader('Content-Type', 'text/html; charset=utf-8');
+    res.send(`<!doctype html><html><head><meta charset="utf-8">
+<title>Words on the corridor — The 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,400;1,500&family=Inter:wght@200;300;400;500&display=swap');
+:root{--paper:#faf6ee;--ink:#1a1815;--ink-mute:#6e6356;--metal:#8a6d3b;--accent:#6a3a1a;--rule:#d8cdb8}
+*{box-sizing:border-box}html,body{margin:0;background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;font-weight:300;line-height:1.55}
+header{text-align:center;padding:48px 24px 28px;border-bottom:4px double var(--ink)}
+header .kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);font-weight:500}
+header h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-weight:500;font-size:54px;margin:8px 0 0;color:var(--ink)}
+header h1 em{color:var(--metal);font-style:normal}
+header .deck{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:16px;color:var(--ink-mute);margin-top:12px;max-width:560px;margin-left:auto;margin-right:auto}
+.cloud{max-width:980px;margin:0 auto;padding:60px 32px 80px;text-align:center;line-height:1.4}
+.cloud span{display:inline-block;margin:6px 14px;font-family:'Cormorant Garamond',serif;font-style:italic;color:var(--ink);transition:color 200ms}
+.cloud span:hover{color:var(--accent);font-weight:500}
+.cloud span.tier-1{color:var(--accent);font-weight:600}
+.cloud span.tier-2{color:var(--metal);font-weight:500}
+.toolbar{position:fixed;top:14px;right:14px;display:flex;gap:6px;z-index:100}
+.toolbar a{font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-mute);background:var(--paper);padding:5px 10px;border:1px solid var(--rule);text-decoration:none}
+.toolbar a:hover{color:var(--metal);border-color:var(--metal)}
+</style></head><body>
+<div class="toolbar">
+  <a href="/issue">Issue</a>
+  <a href="/scoreboard.html">Scoreboard</a>
+</div>
+<header>
+  <div class="kicker">The Corridor · word study</div>
+  <h1>Words on the <em>corridor</em></h1>
+  <div class="deck">${r.rowCount} feature${r.rowCount === 1 ? '' : 's'} written so far · the desk's vocabulary, sized by how often it shows up.</div>
+</header>
+<div class="cloud">
+${top.map(([w, n], i) => {
+  const size = fontFor(n);
+  const op = opacityFor(n);
+  const tier = i < 5 ? 'tier-1' : i < 20 ? 'tier-2' : '';
+  return `<span class="${tier}" style="font-size:${size}px;opacity:${op.toFixed(2)}" title="used ${n}×">${esc(w)}</span>`;
+}).join(' ')}
+</div>
+</body></html>`);
+  } catch (e: any) {
+    res.status(500).send('error: ' + e.message);
+  }
+});
+
 // PDF export — render /issue (or /issue/:cat) via Playwright headless and stream to disk
 app.get('/issue.pdf', async (req, res) => {
   try {

← 18a9a29 iter 101: /sponsor/:id owner-claim form — public-facing per-  ·  back to Ventura Corridor  ·  iter 104+105+106+107+108: regen-thin job + sitemap + robots fa94a30 →