← back to Allnewsdaily
scripts/short/pick-stories.js
160 lines
#!/usr/bin/env node
/**
* STAGE 1 — pick-stories.js (TK-11342)
* Read data/wire.json -> select 6 stories (splash first, then strongest column
* items across the 3 columns), dedupe by outlet (max 2/outlet), skip topics
* that are empty or < 25 chars. Write data/short/stories.json per CONTRACTS.md
* and print the 6 chosen headlines.
*
* NOTE (verified 2026-09-09): wire.json items have NO `title` field —
* `item.topic` IS the headline sentence.
*
* Cody/DTD red-team hardening (2026-09-09):
* - FRESHNESS GUARD (#1): refuse to run if wire.updatedAt is > MAX_STALE_MIN old,
* so a dead feed-refresh loop can never render stale news as "today's briefing"
* (the silent-wrong-success failure). Exit code 2. Override with --allow-stale.
* - CROSS-DAY DEDUPE (#1): exclude links aired in the last HISTORY_DAYS days
* (data/short/history.json, written by the orchestrator after a successful
* upload). Relaxes to refill if a slow-news day leaves < TARGET eligible.
* - DEFENSIVE ENTITY DECODE (#10b): decode HTML entities in headlines so a feed
* regression can't put "Apple &amp; Google" on a public card.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..', '..');
const WIRE = path.join(ROOT, 'data', 'wire.json');
const OUT_DIR = path.join(ROOT, 'data', 'short');
const OUT = path.join(OUT_DIR, 'stories.json');
const HISTORY = path.join(OUT_DIR, 'history.json');
const MIN_TOPIC_LEN = 25;
const MAX_PER_OUTLET = 2;
const TARGET = 6;
const MAX_STALE_MIN = Number(process.env.AND_MAX_STALE_MIN || 30); // reject feeds older than this
const HISTORY_DAYS = Number(process.env.AND_HISTORY_DAYS || 2); // don't re-air links aired in last N days
const ALLOW_STALE = process.argv.includes('--allow-stale');
const ENT = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" };
function decodeEntities(s) {
return s
.replace(/&(amp|lt|gt|quot|#39|apos);/g, (m) => ENT[m])
.replace(/&#(\d+);/g, (_, d) => { try { return String.fromCodePoint(+d); } catch { return _; } })
.replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return _; } });
}
function clean(s) {
return decodeEntities(s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
}
function dateStamp(iso) {
const d = iso ? new Date(iso) : new Date();
const use = isNaN(d.getTime()) ? new Date() : d;
return use.toISOString().slice(0, 10); // YYYY-MM-DD
}
// Links aired in the last HISTORY_DAYS days (the orchestrator appends after a real upload).
function loadRecentLinks() {
try {
const h = JSON.parse(fs.readFileSync(HISTORY, 'utf8'));
const cutoff = new Date(Date.now() - HISTORY_DAYS * 86400000).toISOString().slice(0, 10);
const set = new Set();
(Array.isArray(h) ? h : []).forEach((d) => {
if (d && d.date >= cutoff) (d.links || []).forEach((l) => set.add(l));
});
return set;
} catch { return new Set(); }
}
function main() {
if (!fs.existsSync(WIRE)) {
console.error(`[pick-stories] FATAL: wire.json not found at ${WIRE}`);
process.exit(1);
}
const wire = JSON.parse(fs.readFileSync(WIRE, 'utf8'));
// --- FRESHNESS GUARD (#1) ---------------------------------------------------
const upd = Date.parse(wire.updatedAt);
if (!ALLOW_STALE) {
if (!Number.isFinite(upd)) {
console.error(`[pick-stories] FATAL: wire.json has no parseable updatedAt — cannot confirm freshness. Refusing to publish. (override: --allow-stale)`);
process.exit(2);
}
const ageMin = (Date.now() - upd) / 60000;
if (ageMin > MAX_STALE_MIN) {
console.error(`[pick-stories] FATAL: wire.json is ${ageMin.toFixed(0)}min stale (> ${MAX_STALE_MIN}min) — the feed-refresh loop is likely dead. Refusing to render stale news as today's briefing. (override: --allow-stale)`);
process.exit(2);
}
}
const recentLinks = loadRecentLinks();
const splash = wire.splash || null;
const columns = Array.isArray(wire.columns) ? wire.columns : [];
let allowRepeats = false; // flipped to refill if strict pass can't reach TARGET
const picked = [];
const outletCount = Object.create(null);
const seenLinks = new Set();
const outletKey = (o) => clean(o).toLowerCase();
function eligible(item) {
if (!item) return false;
const topic = clean(item.topic);
if (topic.length < MIN_TOPIC_LEN) return false; // skip short/empty
const link = clean(item.link);
if (link && seenLinks.has(link)) return false; // dedupe within this run
if (!allowRepeats && link && recentLinks.has(link)) return false; // cross-day dedupe (#1)
const ok = outletKey(item.outlet);
if (ok && (outletCount[ok] || 0) >= MAX_PER_OUTLET) return false; // <=2/outlet
return true;
}
function take(item, tag) {
const ok = outletKey(item.outlet);
if (ok) outletCount[ok] = (outletCount[ok] || 0) + 1;
const link = clean(item.link);
if (link) seenLinks.add(link);
picked.push({ n: picked.length + 1, headline: clean(item.topic), outlet: clean(item.outlet), link, tag: clean(tag) || 'Top' });
}
function selectPass() {
if (splash && eligible(splash)) take(splash, 'Top');
const cursors = columns.map(() => 0);
let progressed = true;
while (picked.length < TARGET && progressed) {
progressed = false;
for (let c = 0; c < columns.length && picked.length < TARGET; c++) {
const items = Array.isArray(columns[c].items) ? columns[c].items : [];
while (cursors[c] < items.length) {
const item = items[cursors[c]++];
progressed = true;
if (eligible(item)) { take(item, columns[c].title); break; }
}
}
}
}
selectPass();
// Refill: if cross-day dedupe left us short on a slow-news day, allow repeats.
if (picked.length < TARGET && recentLinks.size) {
console.warn(`[pick-stories] WARN: only ${picked.length}/${TARGET} fresh (non-repeat) stories — relaxing cross-day dedupe to refill.`);
allowRepeats = true;
selectPass();
}
const outDate = dateStamp(wire.updatedAt);
const result = { date: outDate, generatedAt: new Date().toISOString(), stories: picked };
fs.mkdirSync(OUT_DIR, { recursive: true });
fs.writeFileSync(OUT, JSON.stringify(result, null, 2));
console.log(`[pick-stories] wrote ${picked.length} stories -> ${path.relative(ROOT, OUT)} (date ${outDate}, feed ${Number.isFinite(upd) ? ((Date.now() - upd) / 60000).toFixed(0) + 'min old' : 'age?'})`);
if (picked.length < TARGET) console.warn(`[pick-stories] WARN: only ${picked.length}/${TARGET} eligible stories found`);
picked.forEach((s) => console.log(` ${s.n}. ${s.headline} — via ${s.outlet} [${s.tag}]`));
return result;
}
if (require.main === module) {
try { main(); }
catch (e) { console.error('[pick-stories] FATAL:', (e && e.stack) || e); process.exit(1); }
}
module.exports = { main };