[object Object]

← back to Allnewsdaily

front page: Drudge-style photos — big splash photo + a lead photo per column; dedupe to 1 article per topic

bf0d0443b2595b3017ba89873b47cf896d36f2b3 · 2026-09-10 07:33:15 -0700 · Steve Abrams

- rss.js: extract lead image per item (media:content/thumbnail, image enclosure, first <img> in desc); https-only URL guard
- aggregate.js: carry image through; dedupe near-identical topics wire-wide; splash prefers image-bearing story + removed from its column
- server.js: render splash photo + per-column lead photo card (SSR + client paint identical), lazy+no-referrer imgs, 16:9 aspect-ratio, graceful no-photo fallback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QqMmD8S45PEQRKbQM1KBo

Files touched

Diff

commit bf0d0443b2595b3017ba89873b47cf896d36f2b3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 07:33:15 2026 -0700

    front page: Drudge-style photos — big splash photo + a lead photo per column; dedupe to 1 article per topic
    
    - rss.js: extract lead image per item (media:content/thumbnail, image enclosure, first <img> in desc); https-only URL guard
    - aggregate.js: carry image through; dedupe near-identical topics wire-wide; splash prefers image-bearing story + removed from its column
    - server.js: render splash photo + per-column lead photo card (SSR + client paint identical), lazy+no-referrer imgs, 16:9 aspect-ratio, graceful no-photo fallback
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_019QqMmD8S45PEQRKbQM1KBo
---
 lib/aggregate.js | 20 +++++++++++++++++---
 lib/rss.js       | 33 +++++++++++++++++++++++++++++++--
 server.js        | 35 +++++++++++++++++++++++++++++------
 3 files changed, 77 insertions(+), 11 deletions(-)

