← back to Ventura Corridor
iter 73: PIVOT to corridor magazine MVP — migration 016 adds magazine_features table (business_id UNIQUE, headline, subhead, editorial, pull_quote, category_tag, photo_url, status draft/reviewed/published/spiked, model, generated_at, reviewed_at, published_at, views, notes); src/jobs/generate_features.ts uses Mac1 Ollama qwen3:14b (per Steve's local-LLM rule) to write 80-130-word magazine features w/ JSON output (headline + subhead + editorial + pull_quote + category_tag), Conde-Nast-Traveler tone, no superlatives, lean on address as sense of place; npm run magazine:gen [N] generates N fresh; --biz=ID regenerates one specific; /api/magazine + PATCH endpoints; /magazine.html viewer w/ Cormorant Garamond serif masthead 'The Boulevard' Vol I Spring 2026, double-rule under masthead, 4-column grid of feature cards, photo-frame placeholder w/ category-tag overlay + first-letter drop cap on lede + pull-quote callouts in copper accent + per-card actions (Mark reviewed → Publish → Spike, ↻ Regen); 5 first features generated successfully (Le Petit Corner, Kelli Rust, Point to Wellness, etc.) at ~25-31s each on MS1 Ollama
1daefa653c72bd16562f8cbbb309fe856d9049c6 · 2026-05-06 16:03:09 -0700 · SteveStudio2
Files touched
A db/migrations/016_magazine.sqlM package.jsonA public/magazine.htmlA src/jobs/generate_features.tsM src/server/index.ts
Diff
commit 1daefa653c72bd16562f8cbbb309fe856d9049c6
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Wed May 6 16:03:09 2026 -0700
iter 73: PIVOT to corridor magazine MVP — migration 016 adds magazine_features table (business_id UNIQUE, headline, subhead, editorial, pull_quote, category_tag, photo_url, status draft/reviewed/published/spiked, model, generated_at, reviewed_at, published_at, views, notes); src/jobs/generate_features.ts uses Mac1 Ollama qwen3:14b (per Steve's local-LLM rule) to write 80-130-word magazine features w/ JSON output (headline + subhead + editorial + pull_quote + category_tag), Conde-Nast-Traveler tone, no superlatives, lean on address as sense of place; npm run magazine:gen [N] generates N fresh; --biz=ID regenerates one specific; /api/magazine + PATCH endpoints; /magazine.html viewer w/ Cormorant Garamond serif masthead 'The Boulevard' Vol I Spring 2026, double-rule under masthead, 4-column grid of feature cards, photo-frame placeholder w/ category-tag overlay + first-letter drop cap on lede + pull-quote callouts in copper accent + per-card actions (Mark reviewed → Publish → Spike, ↻ Regen); 5 first features generated successfully (Le Petit Corner, Kelli Rust, Point to Wellness, etc.) at ~25-31s each on MS1 Ollama
---
db/migrations/016_magazine.sql | 28 ++++
package.json | 3 +-
public/magazine.html | 309 +++++++++++++++++++++++++++++++++++++++++
src/jobs/generate_features.ts | 128 +++++++++++++++++
src/server/index.ts | 52 +++++++
5 files changed, 519 insertions(+), 1 deletion(-)
diff --git a/db/migrations/016_magazine.sql b/db/migrations/016_magazine.sql
new file mode 100644
index 0000000..57cc87a
--- /dev/null
+++ b/db/migrations/016_magazine.sql
@@ -0,0 +1,28 @@
+-- Migration 016 — magazine of corridor businesses with AI-generated editorial
+-- Pivot from outreach pipeline → corridor shopping guide / "magazine of who's here"
+-- Each business gets a glowing AI-written feature; ad-signal-detected businesses prioritized
+-- (they're proven ad-buyers, natural targets for a paid feature placement).
+
+CREATE TABLE IF NOT EXISTS magazine_features (
+ id BIGSERIAL PRIMARY KEY,
+ business_id BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+ headline TEXT, -- 4-8 word punchy headline
+ subhead TEXT, -- 10-20 word descriptor
+ editorial TEXT, -- 80-150 word feature paragraph
+ pull_quote TEXT, -- optional callout (≤20 words)
+ category_tag TEXT, -- 'restaurant' | 'professional' | 'beauty' | 'shop' | etc
+ photo_url TEXT, -- placeholder until photos sourced
+ status TEXT NOT NULL DEFAULT 'draft', -- 'draft' | 'reviewed' | 'published' | 'spiked'
+ model TEXT, -- 'qwen3:14b' | 'claude' | etc
+ prompt_hash TEXT, -- so we can re-gen if prompt changes
+ generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ reviewed_at TIMESTAMPTZ,
+ published_at TIMESTAMPTZ,
+ views INTEGER NOT NULL DEFAULT 0,
+ notes TEXT,
+ UNIQUE (business_id) -- one feature per business at a time
+);
+
+CREATE INDEX IF NOT EXISTS idx_magfeat_status ON magazine_features (status);
+CREATE INDEX IF NOT EXISTS idx_magfeat_published_at ON magazine_features (published_at DESC) WHERE status='published';
+CREATE INDEX IF NOT EXISTS idx_magfeat_category ON magazine_features (category_tag);
diff --git a/package.json b/package.json
index 4e9dcd2..78153be 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,8 @@
"enrich:adstransparency": "tsx src/enrich/ads_transparency.ts",
"migrate": "tsx db/migrate.ts",
"snapshot": "tsx src/jobs/daily_snapshot.ts",
- "digest": "tsx src/jobs/weekly_digest.ts"
+ "digest": "tsx src/jobs/weekly_digest.ts",
+ "magazine:gen": "tsx src/jobs/generate_features.ts"
},
"dependencies": {
"dotenv": "^16.4.5",
diff --git a/public/magazine.html b/public/magazine.html
new file mode 100644
index 0000000..039b41f
--- /dev/null
+++ b/public/magazine.html
@@ -0,0 +1,309 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Ventura Corridor — the magazine</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', 'Times New Roman', serif;
+ --sans: 'Inter', -apple-system, system-ui, sans-serif;
+ --mono: 'JetBrains Mono', ui-monospace, monospace;
+ }
+ html, body { margin: 0; background: var(--paper); color: var(--ink); font-family: var(--sans); font-weight: 300; min-height: 100vh; }
+ * { box-sizing: border-box; }
+
+ /* Masthead */
+ header.masthead {
+ text-align: center;
+ padding: 56px 24px 32px;
+ border-bottom: 4px double var(--ink);
+ background: var(--paper);
+ }
+ header.masthead .kicker {
+ font-family: var(--sans); font-weight: 500;
+ font-size: 10px; letter-spacing: 0.4em; text-transform: uppercase;
+ color: var(--metal); margin-bottom: 14px;
+ }
+ header.masthead h1 {
+ font-family: var(--serif); font-style: italic; font-weight: 500;
+ font-size: 72px; line-height: 0.95; margin: 0; letter-spacing: -0.02em;
+ color: var(--ink);
+ }
+ header.masthead h1 em { font-style: normal; color: var(--metal); }
+ header.masthead .deck {
+ font-family: var(--serif); font-weight: 300; font-style: italic;
+ font-size: 17px; color: var(--ink-mute); margin-top: 14px;
+ max-width: 540px; margin-left: auto; margin-right: auto; line-height: 1.5;
+ }
+ header.masthead .issue {
+ margin-top: 20px;
+ font-family: var(--mono); font-size: 9px; letter-spacing: 0.3em;
+ text-transform: uppercase; color: var(--ink-mute);
+ }
+
+ /* Navigation rail (admin links — collapsed by default) */
+ nav.admin-rail {
+ background: rgba(0,0,0,0.04);
+ padding: 10px 24px;
+ text-align: center;
+ border-bottom: 1px solid var(--rule);
+ }
+ nav.admin-rail a, nav.admin-rail span {
+ font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase;
+ color: var(--ink-mute); margin: 0 8px; text-decoration: none;
+ }
+ nav.admin-rail a:hover { color: var(--metal); }
+
+ /* Filter chips */
+ .chips {
+ display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;
+ padding: 18px 24px; border-bottom: 1px solid var(--rule);
+ }
+ .chips button {
+ background: transparent; border: 1px solid var(--rule);
+ color: var(--ink-mute); font-size: 9px; letter-spacing: 0.22em;
+ text-transform: uppercase; padding: 5px 12px; cursor: pointer;
+ font-family: var(--sans);
+ }
+ .chips button.active { color: var(--ink); border-color: var(--metal); background: rgba(184,153,104,0.08); }
+ .chips button:hover { color: var(--ink); border-color: var(--metal); }
+
+ /* Magazine grid */
+ main.spread {
+ max-width: 1240px; margin: 0 auto; padding: 32px 24px 80px;
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 36px;
+ }
+
+ article.feature {
+ background: var(--paper);
+ padding: 8px 0;
+ position: relative;
+ break-inside: avoid;
+ }
+ article.feature .photo-frame {
+ aspect-ratio: 4/3;
+ background:
+ repeating-linear-gradient(45deg, rgba(0,0,0,0.04) 0 8px, transparent 8px 16px),
+ linear-gradient(135deg, #c8b894 0%, #8a7754 100%);
+ border: 1px solid var(--rule);
+ display: flex; align-items: center; justify-content: center;
+ color: var(--paper);
+ font-family: var(--serif); font-style: italic; font-size: 18px;
+ margin-bottom: 14px;
+ position: relative;
+ overflow: hidden;
+ }
+ article.feature .photo-frame .placeholder-text {
+ text-align: center; padding: 12px; opacity: 0.85;
+ text-shadow: 0 1px 2px rgba(0,0,0,0.3);
+ font-size: 13px; letter-spacing: 0.05em;
+ }
+ article.feature .photo-frame::after {
+ content: '';
+ position: absolute; inset: 0;
+ background: linear-gradient(180deg, transparent 60%, rgba(0,0,0,0.4));
+ }
+ article.feature .photo-frame .cat-tag {
+ position: absolute; top: 12px; left: 12px;
+ background: var(--ink); color: var(--paper);
+ padding: 4px 10px; font-size: 8px; letter-spacing: 0.3em;
+ text-transform: uppercase; font-family: var(--sans); font-weight: 500;
+ z-index: 2;
+ }
+
+ article.feature h2 {
+ font-family: var(--serif); font-weight: 500; font-style: italic;
+ font-size: 30px; line-height: 1.05; margin: 0 0 8px;
+ letter-spacing: -0.01em;
+ color: var(--ink);
+ }
+ article.feature .subhead {
+ font-family: var(--serif); font-weight: 300; font-style: italic;
+ font-size: 16px; color: var(--ink-mute); margin: 0 0 16px;
+ line-height: 1.4;
+ }
+ article.feature .lede {
+ font-family: var(--serif); font-weight: 400;
+ font-size: 16px; line-height: 1.65;
+ color: var(--ink); margin: 0 0 16px;
+ }
+ article.feature .lede::first-letter {
+ font-family: var(--serif); font-weight: 600;
+ font-size: 52px; line-height: 0.9; float: left;
+ margin: 4px 8px 0 0; color: var(--accent);
+ }
+ article.feature .pull {
+ font-family: var(--serif); font-style: italic; font-weight: 400;
+ font-size: 19px; line-height: 1.35;
+ color: var(--accent);
+ border-left: 2px solid var(--metal);
+ padding: 8px 0 8px 16px;
+ margin: 14px 0;
+ }
+ article.feature footer {
+ margin-top: 16px; padding-top: 12px;
+ border-top: 1px solid var(--rule);
+ display: flex; justify-content: space-between; align-items: baseline;
+ gap: 12px; flex-wrap: wrap;
+ }
+ article.feature footer .biz {
+ font-family: var(--sans); font-weight: 500; font-size: 11px;
+ color: var(--ink); letter-spacing: 0.02em;
+ }
+ article.feature footer .addr {
+ font-family: var(--mono); font-size: 9px; letter-spacing: 0.08em;
+ color: var(--ink-mute);
+ }
+ article.feature footer .actions {
+ display: flex; gap: 6px;
+ }
+ article.feature footer .actions button {
+ background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
+ font-size: 8px; letter-spacing: 0.22em; text-transform: uppercase;
+ padding: 3px 9px; cursor: pointer; font-family: var(--sans);
+ }
+ article.feature footer .actions button:hover { color: var(--metal); border-color: var(--metal); }
+ article.feature footer .actions button.publish { color: var(--accent); border-color: var(--accent); }
+ article.feature footer .actions button.regen { color: var(--metal); }
+
+ article.feature.s-published { border-top: 3px solid var(--accent); padding-top: 14px; }
+ article.feature.s-spiked { opacity: 0.4; }
+
+ .empty { text-align: center; padding: 80px 24px; color: var(--ink-mute); font-style: italic; font-family: var(--serif); font-size: 22px; }
+ .loading { text-align: center; padding: 60px; color: var(--ink-mute); font-size: 11px; letter-spacing: .26em; text-transform: uppercase; }
+
+ /* Print */
+ @media print {
+ nav.admin-rail, .chips, article.feature footer .actions { display: none; }
+ article.feature { break-inside: avoid; page-break-inside: avoid; }
+ }
+</style>
+</head>
+<body>
+
+<header class="masthead">
+ <div class="kicker">Volume I · Spring Issue · 2026</div>
+ <h1>The <em>Boulevard</em></h1>
+ <div class="deck">A magazine of who's here. The corridor's restaurants, professionals, ateliers, and ateliers-disguised-as-storefronts — written warm.</div>
+ <div class="issue" id="issue-stat">— · — · —</div>
+</header>
+
+<nav class="admin-rail">
+ <span style="color:var(--ink-mute)">admin:</span>
+ <a href="/today.html">today</a>
+ <a href="/pitches.html">pitches</a>
+ <a href="/buildings.html">buildings</a>
+ <a href="/walk-route.html">walk route</a>
+ <a href="/responses.html">responses</a>
+ <a href="/postcards.html">postcards</a>
+ <a href="/magazine.html" style="color:var(--metal-glow);font-weight:500">magazine</a>
+ <a href="/crawl-derby.html">🏇 derby</a>
+</nav>
+
+<div class="chips">
+ <button data-status="all" class="active">All features</button>
+ <button data-status="draft">⚙ Drafts (need review)</button>
+ <button data-status="reviewed">✓ Reviewed</button>
+ <button data-status="published">★ Published</button>
+ <button onclick="generateMore()">+ Generate 5 more (Mac1 Ollama)</button>
+</div>
+
+<main class="spread" id="spread">
+ <div class="loading">Loading the issue…</div>
+</main>
+
+<script>
+function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
+let currentStatus = 'all';
+
+async function load() {
+ try {
+ const url = currentStatus === 'all' ? '/api/magazine' : '/api/magazine?status=' + currentStatus;
+ const data = await fetch(url).then(r => r.json());
+ const rows = data.rows || [];
+ const counts = data.by_status || [];
+ const total = counts.reduce((a, x) => a + Number(x.n), 0);
+ const draftN = (counts.find(x => x.status === 'draft') || {}).n || 0;
+ const pubN = (counts.find(x => x.status === 'published') || {}).n || 0;
+ document.getElementById('issue-stat').textContent =
+ `${total} features · ${draftN} drafts awaiting review · ${pubN} published`;
+ const main = document.getElementById('spread');
+ if (!rows.length) {
+ main.innerHTML = `<div class="empty">No features yet.<br><br><span style="font-size:14px;font-style:normal;color:var(--metal)">Click "+ Generate 5 more" to seed the issue.</span></div>`;
+ return;
+ }
+ main.innerHTML = rows.map(r => {
+ const cat = (r.category_tag || 'shop').toUpperCase();
+ const photoTxt = `${escHtml(r.name)}\n${escHtml((r.address||'').split(',')[0])}`;
+ return `
+ <article class="feature s-${r.status}" data-id="${r.id}">
+ <div class="photo-frame">
+ <span class="cat-tag">${escHtml(cat)}</span>
+ <div class="placeholder-text">${escHtml(r.name)}<br><span style="font-size:10px;letter-spacing:.18em;text-transform:uppercase">photo TBD</span></div>
+ </div>
+ <h2>${escHtml(r.headline || '(untitled)')}</h2>
+ <div class="subhead">${escHtml(r.subhead || '')}</div>
+ <p class="lede">${escHtml(r.editorial || '')}</p>
+ ${r.pull_quote ? `<div class="pull">"${escHtml(r.pull_quote)}"</div>` : ''}
+ <footer>
+ <div>
+ <div class="biz">${escHtml(r.name)}</div>
+ <div class="addr">${escHtml(r.address || '')} · ${escHtml(r.city || '')}</div>
+ </div>
+ <div class="actions">
+ ${r.status === 'draft' ? `<button onclick="advance(${r.id}, 'reviewed')">✓ Mark reviewed</button>` : ''}
+ ${r.status === 'reviewed' ? `<button class="publish" onclick="advance(${r.id}, 'published')">★ Publish</button>` : ''}
+ ${r.status !== 'spiked' ? `<button onclick="advance(${r.id}, 'spiked')">✕ Spike</button>` : `<button onclick="advance(${r.id}, 'draft')">↶ Restore</button>`}
+ <button class="regen" onclick="regen(${r.business_id})">↻ Regen</button>
+ </div>
+ </footer>
+ </article>`;
+ }).join('');
+ } catch (e) {
+ document.getElementById('spread').innerHTML = `<div class="empty">load failed: ${e.message}</div>`;
+ }
+}
+
+async function advance(id, status) {
+ try {
+ const r = await fetch('/api/magazine/' + id, {
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status })
+ });
+ if (!r.ok) throw new Error(await r.text());
+ load();
+ } catch (e) { alert('failed: ' + e.message); }
+}
+
+async function regen(business_id) {
+ if (!confirm('Regenerate the editorial via Mac1 Ollama? Takes ~30s.')) return;
+ // POST to a tiny endpoint that respawns the generator for one biz
+ alert('Run this in terminal:\n\nnpx tsx src/jobs/generate_features.ts --biz=' + business_id + '\n\nThen click any chip to refresh.');
+}
+
+async function generateMore() {
+ alert('Run in terminal:\n\nnpm run magazine:gen 5\n\nTakes ~2 minutes for 5 features. Then click any chip to refresh.');
+}
+
+document.querySelectorAll('.chips button[data-status]').forEach(b => {
+ b.addEventListener('click', () => {
+ document.querySelectorAll('.chips button[data-status]').forEach(x => x.classList.remove('active'));
+ b.classList.add('active');
+ currentStatus = b.dataset.status;
+ load();
+ });
+});
+
+load();
+</script>
+</body>
+</html>
diff --git a/src/jobs/generate_features.ts b/src/jobs/generate_features.ts
new file mode 100644
index 0000000..6d5267e
--- /dev/null
+++ b/src/jobs/generate_features.ts
@@ -0,0 +1,128 @@
+/**
+ * Generate AI-written magazine features for corridor businesses.
+ * Uses Mac1 Ollama qwen3:14b (per Steve's "local LLM for non-DW work" rule).
+ *
+ * $ npx tsx src/jobs/generate_features.ts # generate 5 fresh features
+ * $ npx tsx src/jobs/generate_features.ts 20 # generate 20
+ * $ npx tsx src/jobs/generate_features.ts --biz=461 # regenerate one specific
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const OLLAMA = process.env.OLLAMA_URL || 'http://100.94.103.98:11434';
+const MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';
+
+const args = process.argv.slice(2);
+const SPECIFIC = (args.find(a => a.startsWith('--biz=')) || '').replace('--biz=', '');
+const COUNT = parseInt(args.find(a => /^\d+$/.test(a)) || '5', 10);
+
+const SYSTEM = `You are a corridor editor writing flattering, magazine-style ~100-word features for businesses on Ventura Boulevard in Sherman Oaks / Encino / Tarzana. Your tone is warm but never gushing — think Conde Nast Traveler shopping guide. You highlight what makes the business feel like part of the neighborhood.
+
+Output a JSON object with EXACTLY these fields:
+{
+ "headline": "6-10 word title",
+ "subhead": "12-18 word subtitle",
+ "editorial": "80-130 word feature paragraph",
+ "pull_quote": "8-15 word callout",
+ "category_tag": "one of: restaurant, professional, beauty, shop, fitness, medical, real-estate, hospitality, salon, automotive"
+}
+
+Rules:
+- Never invent specific prices, opening dates, owner names, or claims you can't verify.
+- Lean on the address as a sense of place ("just east of Sepulveda" / "in the white-stone tower at 15821 Ventura").
+- Avoid superlatives ("the BEST"). Prefer concrete sensory details.
+- Output ONLY the JSON. No preamble, no markdown fences.`;
+
+async function generate(biz: any): Promise<any> {
+ const userPrompt = `Business: ${biz.name}
+Address: ${biz.address || 'unknown'}, ${biz.city || ''} ${biz.zip || ''}
+Category: ${biz.naics || biz.pitch_type || biz.category || 'unknown'}
+Building context: ${biz.bldg_address ? `Inside the multi-tenant building at ${biz.bldg_address}` : 'Storefront on Ventura'}
+
+Write the feature.`;
+
+ const r = await fetch(`${OLLAMA}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: MODEL,
+ messages: [
+ { role: 'system', content: SYSTEM },
+ { role: 'user', content: userPrompt }
+ ],
+ stream: false,
+ options: { temperature: 0.7 },
+ format: 'json'
+ })
+ });
+ if (!r.ok) throw new Error(`ollama ${r.status}: ${await r.text()}`);
+ const j = await r.json();
+ const raw = j.message?.content || '';
+ try {
+ return JSON.parse(raw);
+ } catch (e) {
+ throw new Error(`bad JSON from model: ${raw.slice(0, 200)}`);
+ }
+}
+
+async function pickCandidates() {
+ if (SPECIFIC) {
+ const r = await query(
+ `SELECT b.id, b.name, b.address, b.city, b.zip, b.category,
+ b.raw->>'primary_naics_description' AS naics,
+ p.pitch_type,
+ TRIM(regexp_replace(b.address, '\\s*(SUITE|STE|UNIT|#).*$', '', 'i')) AS bldg_address
+ FROM businesses b LEFT JOIN pitches p ON p.business_id = b.id
+ WHERE b.id = $1`,
+ [parseInt(SPECIFIC, 10)]
+ );
+ return r.rows;
+ }
+ // Skip businesses already featured; prefer those with a pitch (better metadata)
+ const r = await query(
+ `SELECT b.id, b.name, b.address, b.city, b.zip, b.category,
+ b.raw->>'primary_naics_description' AS naics,
+ p.pitch_type,
+ TRIM(regexp_replace(b.address, '\\s*(SUITE|STE|UNIT|#).*$', '', 'i')) AS bldg_address
+ FROM businesses b
+ LEFT JOIN pitches p ON p.business_id = b.id
+ WHERE b.on_corridor
+ AND b.merged_into IS NULL
+ AND b.address IS NOT NULL
+ AND b.name NOT ILIKE '%LLC' AND b.name NOT ILIKE '%INC' AND b.name NOT ILIKE '%CORP'
+ AND length(b.name) BETWEEN 4 AND 60
+ AND NOT EXISTS (SELECT 1 FROM magazine_features mf WHERE mf.business_id = b.id)
+ ORDER BY (p.pitch_type IS NOT NULL) DESC, random()
+ LIMIT $1`,
+ [COUNT]
+ );
+ return r.rows;
+}
+
+async function main() {
+ const cands = await pickCandidates();
+ if (!cands.length) { console.log('[generate_features] no candidates'); await pool.end(); return; }
+ console.log(`[generate_features] generating ${cands.length} via ${MODEL} on ${OLLAMA}`);
+ for (const biz of cands) {
+ try {
+ const t0 = Date.now();
+ const feat = await generate(biz);
+ const ms = Date.now() - t0;
+ await query(
+ `INSERT INTO magazine_features (business_id, headline, subhead, editorial, pull_quote, category_tag, status, model, generated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7, NOW())
+ ON CONFLICT (business_id) DO UPDATE SET
+ headline=EXCLUDED.headline, subhead=EXCLUDED.subhead, editorial=EXCLUDED.editorial,
+ pull_quote=EXCLUDED.pull_quote, category_tag=EXCLUDED.category_tag,
+ model=EXCLUDED.model, generated_at=NOW(), status='draft'`,
+ [biz.id, feat.headline, feat.subhead, feat.editorial, feat.pull_quote, feat.category_tag, MODEL]
+ );
+ console.log(` ✓ ${biz.id} ${biz.name.slice(0,40).padEnd(40)} (${ms}ms) "${(feat.headline || '').slice(0,50)}"`);
+ } catch (e: any) {
+ console.log(` ✕ ${biz.id} ${biz.name.slice(0,40).padEnd(40)} — ${e.message.slice(0, 80)}`);
+ }
+ }
+ await pool.end();
+}
+
+main().catch(e => { console.error('[generate_features] fatal:', e); process.exit(1); });
diff --git a/src/server/index.ts b/src/server/index.ts
index 6259a89..851ad0c 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -70,6 +70,8 @@ const ADMIN_PATHS = [
/^\/api\/activity\/?$/i,
/^\/api\/data-freshness\/?$/i,
/^\/api\/stale-pitches\/?$/i,
+ /^\/magazine(\.html)?\/?$/i,
+ /^\/api\/magazine(\/.*)?$/i,
/^\/crawl-derby(\.html)?\/?$/i,
/^\/api\/crawl(\/.*)?$/i,
/^\/postcards(\.html)?\/?$/i,
@@ -1740,6 +1742,56 @@ app.get('/api/data-freshness', async (_req, res) => {
}
});
+// ─── Magazine: AI-generated features for corridor businesses ────────
+app.get('/api/magazine', async (req, res) => {
+ try {
+ const status = String(req.query.status || 'all'); // all | draft | reviewed | published
+ const where: string[] = [];
+ const params: any[] = [];
+ if (status !== 'all') { params.push(status); where.push(`mf.status = $${params.length}`); }
+ const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200);
+ params.push(limit);
+ const r = await query(
+ `SELECT mf.id, mf.business_id, mf.headline, mf.subhead, mf.editorial, mf.pull_quote,
+ mf.category_tag, mf.photo_url, mf.status, mf.model, mf.generated_at,
+ mf.reviewed_at, mf.published_at, mf.views, mf.notes,
+ b.name, b.address, b.city, b.zip,
+ b.raw->>'primary_naics_description' AS naics
+ FROM magazine_features mf
+ JOIN businesses b ON b.id = mf.business_id
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+ ORDER BY mf.generated_at DESC
+ LIMIT $${params.length}`,
+ params
+ );
+ const counts = await query(`SELECT status, count(*) AS n FROM magazine_features GROUP BY status`);
+ res.json({ count: r.rowCount, rows: r.rows, by_status: counts.rows });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.patch('/api/magazine/:id', express.json(), async (req, res) => {
+ try {
+ const id = parseInt(req.params.id, 10);
+ const allowed = ['headline','subhead','editorial','pull_quote','category_tag','photo_url','status','notes'];
+ const sets: string[] = [];
+ const params: any[] = [];
+ for (const k of allowed) if (k in (req.body || {})) {
+ params.push(req.body[k] === '' ? null : req.body[k]);
+ sets.push(`${k} = $${params.length}`);
+ }
+ if (req.body?.status === 'reviewed') sets.push('reviewed_at = NOW()');
+ if (req.body?.status === 'published') sets.push('published_at = COALESCE(published_at, NOW())');
+ if (!sets.length) return res.status(400).json({ error: 'no fields' });
+ params.push(id);
+ await query(`UPDATE magazine_features SET ${sets.join(', ')} WHERE id = $${params.length}`, params);
+ res.json({ ok: true });
+ } catch (e: any) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// ─── Stale pitches: sent >N days ago, no reply, not closed ─────────────
// Powers the "Cold leads" card on /today.html.
app.get('/api/stale-pitches', async (req, res) => {
← 67f2256 iter 72: weekly digest email job — src/jobs/weekly_digest.ts
·
back to Ventura Corridor
·
iter 74: bulk-generate 50 features now + nightly magazine la 78b1bea →