[object Object]

← back to Allnewsdaily

Drudge-style front page: live free-RSS wire rewritten into original 1-sentence topics (qwen3:14b, cached) + Columnists/Magazines rails; outlet directory preserved at /directory; AdSense + trust layer intact

45634689d17d78b78b46589a6ae4cdfef9dc2b93 · 2026-09-09 11:22:02 -0700 · Steve Abrams

- lib/rss.js: zero-dep RSS/Atom parser
- lib/paraphrase.js: $0-local headline→original-sentence rewrite (Ollama qwen3:14b, strict no-embellishment prompt, URL-hash cache, deterministic fallback)
- lib/aggregate.js: fetch ~21 free feeds → dedupe → rewrite (concurrency 8) → in-memory wire, 10-min refresh
- server.js: / renders server-side Drudge front; /directory = old grid; /api/wire; sitemap += /directory
- data/feeds.json: curated free feeds + 16 columnists + 10 magazines

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

Files touched

Diff

commit 45634689d17d78b78b46589a6ae4cdfef9dc2b93
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 11:22:02 2026 -0700

    Drudge-style front page: live free-RSS wire rewritten into original 1-sentence topics (qwen3:14b, cached) + Columnists/Magazines rails; outlet directory preserved at /directory; AdSense + trust layer intact
    
    - lib/rss.js: zero-dep RSS/Atom parser
    - lib/paraphrase.js: $0-local headline→original-sentence rewrite (Ollama qwen3:14b, strict no-embellishment prompt, URL-hash cache, deterministic fallback)
    - lib/aggregate.js: fetch ~21 free feeds → dedupe → rewrite (concurrency 8) → in-memory wire, 10-min refresh
    - server.js: / renders server-side Drudge front; /directory = old grid; /api/wire; sitemap += /directory
    - data/feeds.json: curated free feeds + 16 columnists + 10 magazines
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_0177CKXFpA1rVH3Sk24fgRZ9
---
 lib/aggregate.js  |  92 +++++++++++++++++++++++++++++++++++++++
 lib/paraphrase.js | 114 ++++++++++++++++++++++++++++++++++++++++++++++++
 lib/rss.js        |  91 ++++++++++++++++++++++++++++++++++++++
 server.js         | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 423 insertions(+), 1 deletion(-)

