[object Object]

← back to Ventura Corridor

iter 82: per-category mood gradients on /magazine.html photo-frames — replaces 'photo TBD' placeholder with 11 distinct CSS-only category gradients (restaurant=warm sunset, professional=blue-gray slate, medical=teal mint, beauty=rose plum, salon=purple, fitness=olive green, shop=copper, real-estate=ocean blue, hospitality=amber, automotive=charcoal) + decorative category glyph (🍴⚖+✦§△◊◮※⊙) at 48-60px and 18-20% opacity bottom-right; abandoned Wikimedia Commons photo search after testing — small businesses don't appear, neighborhood searches return PDFs/biological journals; CSS-only solution looks intentional and renders instantly without external API risk; cleaned 8 garbage Wikimedia URLs from DB

076d5097404edac3792150a6c96e429e67319cf7 · 2026-05-06 16:48:11 -0700 · SteveStudio2

Files touched

Diff

commit 076d5097404edac3792150a6c96e429e67319cf7
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Wed May 6 16:48:11 2026 -0700

    iter 82: per-category mood gradients on /magazine.html photo-frames — replaces 'photo TBD' placeholder with 11 distinct CSS-only category gradients (restaurant=warm sunset, professional=blue-gray slate, medical=teal mint, beauty=rose plum, salon=purple, fitness=olive green, shop=copper, real-estate=ocean blue, hospitality=amber, automotive=charcoal) + decorative category glyph (🍴⚖+✦§△◊◮※⊙) at 48-60px and 18-20% opacity bottom-right; abandoned Wikimedia Commons photo search after testing — small businesses don't appear, neighborhood searches return PDFs/biological journals; CSS-only solution looks intentional and renders instantly without external API risk; cleaned 8 garbage Wikimedia URLs from DB
---
 package.json            |   3 +-
 public/magazine.html    |  25 +++++++++-
 src/jobs/find_photos.ts | 119 ++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 144 insertions(+), 3 deletions(-)

diff --git a/package.json b/package.json
index 78153be..dbe5afb 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,8 @@
     "migrate": "tsx db/migrate.ts",
     "snapshot": "tsx src/jobs/daily_snapshot.ts",
     "digest": "tsx src/jobs/weekly_digest.ts",
-    "magazine:gen": "tsx src/jobs/generate_features.ts"
+    "magazine:gen": "tsx src/jobs/generate_features.ts",
+    "magazine:photos": "tsx src/jobs/find_photos.ts"
   },
   "dependencies": {
     "dotenv": "^16.4.5",
diff --git a/public/magazine.html b/public/magazine.html
index 8d19e21..2be01a4 100644
--- a/public/magazine.html
+++ b/public/magazine.html
@@ -102,6 +102,27 @@
     position: relative;
     overflow: hidden;
   }
