← 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(/&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 absUrl(u) {
  if (!u) return '';
  u = String(u).trim().replace(/&amp;/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(/&lt;/g, '<').replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"').replace(/&#0*34;/g, '"').replace(/&#0*39;/g, "'").replace(/&apos;/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 };