← back to Allnewsdaily
lib/rss.js
121 lines
'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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /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 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>
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),
image: pickImage(b)
});
}
// 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),
image: pickImage(b)
});
}
}
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 };