diff --git a/lib/aggregate.js b/lib/aggregate.js
index 978ffac..91f6519 100644
--- a/lib/aggregate.js
+++ b/lib/aggregate.js
@@ -69,14 +69,28 @@ async function rebuild() {
     for (const c of cols) {
       c.items = c.items.map((it) => {
         const r = byId.get(it.link + '|' + it.title);
-        return { outlet: it.outlet, link: it.link, date: it.date, topic: (r && r.topic) || it.title };
+        return { outlet: it.outlet, link: it.link, date: it.date, topic: (r && r.topic) || it.title, image: it.image || '' };
       });
     }
 
-    // Splash = newest item across the US + World columns
+    // "1 article per topic": dedupe near-identical stories so each topic appears once across the whole wire.
+    const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim().split(' ').slice(0, 10).join(' ');
+    const seenTopic = new Set();
+    for (const c of cols) {
+      c.items = c.items.filter((it) => {
+        const k = norm(it.topic);
+        if (!k || seenTopic.has(k)) return false;
+        seenTopic.add(k);
+        return true;
+      });
+    }
+
+    // Splash = newest image-bearing item across the US + World columns (fallback: newest overall).
     const splashPool = cols.filter((c) => c.key !== 'money_tech').flatMap((c) => c.items);
     splashPool.sort((a, b) => parseDate(b.date) - parseDate(a.date));
-    const splash = splashPool[0] || (cols[0] && cols[0].items[0]) || null;
+    const splash = splashPool.find((it) => it.image) || splashPool[0] || (cols[0] && cols[0].items[0]) || null;
+    // Don't repeat the splash story inside its column.
+    if (splash) for (const c of cols) c.items = c.items.filter((it) => it.link !== splash.link);
 
     WIRE = {
       columns: cols,
diff --git a/lib/rss.js b/lib/rss.js
index a18fce5..e554f02 100644
--- a/lib/rss.js
+++ b/lib/rss.js
@@ -30,6 +30,33 @@ function pickLink(block) {
   return decodeEntities(txt);
 }
 
+function absUrl(u) {
+  if (!u) return '';
+  u = String(u).trim().replace(/&amp;/g, '&');
+  if (u.startsWith('//')) u = 'https:' + u;             // protocol-relative → https
+  else if (u.startsWith('http://')) u = 'https://' + u.slice(7); // upgrade to avoid mixed-content on our HTTPS page
+  // Only allow clean https URLs — blocks data:/javascript:/file:/malformed schemes and stray whitespace.
+  if (!/^https:\/\/[^\s"'<>]+$/i.test(u)) return '';
+  return u;
+}
+
+// Extract a lead image URL from a feed item block: media:content/thumbnail,
+// image enclosure, or the first <img> inside description/content (raw or entity-encoded).
+function pickImage(block) {
+  let m = block.match(/<media:(?:content|thumbnail)[^>]*\burl=["']([^"']+)["']/i);
+  if (m) return absUrl(m[1]);
+  m = block.match(/<enclosure[^>]*\burl=["']([^"']+)["'][^>]*\btype=["']image\//i)
+    || block.match(/<enclosure[^>]*\btype=["']image\/[^"']*["'][^>]*\burl=["']([^"']+)["']/i);
+  if (m) return absUrl(m[1]);
+  const desc = pick(block, 'content:encoded') || pick(block, 'description') || pick(block, 'content') || pick(block, 'summary');
+  const html = desc
+    .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"').replace(/&#0*34;/g, '"').replace(/&#0*39;/g, "'").replace(/&apos;/g, "'");
+  m = html.match(/<img[^>]+\bsrc=["']([^"']+)["']/i);
+  if (m) return absUrl(m[1]);
+  return '';
+}
+
 function parseFeed(xml) {
   const items = [];
   // RSS/RDF <item> ... </item>
@@ -44,7 +71,8 @@ function parseFeed(xml) {
       title,
       link,
       date: decodeEntities(pick(b, 'pubDate') || pick(b, 'dc:date') || pick(b, 'date')),
-      summary: decodeEntities(pick(b, 'description') || pick(b, 'summary')).slice(0, 400)
+      summary: decodeEntities(pick(b, 'description') || pick(b, 'summary')).slice(0, 400),
+      image: pickImage(b)
     });
   }
   // Atom <entry> ... </entry>
@@ -59,7 +87,8 @@ function parseFeed(xml) {
         title,
         link,
         date: decodeEntities(pick(b, 'updated') || pick(b, 'published')),
-        summary: decodeEntities(pick(b, 'summary') || pick(b, 'content')).slice(0, 400)
+        summary: decodeEntities(pick(b, 'summary') || pick(b, 'content')).slice(0, 400),
+        image: pickImage(b)
       });
     }
   }
diff --git a/server.js b/server.js
index b5706c8..b80a8f9 100644
--- a/server.js
+++ b/server.js
@@ -85,15 +85,26 @@ function renderFront() {
   const storyLink = (it) =>
     `<a class="story" href="${esc(it.link)}" target="_blank" rel="noopener nofollow">${esc(cap20(it.topic))}<span class="src"> — ${esc(it.outlet)}</span></a>`;
 
-  const columnsHtml = (wire.columns || []).map((c) => `
+  const leadCard = (it) =>
+    `<a class="lead" href="${esc(it.link)}" target="_blank" rel="noopener nofollow"><img class="lead-img" src="${esc(it.image)}" loading="lazy" referrerpolicy="no-referrer" alt="" onerror="this.remove()"><span class="lead-topic">${esc(cap20(it.topic))}<span class="src"> — ${esc(it.outlet)}</span></span></a>`;
+
+  const columnsHtml = (wire.columns || []).map((c) => {
+    const items = c.items || [];
+    const li = items.findIndex((it) => it.image);
+    const lead = li >= 0 ? leadCard(items[li]) : '';
+    const rest = items.filter((_, i) => i !== li).map(storyLink).join('\n');
+    return `
     <section class="col">
       <h2 class="colhead">${esc(c.title)}</h2>
-      ${(c.items || []).map(storyLink).join('\n')}
-    </section>`).join('\n');
+      ${lead}
+      ${rest}
+    </section>`;
+  }).join('\n');
 
   const splashHtml = wire.splash ? `
     <a class="splash" href="${esc(wire.splash.link)}" target="_blank" rel="noopener nofollow">
-      ${esc(cap20(wire.splash.topic))}<span class="src"> — ${esc(wire.splash.outlet)}</span>
+      ${wire.splash.image ? `<img class="splash-img" src="${esc(wire.splash.image)}" loading="lazy" referrerpolicy="no-referrer" alt="" onerror="this.remove()">` : ''}
+      <span class="splash-topic">${esc(cap20(wire.splash.topic))}<span class="src"> — ${esc(wire.splash.outlet)}</span></span>
     </a>` : `<div class="splash placeholder">Assembling today's wire…</div>`;
 
   const columnistsHtml = (cfg.columnists || []).map((p) =>
@@ -161,11 +172,15 @@ ${bannerAd('top')}
   function fmtTime(ms){var s=Math.max(0,Math.round(ms/1000));var m=Math.floor(s/60);var r=s%60;return m+':'+(r<10?'0':'')+r;}
   function fmtET(iso){try{return new Date(iso).toLocaleTimeString('en-US',{hour:'numeric',minute:'2-digit',timeZone:'America/New_York'})+' ET';}catch(e){return '';}}
   function story(it){return '<a class="story" href="'+esc(it.link)+'" target="_blank" rel="noopener nofollow">'+esc(cap20(it.topic))+'<span class="src"> — '+esc(it.outlet)+'</span></a>';}
+  function leadCard(it){return '<a class="lead" href="'+esc(it.link)+'" target="_blank" rel="noopener nofollow"><img class="lead-img" src="'+esc(it.image)+'" loading="lazy" referrerpolicy="no-referrer" alt="" onerror="this.remove()"><span class="lead-topic">'+esc(cap20(it.topic))+'<span class="src"> — '+esc(it.outlet)+'</span></span></a>';}
   function paint(w){
-    if(w.splash && elSplash){ elSplash.innerHTML='<a class="splash" href="'+esc(w.splash.link)+'" target="_blank" rel="noopener nofollow">'+esc(cap20(w.splash.topic))+'<span class="src"> — '+esc(w.splash.outlet)+'</span></a>'; }
+    if(w.splash && elSplash){ var s=w.splash; elSplash.innerHTML='<a class="splash" href="'+esc(s.link)+'" target="_blank" rel="noopener nofollow">'+(s.image?'<img class="splash-img" src="'+esc(s.image)+'" loading="lazy" referrerpolicy="no-referrer" alt="" onerror="this.remove()">':'')+'<span class="splash-topic">'+esc(cap20(s.topic))+'<span class="src"> — '+esc(s.outlet)+'</span></span></a>'; }
     if(Array.isArray(w.columns) && elCols){
       elCols.innerHTML = w.columns.map(function(c){
-        return '<section class="col"><h2 class="colhead">'+esc(c.title)+'</h2>'+(c.items||[]).map(story).join('')+'</section>';
+        var items=c.items||[]; var li=-1; for(var i=0;i<items.length;i++){ if(items[i].image){li=i;break;} }
+        var lead= li>=0? leadCard(items[li]) : '';
+        var rest= items.filter(function(_,i){return i!==li;}).map(story).join('');
+        return '<section class="col"><h2 class="colhead">'+esc(c.title)+'</h2>'+lead+rest+'</section>';
       }).join('');
     }
     if(w.updatedAt && elUpd){ elUpd.textContent = 'Updated '+fmtET(w.updatedAt)+' · '+(w.sourcesOk||0)+'/'+(w.sources||0)+' sources'; }
@@ -358,6 +373,14 @@ main{max-width:1180px;margin:0 auto;padding:0 12px 60px}
   font-size:clamp(16px,2.5vw,23px);line-height:1.18;padding:12px 12px 6px;letter-spacing:.3px}
 .splash:hover{text-decoration:underline}.splash.placeholder{color:#888;font-style:italic;text-transform:none}
 .splash .src{color:#7a2a2a;font-weight:400;font-size:.62em}
+.splash-img{display:block;margin:0 auto 8px;width:100%;max-width:min(560px,100%);height:auto;border:1px solid #cbc9c0}
+.splash-topic{display:block}
+/* column lead photo card (Drudge-style) */
+.lead{display:block;text-decoration:none;padding:2px 0 8px;margin-bottom:7px;border-bottom:2px solid #000}
+.lead-img{display:block;width:100%;aspect-ratio:16/9;object-fit:cover;margin-bottom:5px;border:1px solid #cbc9c0;background:#e9e7df}
+.lead-topic{display:block;color:var(--red);font-weight:700;font-size:13px;line-height:1.2;text-transform:uppercase;letter-spacing:.2px}
+.lead:hover .lead-topic{text-decoration:underline}
+.lead .src{color:var(--src);font-weight:400;font-style:italic;text-transform:none;font-size:11px}
 .grid{display:grid;grid-template-columns:repeat(3,1fr);gap:0 26px}
 .col{padding:6px 0 10px;border-left:1px solid #ddd;padding-left:14px}
 .col:first-child{border-left:0;padding-left:0}

← af0cd1b front page: Drudge-scale smaller text + cap headlines to 20  ·  back to Allnewsdaily  ·  Document verified proxy cutover blocker and corrected handof db75327 →