diff --git a/lib/aggregate.js b/lib/aggregate.js
new file mode 100644
index 0000000..6fcf3b9
--- /dev/null
+++ b/lib/aggregate.js
@@ -0,0 +1,92 @@
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const { fetchFeed } = require('./rss');
+const { rewriteAll } = require('./paraphrase');
+
+const FEEDS_PATH = path.join(__dirname, '..', 'data', 'feeds.json');
+const PER_FEED = parseInt(process.env.PER_FEED || '5', 10);          // items kept per feed
+const PER_COLUMN = parseInt(process.env.PER_COLUMN || '22', 10);      // items shown per column
+
+function loadFeeds() {
+  return JSON.parse(fs.readFileSync(FEEDS_PATH, 'utf8'));
+}
+
+function parseDate(s) {
+  const t = Date.parse(s || '');
+  return Number.isNaN(t) ? 0 : t;
+}
+
+// In-memory wire cache
+let WIRE = { columns: [], splash: null, updatedAt: null, building: false, sources: 0, sourcesOk: 0 };
+
+async function buildColumn(colKey, col) {
+  const results = await Promise.all(
+    col.feeds.map(async (f) => {
+      const r = await fetchFeed(f.url);
+      return (r.items || []).slice(0, PER_FEED).map((it) => ({ ...it, outlet: f.outlet }));
+    })
+  );
+  // flatten, dedupe by link, sort newest first
+  const seen = new Set();
+  let items = [];
+  for (const arr of results) {
+    for (const it of arr) {
+      const id = it.link.split('#')[0].split('?')[0];
+      if (seen.has(id)) continue;
+      seen.add(id);
+      items.push(it);
+    }
+  }
+  items.sort((a, b) => parseDate(b.date) - parseDate(a.date));
+  items = items.slice(0, PER_COLUMN);
+  const okCount = results.filter((a) => a.length > 0).length;
+  return { key: colKey, title: col.title, items, ok: okCount, total: col.feeds.length };
+}
+
+async function rebuild() {
+  if (WIRE.building) return WIRE;
+  WIRE.building = true;
+  try {
+    const feeds = loadFeeds();
+    const cols = await Promise.all(
+      Object.entries(feeds.columns).map(([k, c]) => buildColumn(k, c))
+    );
+
+    // Rewrite every headline into an original topic sentence (cached by URL).
+    const flat = [];
+    for (const c of cols) for (const it of c.items) flat.push(it);
+    const rewritten = await rewriteAll(flat, { concurrency: 8 });
+    // map back by link+title
+    const byId = new Map(rewritten.map((r) => [r.link + '|' + r.title, r]));
+    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 };
+      });
+    }
+
+    // Splash = newest item across the US + World columns
+    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;
+
+    WIRE = {
+      columns: cols,
+      splash,
+      updatedAt: new Date().toISOString(),
+      building: false,
+      sources: cols.reduce((n, c) => n + c.total, 0),
+      sourcesOk: cols.reduce((n, c) => n + c.ok, 0)
+    };
+  } catch (e) {
+    console.error('[aggregate] rebuild failed', e.message);
+    WIRE.building = false;
+  }
+  return WIRE;
+}
+
+function getWire() { return WIRE; }
+function meta() { return loadFeeds(); }
+
+module.exports = { rebuild, getWire, meta };
diff --git a/lib/paraphrase.js b/lib/paraphrase.js
new file mode 100644
index 0000000..d8c55fc
--- /dev/null
+++ b/lib/paraphrase.js
@@ -0,0 +1,114 @@
+'use strict';
+// Rewrite a news headline into ONE original topic sentence (same story, different words).
+// $0 local via Ollama (hermes3:8b). Cached by URL hash so each story is rewritten once.
+// Deterministic fallback keeps the page working if Ollama is unreachable.
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
+const MODEL = process.env.PARAPHRASE_MODEL || 'qwen3:14b';
+const CACHE_PATH = path.join(__dirname, '..', 'data', 'paraphrase-cache.json');
+const MAX_CACHE = 5000;
+
+let cache = {};
+try { cache = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); } catch (_) { cache = {}; }
+
+let dirty = false;
+function key(link, title) {
+  return crypto.createHash('sha1').update((link || '') + '|' + (title || '')).digest('hex').slice(0, 16);
+}
+function saveCache() {
+  if (!dirty) return;
+  try {
+    // trim oldest if oversized (object insertion order ~ age)
+    const keys = Object.keys(cache);
+    if (keys.length > MAX_CACHE) {
+      const trimmed = {};
+      for (const k of keys.slice(keys.length - MAX_CACHE)) trimmed[k] = cache[k];
+      cache = trimmed;
+    }
+    fs.writeFileSync(CACHE_PATH, JSON.stringify(cache));
+    dirty = false;
+  } catch (e) { console.error('[paraphrase] cache save failed', e.message); }
+}
+
+// Light, deterministic rewrite used only when the model is unavailable.
+// It is NOT the headline verbatim — it de-headline-ifies and reframes as a topic line.
+function fallbackRewrite(title, outlet) {
+  let t = String(title || '').trim().replace(/\s+/g, ' ');
+  t = t.replace(/\s*[-–—|:]\s*[^-–—|:]{1,40}$/, ''); // drop trailing " - Outlet" style tails
+  if (!t) return '';
+  const lower = t.charAt(0).toLowerCase() + t.slice(1);
+  return `Report: ${lower}`.slice(0, 180);
+}
+
+async function callOllama(title, summary, { timeoutMs = 20000 } = {}) {
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), timeoutMs);
+  const prompt =
+    '/no_think You rewrite a news headline into ONE plain factual sentence in your own words. ' +
+    'STRICT RULES: use ONLY information in the headline (and context if given); add NO new facts, names, numbers, dates, or adjectives; ' +
+    'do NOT copy the headline wording verbatim; ban hype words like groundbreaking, highly anticipated, stunning, revolutionary, shocking; ' +
+    'neutral declarative tone; max 160 characters; output ONLY the sentence, no quotes, no prefix.\n\n' +
+    `Headline: ${title}\n` +
+    (summary ? `Context: ${summary.slice(0, 200)}\n` : '') +
+    'Sentence:';
+  try {
+    const res = await fetch(`${OLLAMA}/api/generate`, {
+      method: 'POST',
+      signal: ctrl.signal,
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ model: MODEL, prompt, stream: false, think: false, options: { temperature: 0.2, num_predict: 90 } })
+    });
+    if (!res.ok) return null;
+    const j = await res.json();
+    let out = (j.response || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+    out = out.replace(/^["'\s]+|["'\s]+$/g, '').replace(/^Sentence:\s*/i, '').replace(/\s+/g, ' ');
+    if (out.length < 12) return null;
+    if (out.length > 200) out = out.slice(0, 197).replace(/\s\S*$/, '') + '…';
+    return out;
+  } catch (_) {
+    return null;
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+// Rewrite one item, using cache. Returns { text, model } and never throws.
+async function rewriteOne(item) {
+  const k = key(item.link, item.title);
+  if (cache[k] && cache[k].text) return { text: cache[k].text, cached: true };
+  const modelOut = await callOllama(item.title, item.summary);
+  const text = modelOut || fallbackRewrite(item.title, item.outlet);
+  if (text) {
+    cache[k] = { text, model: modelOut ? MODEL : 'fallback', ts: Date.now() };
+    dirty = true;
+  }
+  return { text, cached: false, model: modelOut ? MODEL : 'fallback' };
+}
+
+// Rewrite a list with bounded concurrency; persists cache once at the end.
+async function rewriteAll(items, { concurrency = 4 } = {}) {
+  const out = new Array(items.length);
+  let i = 0;
+  async function worker() {
+    while (i < items.length) {
+      const idx = i++;
+      const r = await rewriteOne(items[idx]);
+      out[idx] = { ...items[idx], topic: r.text, rewriteModel: r.model || (cache[key(items[idx].link, items[idx].title)] || {}).model };
+    }
+  }
+  await Promise.all(Array.from({ length: Math.min(concurrency, items.length || 1) }, worker));
+  saveCache();
+  return out;
+}
+
+// Synchronous read: topic if already cached, else null (used for instant first paint).
+function cachedTopic(item) {
+  const k = key(item.link, item.title);
+  return cache[k] && cache[k].text ? cache[k].text : null;
+}
+
+module.exports = { rewriteAll, rewriteOne, cachedTopic, fallbackRewrite };
diff --git a/lib/rss.js b/lib/rss.js
new file mode 100644
index 0000000..a18fce5
--- /dev/null
+++ b/lib/rss.js
@@ -0,0 +1,91 @@
+'use strict';
+// Zero-dependency RSS 2.0 / RDF / Atom parser. Node 18+ global fetch.
+// We only need: title, link, pubDate, and a short summary per item.
+
+function decodeEntities(s) {
+  if (!s) return '';
+  return String(s)
+    .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
+    .replace(/<[^>]+>/g, ' ')              // strip any nested HTML in summaries
+    .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
+    .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
+    .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&nbsp;/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
+function pick(block, tag) {
+  const m = block.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, 'i'));
+  return m ? m[1] : '';
+}
+
+// Atom <link href="..."/> (prefer rel="alternate"), else RSS <link>text</link>
+function pickLink(block) {
+  const alt = block.match(/<link[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/i);
+  if (alt) return alt[1];
+  const href = block.match(/<link[^>]*href=["']([^"']+)["'][^>]*\/?>/i);
+  if (href) return href[1];
+  const txt = pick(block, 'link');
+  return decodeEntities(txt);
+}
+
+function parseFeed(xml) {
+  const items = [];
+  // RSS/RDF <item> ... </item>
+  const itemRe = /<item(?:\s[^>]*)?>([\s\S]*?)<\/item>/gi;
+  let m;
+  while ((m = itemRe.exec(xml)) && items.length < 40) {
+    const b = m[1];
+    const title = decodeEntities(pick(b, 'title'));
+    const link = pickLink(b).trim();
+    if (!title || !link) continue;
+    items.push({
+      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)
+    });
+  }
+  // Atom <entry> ... </entry>
+  if (items.length === 0) {
+    const entryRe = /<entry(?:\s[^>]*)?>([\s\S]*?)<\/entry>/gi;
+    while ((m = entryRe.exec(xml)) && items.length < 40) {
+      const b = m[1];
+      const title = decodeEntities(pick(b, 'title'));
+      const link = pickLink(b).trim();
+      if (!title || !link) continue;
+      items.push({
+        title,
+        link,
+        date: decodeEntities(pick(b, 'updated') || pick(b, 'published')),
+        summary: decodeEntities(pick(b, 'summary') || pick(b, 'content')).slice(0, 400)
+      });
+    }
+  }
+  return items;
+}
+
+async function fetchFeed(url, { timeoutMs = 12000 } = {}) {
+  const ctrl = new AbortController();
+  const t = setTimeout(() => ctrl.abort(), timeoutMs);
+  try {
+    const res = await fetch(url, {
+      signal: ctrl.signal,
+      redirect: 'follow',
+      headers: {
+        'User-Agent': 'AllNewsDaily/1.0 (+https://allnewsdaily.com; news aggregator)',
+        'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*'
+      }
+    });
+    if (!res.ok) return { ok: false, status: res.status, items: [] };
+    const xml = await res.text();
+    return { ok: true, status: res.status, items: parseFeed(xml) };
+  } catch (e) {
+    return { ok: false, error: e.message, items: [] };
+  } finally {
+    clearTimeout(t);
+  }
+}
+
+module.exports = { fetchFeed, parseFeed, decodeEntities };
diff --git a/server.js b/server.js
index 7d4806e..f988975 100644
--- a/server.js
+++ b/server.js
@@ -3,6 +3,7 @@ const fs = require('fs');
 const path = require('path');
 const { spawn } = require('child_process');
 const { GUIDES } = require('./content/guides');
+const { rebuild, getWire, meta } = require('./lib/aggregate');
 
 const app = express();
 const PORT = process.env.PORT || 9788;
@@ -45,7 +46,87 @@ function mergeOutlets() {
 app.use(express.json());
 app.use('/static', express.static(path.join(__dirname, 'public')));
 
+// ---- Drudge-style front page (server-rendered so crawlers/AdSense see content) ----
+function renderFront() {
+  const wire = getWire();
+  const cfg = meta();
+  const fmt = (iso) => {
+    try { return new Date(iso).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York' }) + ' ET'; }
+    catch (_) { return ''; }
+  };
+  const today = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/New_York' });
+
+  const storyLink = (it) =>
+    `<a class="story" href="${esc(it.link)}" target="_blank" rel="noopener nofollow">${esc(it.topic)}<span class="src"> — ${esc(it.outlet)}</span></a>`;
+
+  const columnsHtml = (wire.columns || []).map((c) => `
+    <section class="col">
+      <h2 class="colhead">${esc(c.title)}</h2>
+      ${(c.items || []).map(storyLink).join('\n')}
+    </section>`).join('\n');
+
+  const splashHtml = wire.splash ? `
+    <a class="splash" href="${esc(wire.splash.link)}" target="_blank" rel="noopener nofollow">
+      ${esc(wire.splash.topic)}<span class="src"> — ${esc(wire.splash.outlet)}</span>
+    </a>` : `<div class="splash placeholder">Assembling today's wire…</div>`;
+
+  const columnistsHtml = (cfg.columnists || []).map((p) =>
+    `<a class="railitem" href="${esc(p.url)}" target="_blank" rel="noopener nofollow">${esc(p.name)}<span class="src"> · ${esc(p.outlet)}</span></a>`).join('\n');
+  const magsHtml = (cfg.magazines || []).map((m) =>
+    `<a class="railitem" href="${esc(m.url)}" target="_blank" rel="noopener nofollow">${esc(m.name)}</a>`).join('\n');
+
+  const updated = wire.updatedAt
+    ? `Updated ${fmt(wire.updatedAt)} · ${wire.sourcesOk}/${wire.sources} sources`
+    : 'Loading wire…';
+
+  return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>ALL NEWS DAILY — the world's newsrooms, one page</title>
+<meta name="description" content="All News Daily — a continuously-updated front page of the world's news, rewritten into plain one-line topics that link straight to the original reporting. Plus columnists, magazines, and a global outlet directory.">
+<link rel="canonical" href="https://allnewsdaily.com/">
+<meta property="og:type" content="website"><meta property="og:site_name" content="All News Daily">
+<meta property="og:title" content="ALL NEWS DAILY">
+<meta property="og:description" content="The world's newsrooms on one page — rewritten one-line topics linking to the source.">
+<link rel="icon" href="/static/favicon.svg">
+<style>${FRONT_CSS}</style>${ADSENSE}</head>
+<body>
+<header class="masthead">
+  <div class="tagline-l">EST. 2026 · GLOBAL WIRE</div>
+  <h1 class="wordmark"><a href="/">ALL NEWS DAILY</a></h1>
+  <div class="tagline-r">${esc(today)}</div>
+</header>
+<div class="updated">${esc(updated)} · <a href="/directory">outlet directory</a> · <a href="/guides">guides</a></div>
+<hr class="rule">
+
+<main>
+  ${splashHtml}
+  <hr class="rule thin">
+  <div class="grid">
+    ${columnsHtml}
+    <aside class="col rail">
+      <h2 class="colhead">COLUMNISTS</h2>
+      ${columnistsHtml}
+      <h2 class="colhead" style="margin-top:22px">MAGAZINES</h2>
+      ${magsHtml}
+      <h2 class="colhead" style="margin-top:22px">DIRECTORY</h2>
+      <a class="railitem" href="/directory">All 98 outlets — live TV &amp; broadcast →</a>
+    </aside>
+  </div>
+</main>
+
+<footer class="foot">
+  <nav><a href="/directory">Directory</a> · <a href="/guides">Guides</a> · <a href="/about">About</a> · <a href="/contact">Contact</a> · <a href="/privacy">Privacy</a></nav>
+  <p>Topic lines are original one-sentence summaries written by All News Daily; every link goes to the source outlet's own reporting. All News Daily does not republish articles. &copy; ${new Date().getFullYear()} All News Daily.</p>
+</footer>
+</body></html>`;
+}
+
 app.get('/', (req, res) => {
+  res.type('html').send(renderFront());
+});
+
+// Original outlet directory (live TV/broadcast grid) preserved here.
+app.get('/directory', (req, res) => {
   res.sendFile(path.join(__dirname, 'public', 'index.html'));
 });
 
@@ -55,7 +136,7 @@ app.get('/robots.txt', (_q, res) => res.type('text/plain').send('User-agent: *\n
 
 app.get('/sitemap.xml', (_q, res) => {
   const B = 'https://allnewsdaily.com';
-  const urls = ['/', '/guides', '/about', '/contact', '/privacy']
+  const urls = ['/', '/directory', '/guides', '/about', '/contact', '/privacy']
     .concat(GUIDES.map((g) => `/guides/${g.slug}`))
     .map((u) => `  <url><loc>${B}${u}</loc></url>`).join('\n');
   res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`);
@@ -65,6 +146,39 @@ app.get('/sitemap.xml', (_q, res) => {
 // Turns the site from a bare outbound-link directory into a real publisher.
 const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
 const ADSENSE = '<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5278231299883833" crossorigin="anonymous"></script>';
+const FRONT_CSS = `
+:root{--ink:#111;--link:#0000cc;--vis:#551a8b;--red:#c00;--rule:#000;--bg:#f8f7f2;--src:#6a6a6a}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.32 Georgia,'Times New Roman',Times,serif}
+a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}a:visited{color:var(--vis)}
+.masthead{display:grid;grid-template-columns:1fr auto 1fr;align-items:end;gap:12px;padding:14px 16px 6px;text-align:center}
+.wordmark{margin:0;font-size:clamp(30px,6vw,58px);letter-spacing:1px;font-weight:700;text-transform:uppercase;font-family:'Times New Roman',Times,serif}
+.wordmark a{color:var(--ink)}.wordmark a:hover{text-decoration:none}
+.tagline-l{text-align:left}.tagline-r{text-align:right}
+.tagline-l,.tagline-r{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#333;padding-bottom:8px}
+.updated{text-align:center;font-size:12px;color:#444;padding:2px 16px 8px;text-transform:uppercase;letter-spacing:.4px}
+.rule{border:0;border-top:3px double var(--rule);margin:6px 16px}
+.rule.thin{border-top:1px solid #000;margin:10px 16px}
+main{max-width:1180px;margin:0 auto;padding:0 12px 60px}
+.splash{display:block;text-align:center;font-weight:700;text-transform:uppercase;color:var(--red);
+  font-size:clamp(22px,3.4vw,34px);line-height:1.2;padding:16px 12px 8px;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}
+.grid{display:grid;grid-template-columns:repeat(4,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}
+.colhead{font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#000;border-bottom:2px solid #000;
+  margin:6px 0 8px;padding-bottom:3px;font-family:Arial,Helvetica,sans-serif}
+.story{display:block;padding:5px 0;border-bottom:1px dotted #cfcfcf;font-size:15px;line-height:1.28}
+.story .src{color:var(--src);font-style:italic;font-size:12px}
+.rail{background:#f0efe8}
+.railitem{display:block;padding:4px 0;border-bottom:1px dotted #d5d5cf;font-size:14px}
+.railitem .src{color:var(--src);font-style:italic;font-size:12px}
+.foot{max-width:1180px;margin:0 auto;padding:20px 16px 40px;border-top:3px double #000;font-size:12px;color:#444;text-align:center}
+.foot nav{margin-bottom:8px}.foot nav a{color:var(--link)}
+@media(max-width:960px){.grid{grid-template-columns:repeat(2,1fr)}.col:nth-child(3){border-left:0;padding-left:0}}
+@media(max-width:560px){.grid{grid-template-columns:1fr}.col{border-left:0;padding-left:0}.masthead{grid-template-columns:1fr}.tagline-l,.tagline-r{text-align:center}}
+`;
 const GUIDE_CSS = `body{margin:0;background:#0d1117;color:#e6edf3;font:17px/1.7 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
 a{color:#58a6ff}.wrap{max-width:760px;margin:0 auto;padding:28px 22px 80px}
 .top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #21262d;padding-bottom:14px;margin-bottom:26px}
@@ -160,6 +274,11 @@ app.get('/api/outlets', (req, res) => {
   res.json({ outlets: mergeOutlets(), updatedAt: new Date().toISOString() });
 });
 
+app.get('/api/wire', (req, res) => {
+  const w = getWire();
+  res.json({ updatedAt: w.updatedAt, sources: w.sources, sourcesOk: w.sourcesOk, splash: w.splash, columns: w.columns });
+});
+
 app.get('/api/health', (req, res) => {
   const outlets = loadOutlets();
   const status = loadLiveStatus();
@@ -194,9 +313,15 @@ function runChecker() {
 
 const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS || '90000', 10);
 
+const WIRE_REFRESH_MS = parseInt(process.env.WIRE_REFRESH_MS || '600000', 10); // 10 min
+
 app.listen(PORT, () => {
   console.log(`allnewsdaily listening on http://0.0.0.0:${PORT}`);
   console.log(`outlets loaded: ${loadOutlets().length}`);
   runChecker();
   setInterval(runChecker, POLL_INTERVAL_MS);
+  // Build the wire (fetch feeds + rewrite headlines) now, then refresh on an interval.
+  rebuild().then((w) => console.log(`[wire] first build: ${w.sourcesOk}/${w.sources} sources, ${(w.columns || []).reduce((n, c) => n + c.items.length, 0)} stories`))
+           .catch((e) => console.error('[wire] first build error', e.message));
+  setInterval(() => { rebuild().catch(() => {}); }, WIRE_REFRESH_MS);
 });

← ffb6efc auto-data-snapshot: 2026-09-09T11:09:48 (2 data files) — .gi  ·  back to Allnewsdaily  ·  Add prod static-wire mode: WIRE_STATIC=1 serves pre-built da 9829705 →