← back to Allnewsdaily
lib/aggregate.js
136 lines
'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
const PER_OUTLET = parseInt(process.env.PER_OUTLET || '3', 10); // max items per outlet per column (diversity)
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));
// Cap items per outlet so one source can't dominate a column (e.g. all-Apple on event day).
const perOutlet = {};
items = items.filter((it) => {
perOutlet[it.outlet] = (perOutlet[it.outlet] || 0) + 1;
return perOutlet[it.outlet] <= PER_OUTLET;
});
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, image: it.image || '' };
});
}
// "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.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,
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(); }
const WIRE_JSON = path.join(__dirname, '..', 'data', 'wire.json');
// Persist the current wire to data/wire.json (used by scripts/build-wire.js).
function writeWireFile() {
const w = getWire();
const out = { columns: w.columns, splash: w.splash, updatedAt: w.updatedAt, sources: w.sources, sourcesOk: w.sourcesOk };
fs.writeFileSync(WIRE_JSON, JSON.stringify(out));
return out;
}
// Load a pre-built wire snapshot into memory (prod static mode — no Ollama needed).
function loadStaticWire() {
try {
const w = JSON.parse(fs.readFileSync(WIRE_JSON, 'utf8'));
if (w && Array.isArray(w.columns)) {
WIRE = { ...w, building: false };
return true;
}
} catch (e) { /* file missing/invalid — leave WIRE as-is */ }
return false;
}
module.exports = { rebuild, getWire, meta, writeWireFile, loadStaticWire, WIRE_JSON };