← back to Crazy News Channel Shadowman
scripts/fetch-real-news.mjs
243 lines
#!/usr/bin/env node
// fetch-real-news.mjs — pulls REAL current headlines from Google News RSS
// (no API key, no cost) and writes them into real-news-data.js as
// `window.P24_REAL_STORIES = [...]`, alongside (not replacing) the invented satire in
// previously lived there. index.html is a static file with no server, so
// it can't fetch news.google.com itself (CORS) — this script is the
// offline refresh step Steve (or a cron) runs locally; the page just loads
// whatever stories-data.js was last written with.
//
// Run: node scripts/fetch-real-news.mjs
//
// Each RSS item becomes a SINGLE-STAGE story (stageIndex 0, one entry in
// `stages`) on purpose — real news doesn't escalate into Onion-style
// absurdity, so giving it only one stage means tick()'s stage-advance loop
// treats it as already at its final stage and never touches it again.
//
// HARD RULE (TK-12165, 2026-09-24): every entry this writes MUST carry a
// non-empty `tags` array — see toStory()'s call into
// scripts/lib/auto-tag-real-news.mjs, the one shared place both this
// fetcher and the one-time backfill read the tag vocabulary from.
import { writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { autoTagRealNews } from "./lib/auto-tag-real-news.mjs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = path.join(__dirname, "..", "real-news-data.js");
const ITEMS_PER_CATEGORY = 6;
const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
// category key (matches index.html's CATEGORY_LABELS / MOODS) -> RSS source.
// Google News has no dedicated topic feed for politics/weather, so those
// use search RSS instead of a /section/topic/<TOPIC> feed.
const CATEGORY_LABELS = {
politics: "Politics",
weather: "Weather",
scitech: "Sci-Tech",
sports: "Sports",
entertainment: "Entertainment",
business: "Business",
uncategorized: "Uncategorized",
};
const FEEDS = {
politics: { kind: "search", q: "politics" },
weather: { kind: "search", q: "weather" },
scitech: { kind: "topic", topic: "TECHNOLOGY" },
sports: { kind: "topic", topic: "SPORTS" },
entertainment: { kind: "topic", topic: "ENTERTAINMENT" },
business: { kind: "topic", topic: "BUSINESS" },
uncategorized: { kind: "topic", topic: "WORLD" },
};
function feedURL(spec) {
const base = "https://news.google.com/rss";
const tail = "hl=en-US&gl=US&ceid=US:en";
return spec.kind === "topic"
? `${base}/headlines/section/topic/${spec.topic}?${tail}`
: `${base}/search?q=${encodeURIComponent(spec.q)}&${tail}`;
}
// --- tiny dependency-free XML/HTML helpers -------------------------------
const ENTITIES = {
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", "#39": "'",
};
function decodeEntities(str) {
return String(str || "").replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, ent) => {
if (ent[0] === "#") {
const code = ent[1] === "x" || ent[1] === "X"
? parseInt(ent.slice(2), 16)
: parseInt(ent.slice(1), 10);
return Number.isFinite(code) ? String.fromCodePoint(code) : m;
}
return ENTITIES[ent] !== undefined ? ENTITIES[ent] : m;
});
}
// Google's <description> is XML-entity-encoded HTML (e.g. the literal text
// "<a href=...>Title</a>&nbsp;&nbsp;<font>Source
// </font>"), so entities must be decoded BEFORE tags are stripped —
// stripping first finds no literal "<...>" to match (they're still escaped
// as "<"/">") and leaves raw HTML sitting in the "stripped" text.
// is itself double-escaped ("&nbsp;"), so a second decode pass
// after the tags come out catches what the first pass turned into " ".
function stripTags(raw) {
const onceDecoded = decodeEntities(String(raw || ""));
// Google's topic-feed <description> is a "further coverage" <ol><li>
// list of several related headline+source pairs — insert a bullet at
// each <li> instead of collapsing the whole list into one run-on line.
const withBullets = onceDecoded.replace(/<li[^>]*>/gi, " • ");
const noTags = withBullets.replace(/<[^>]*>/g, " ");
return decodeEntities(noTags).replace(/\s+/g, " ").replace(/^\s*•\s*/, "").trim();
}
function tag(block, name) {
const m = block.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "i"));
return m ? m[1] : "";
}
function cdataOrText(raw) {
const m = raw.match(/^\s*<!\[CDATA\[([\s\S]*?)\]\]>\s*$/);
return decodeEntities(m ? m[1] : raw).trim();
}
function parseItems(xml) {
const items = [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m) => m[1]);
return items.map((block) => {
const rawTitle = cdataOrText(tag(block, "title"));
const link = cdataOrText(tag(block, "link"));
const pubDate = cdataOrText(tag(block, "pubDate"));
const sourceMatch = block.match(/<source[^>]*>([\s\S]*?)<\/source>/i);
const sourceName = sourceMatch ? decodeEntities(sourceMatch[1]).trim() : "";
const description = stripTags(tag(block, "description"));
return { rawTitle, link, pubDate, sourceName, description };
});
}
// Google appends " - <Source Name>" to every item title. Strip it using the
// <source> tag (ground truth) so a headline that legitimately contains
// " - " isn't mangled; falls back to trimming the last " - X" segment only
// when no <source> tag was present.
function cleanTitle(rawTitle, sourceName) {
if (sourceName && rawTitle.endsWith(` - ${sourceName}`)) {
return rawTitle.slice(0, -(sourceName.length + 3)).trim();
}
const idx = rawTitle.lastIndexOf(" - ");
return idx > 0 ? rawTitle.slice(0, idx).trim() : rawTitle.trim();
}
function formatPublished(pubDate) {
const d = new Date(pubDate);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleString("en-US", {
month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit",
});
}
async function fetchFeed(categoryKey, spec) {
const url = feedURL(spec);
const res = await fetch(url, { headers: { "User-Agent": UA, Accept: "application/rss+xml, application/xml, text/xml" } });
if (!res.ok) throw new Error(`${categoryKey}: HTTP ${res.status} fetching ${url}`);
const xml = await res.text();
return parseItems(xml);
}
function toStory(categoryKey, n, raw) {
const headline = cleanTitle(raw.rawTitle, raw.sourceName);
const sourceName = raw.sourceName || "wire services";
// Google News RSS descriptions are not real article summaries — every
// item's <description> is just its own headline re-wrapped in an <a>
// plus the source name (verified live 2026-09-24 across both topic and
// search feeds), so "<headline> <sourceName>" carries zero information
// beyond what's already in the headline/byline. Strip that same trailing
// "<sourceName>" off the stripped description before comparing, so a
// dek is only kept when there's real extra text left over.
const strippedDesc = raw.description;
const descWithoutSource = sourceName && strippedDesc.endsWith(sourceName)
? strippedDesc.slice(0, -sourceName.length).trim()
: strippedDesc;
const dek = descWithoutSource && descWithoutSource !== headline ? descWithoutSource : "";
const published = formatPublished(raw.pubDate);
const detail = dek || `Via ${sourceName}${published ? `, ${published}` : ""}.`;
const paragraphs = [
detail,
`Read the full story at ${sourceName}.`,
];
const story = {
id: `real-${categoryKey}-${n}`,
category: categoryKey,
categoryLabel: CATEGORY_LABELS[categoryKey],
location: "",
byline: sourceName,
image: null,
sourceUrl: raw.link,
sourceName,
publishedLabel: published,
intervalSec: 999999,
offsetSec: 0,
stageIndex: 0,
article: {
dek,
photoCaption: "",
paragraphs,
},
// A single stage: stageIndex (0) === stages.length - 1 (0) is true from
// the moment this loads, so tick()'s `alreadyMax` check holds it there
// forever — real news never escalates into a satirical spiral.
stages: [
{ headline, detail },
],
};
// HARD RULE (TK-12165): every real-news entry MUST carry a non-empty
// tags array. autoTagRealNews() (scripts/lib/auto-tag-real-news.mjs) is
// deterministic and always returns 3-6 tags (category + outlet + keyword
// matches, padded with a category fallback if nothing else matched), so
// this can never write an untagged entry.
story.tags = autoTagRealNews(story);
return story;
}
async function main() {
const categories = Object.keys(FEEDS);
const all = [];
const summary = [];
for (const categoryKey of categories) {
const spec = FEEDS[categoryKey];
let items;
try {
items = await fetchFeed(categoryKey, spec);
} catch (err) {
console.error(`✗ ${categoryKey}: ${err.message}`);
summary.push({ categoryKey, count: 0, error: err.message });
continue;
}
const picked = items.slice(0, ITEMS_PER_CATEGORY);
picked.forEach((raw, i) => all.push(toStory(categoryKey, i + 1, raw)));
console.log(`✓ ${categoryKey}: ${picked.length} stories from ${feedURL(spec)}`);
summary.push({ categoryKey, count: picked.length });
}
if (!all.length) {
console.error("No stories fetched from any category — aborting write so stories-data.js is left untouched.");
process.exit(1);
}
const header =
"// AUTO-GENERATED by scripts/fetch-real-news.mjs — real headlines pulled from\n" +
"// Google News RSS (no API key, $0). Do not hand-edit; re-run the script to\n" +
"// refresh. Each story is single-stage (real news doesn't escalate) and links\n" +
`// out to its real source article. Generated ${new Date().toISOString()}.\n`;
const body = `window.P24_REAL_STORIES = ${JSON.stringify(all, null, 1)};\n`;
await writeFile(OUT_FILE, header + body, "utf8");
console.log(`\nWrote ${all.length} real stories to ${OUT_FILE}`);
console.log("Per category:", summary.map((s) => `${s.categoryKey}=${s.count}`).join(", "));
}
main().catch((err) => {
console.error("fetch-real-news.mjs failed:", err);
process.exit(1);
});