← 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
M lib/aggregate.jsM lib/rss.jsM server.js
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(/&/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(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/�*34;/g, '"').replace(/�*39;/g, "'").replace(/'/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 →