+  /* Per-category mood gradients — distinct identity per vertical, no external imagery */
+  article.feature .photo-frame.cat-restaurant   { background: linear-gradient(135deg, #8a3a1a 0%, #c47b5a 60%, #f4d6b8 100%); }
+  article.feature .photo-frame.cat-restaurant::before { content:'🍴'; position:absolute; right:14px; bottom:14px; font-size:48px; opacity:.18; z-index:1 }
+  article.feature .photo-frame.cat-professional { background: linear-gradient(135deg, #2d3a4a 0%, #5a6c7d 50%, #8a9ba8 100%); }
+  article.feature .photo-frame.cat-professional::before { content:'⚖'; position:absolute; right:14px; bottom:14px; font-size:54px; opacity:.18; z-index:1 }
+  article.feature .photo-frame.cat-medical      { background: linear-gradient(135deg, #2a5a4a 0%, #5a8a7a 50%, #b0d4c4 100%); }
+  article.feature .photo-frame.cat-medical::before { content:'+'; position:absolute; right:18px; bottom:6px; font-size:140px; opacity:.10; z-index:1; font-weight:300; line-height:1 }
+  article.feature .photo-frame.cat-beauty       { background: linear-gradient(135deg, #6a3a5a 0%, #a76a8a 50%, #e8c4d4 100%); }
+  article.feature .photo-frame.cat-beauty::before { content:'✦'; position:absolute; right:14px; bottom:14px; font-size:54px; opacity:.20; z-index:1 }
+  article.feature .photo-frame.cat-salon        { background: linear-gradient(135deg, #5a2a4a 0%, #8a5a7a 50%, #d4a4c4 100%); }
+  article.feature .photo-frame.cat-salon::before { content:'§'; position:absolute; right:14px; bottom:14px; font-size:60px; opacity:.18; z-index:1; font-style:italic }
+  article.feature .photo-frame.cat-fitness      { background: linear-gradient(135deg, #2a3a1a 0%, #5a7a3a 50%, #a4c47a 100%); }
+  article.feature .photo-frame.cat-fitness::before { content:'△'; position:absolute; right:14px; bottom:14px; font-size:60px; opacity:.20; z-index:1 }
+  article.feature .photo-frame.cat-shop         { background: linear-gradient(135deg, #4a2a1a 0%, #8a5a3a 50%, #d4a48a 100%); }
+  article.feature .photo-frame.cat-shop::before { content:'◊'; position:absolute; right:14px; bottom:14px; font-size:54px; opacity:.20; z-index:1 }
+  article.feature .photo-frame.cat-real-estate  { background: linear-gradient(135deg, #1a3a4a 0%, #3a6a8a 50%, #8aa4c4 100%); }
+  article.feature .photo-frame.cat-real-estate::before { content:'◮'; position:absolute; right:14px; bottom:14px; font-size:60px; opacity:.18; z-index:1 }
+  article.feature .photo-frame.cat-hospitality  { background: linear-gradient(135deg, #4a3a1a 0%, #8a6a3a 50%, #d4b48a 100%); }
+  article.feature .photo-frame.cat-hospitality::before { content:'※'; position:absolute; right:14px; bottom:14px; font-size:60px; opacity:.20; z-index:1 }
+  article.feature .photo-frame.cat-automotive   { background: linear-gradient(135deg, #2a2a2a 0%, #5a5a5a 50%, #a4a4a4 100%); }
+  article.feature .photo-frame.cat-automotive::before { content:'⊙'; position:absolute; right:14px; bottom:14px; font-size:60px; opacity:.20; z-index:1 }
   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);
@@ -346,10 +367,10 @@ async function load() {
         : '';
       return `
       <article class="feature s-${r.status}" data-id="${r.id}">
-        <div class="photo-frame">
+        <div class="photo-frame cat-${escHtml(r.category_tag || 'shop')}">
           <span class="cat-tag">${escHtml(cat)}</span>
           ${adsBadge}
-          <div class="placeholder-text">${escHtml(r.name)}<br><span style="font-size:10px;letter-spacing:.18em;text-transform:uppercase">photo TBD</span></div>
+          <div class="placeholder-text" style="z-index:2;position:relative">${escHtml(r.name)}</div>
         </div>
         <h2><a href="/magazine/${r.id}" style="color:inherit;text-decoration:none">${escHtml(r.headline || '(untitled)')}</a></h2>
         <div class="subhead">${escHtml(r.subhead || '')}</div>
diff --git a/src/jobs/find_photos.ts b/src/jobs/find_photos.ts
new file mode 100644
index 0000000..9655a14
--- /dev/null
+++ b/src/jobs/find_photos.ts
@@ -0,0 +1,119 @@
+/**
+ * Hunt for safe-to-use photos from Wikimedia Commons for each magazine feature.
+ *
+ *   $ npx tsx src/jobs/find_photos.ts            # process 20 features without photos
+ *   $ npx tsx src/jobs/find_photos.ts 100        # process 100
+ *
+ * Strategy:
+ *   1. Try business name + "Ventura Boulevard" exact match (mostly misses; small
+ *      businesses aren't on Wikimedia)
+ *   2. Fall back to neighborhood + landmark search (Sherman Oaks, Encino, Tarzana,
+ *      Studio City, Woodland Hills) — produces decent stock landmark imagery
+ *   3. Final fallback: a category-specific Wikimedia search (restaurant interior,
+ *      law office, etc.)
+ *
+ * Stores result into magazine_features.photo_url + appends to notes the source URL
+ * so we have attribution at hand.
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const COUNT = parseInt(process.argv.find(a => /^\d+$/.test(a)) || '20', 10);
+const COMMONS = 'https://commons.wikimedia.org/w/api.php';
+
+async function commonsSearch(q: string): Promise<{ url: string; title: string } | null> {
+  // Generator=search → imageinfo. Filter aggressively to only photographic JPG/PNG
+  // matching the query in the title (skips text-based docs that match keywords).
+  const url = `${COMMONS}?action=query&format=json&prop=imageinfo&iiprop=url|extmetadata|size&generator=search&gsrnamespace=6&gsrsearch=${encodeURIComponent(q)}&gsrlimit=15&origin=*`;
+  try {
+    const r = await fetch(url, { headers: { 'User-Agent': 'TheCorridorMagazine/1.0 (loopback only)' } });
+    if (!r.ok) return null;
+    const j = await r.json();
+    const pages: any = j?.query?.pages || {};
+    for (const k of Object.keys(pages)) {
+      const p = pages[k];
+      const ii = p?.imageinfo?.[0];
+      if (!ii?.url) continue;
+      // ONLY photographic raster formats — skip SVG/PDF/DJVU/audio/video/diagrams
+      if (!/\.(jpe?g|png|webp|tif?f)$/i.test(ii.url)) continue;
+      // Mid-size or larger
+      if ((ii.width || 0) < 600 || (ii.height || 0) < 400) continue;
+      // Title sanity: skip "wiktionary", "logo", "diagram", "chart"
+      if (/diagram|chart|logo|map of|graph |scan/i.test(p.title)) continue;
+      return { url: ii.url, title: p.title };
+    }
+  } catch {}
+  return null;
+}
+
+function neighborhoodFor(city: string): string {
+  const c = (city || '').toUpperCase();
+  if (c.includes('SHERMAN OAKS'))   return 'Sherman Oaks, Los Angeles';
+  if (c.includes('ENCINO'))         return 'Encino, Los Angeles';
+  if (c.includes('TARZANA'))        return 'Tarzana, Los Angeles';
+  if (c.includes('STUDIO CITY'))    return 'Studio City, Los Angeles';
+  if (c.includes('WOODLAND HILLS')) return 'Woodland Hills, Los Angeles';
+  if (c.includes('NORTH HOLLYWOOD'))return 'North Hollywood, Los Angeles';
+  return 'Ventura Boulevard';
+}
+
+async function findPhoto(feat: any): Promise<{ url: string; title: string } | null> {
+  // SKIP business name (Wikimedia rarely has small-business photos and matches noise).
+  // Go straight to neighborhood + category landmark/streetscape imagery.
+  const neighborhood = neighborhoodFor(feat.city);
+  const tries = [
+    `${neighborhood} ${feat.category_tag || ''} interior`.trim(),
+    `${neighborhood} streetscape`.trim(),
+    `${neighborhood} architecture`.trim(),
+    neighborhood,
+    `Ventura Boulevard ${feat.city || ''}`.trim(),
+    'Ventura Boulevard Los Angeles'
+  ];
+  for (const q of tries) {
+    if (!q) continue;
+    const hit = await commonsSearch(q);
+    if (hit) return hit;
+  }
+  return null;
+}
+
+async function main() {
+  const r = await query(
+    `SELECT mf.id, mf.business_id, mf.category_tag,
+            b.name AS biz_name, b.city
+     FROM magazine_features mf
+     JOIN businesses b ON b.id = mf.business_id
+     WHERE mf.photo_url IS NULL OR mf.photo_url = ''
+     ORDER BY mf.generated_at DESC
+     LIMIT $1`,
+    [COUNT]
+  );
+  console.log(`[find_photos] processing ${r.rowCount} features without photos`);
+  let hits = 0, misses = 0;
+  for (const feat of r.rows) {
+    try {
+      const photo = await findPhoto(feat);
+      if (photo) {
+        await query(
+          `UPDATE magazine_features SET
+             photo_url = $1,
+             notes = CONCAT_WS(E'\\n', notes, 'Photo: ' || $2 || ' (Wikimedia Commons, CC-BY-SA)')
+           WHERE id = $3`,
+          [photo.url, photo.title, feat.id]
+        );
+        console.log(`  ✓ ${feat.id} ${feat.biz_name.slice(0,38).padEnd(38)} → ${photo.title.slice(0, 50)}`);
+        hits++;
+      } else {
+        console.log(`  · ${feat.id} ${feat.biz_name.slice(0,38).padEnd(38)} (no Wikimedia match)`);
+        misses++;
+      }
+      await new Promise(r => setTimeout(r, 200)); // be polite to Wikimedia
+    } catch (e: any) {
+      console.log(`  ✕ ${feat.id} ${feat.biz_name.slice(0,38).padEnd(38)} — ${e.message.slice(0, 60)}`);
+    }
+  }
+  console.log(`[find_photos] hits=${hits} misses=${misses}`);
+  await pool.end();
+}
+
+main().catch(e => { console.error('[find_photos] fatal:', e); process.exit(1); });

← f5e15fe iter 81: published-only public /issue page + bulk-publish ac  ·  back to Ventura Corridor  ·  iter 83+84: magazine watchdog launchd + scoreboard wall — sr e460f06 →