Feed Reader

rss.js 101 lines

Files · 10
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101// RSS 2.0 and Atom parsing into RSS item rows. No direct fetch, external
// entity expansion, or HTML execution.
export function parseFeed(xml) {
  if (/<!DOCTYPE|<!ENTITY/i.test(xml))
    throw new Error(
      "Feeds with document types or external entities are not supported.",
    );
  const doc = new DOMParser().parseFromString(xml, "application/xml");
  if (doc.querySelector("parsererror"))
    throw new Error("The feed is not valid XML.");
  const root = doc.documentElement;
  if (root.localName === "rss") return parseRSS(root);
  if (root.localName === "feed") return parseAtom(root);
  throw new Error("Choose an RSS 2.0 or Atom feed.");
}
const children = (parent, name) =>
  [...parent.children].filter((node) => node.localName === name);
const child = (parent, name) => children(parent, name)[0];
const text = (parent, name) => child(parent, name)?.textContent ?? null;
function byteCount(value) {
  if (value === null || value === undefined) return null;
  if (!/^\d+$/.test(value))
    throw new Error("An enclosure length must be a nonnegative byte count.");
  return Number(value);
}
function parseRSS(root) {
  const channel = child(root, "channel");
  if (!channel) throw new Error("The RSS document has no channel.");
  const items = children(channel, "item").map((node) => {
    const guid = child(node, "guid"),
      source = child(node, "source"),
      enclosure = child(node, "enclosure");
    return {
      guid: guid?.textContent ?? null,
      guid_is_perma_link:
        guid?.getAttribute("isPermaLink")?.toLowerCase() === "false" ? 0 : 1,
      title: text(node, "title"),
      link: text(node, "link"),
      description: text(node, "description"),
      author: text(node, "author"),
      category: text(node, "category"),
      comments: text(node, "comments"),
      pub_date: text(node, "pubDate"),
      source: source?.textContent ?? null,
      source_url: source?.getAttribute("url") ?? null,
      enclosure_url: enclosure?.getAttribute("url") ?? null,
      enclosure_length: byteCount(enclosure?.getAttribute("length")),
      enclosure_type: enclosure?.getAttribute("type") ?? null,
    };
  });
  return { title: text(channel, "title"), items: usable(items) };
}
// Atom entries map onto the RSS columns: id is a non-permalink GUID, dates
// become RFC 822, and rel="enclosure"/"replies" links become the enclosure
// and comments.
function parseAtom(root) {
  const link = (node, rel) =>
    children(node, "link").find(
      (l) => (l.getAttribute("rel") || "alternate") === rel,
    );
  const rfc822 = (value) => {
    const time = Date.parse(value);
    return Number.isNaN(time) ? value : new Date(time).toUTCString();
  };
  const items = children(root, "entry").map((node) => {
    const enclosure = link(node, "enclosure"),
      date = text(node, "published") ?? text(node, "updated"),
      author = child(node, "author");
    return {
      guid: text(node, "id"),
      guid_is_perma_link: 0,
      title: text(node, "title"),
      link: link(node, "alternate")?.getAttribute("href") ?? null,
      description: text(node, "summary") ?? text(node, "content"),
      author: author ? text(author, "name") : null,
      category: child(node, "category")?.getAttribute("term") ?? null,
      comments: link(node, "replies")?.getAttribute("href") ?? null,
      pub_date: date && rfc822(date),
      source: null,
      source_url: null,
      enclosure_url: enclosure?.getAttribute("href") ?? null,
      enclosure_length: byteCount(enclosure?.getAttribute("length")),
      enclosure_type: enclosure?.getAttribute("type") ?? null,
    };
  });
  return { title: text(root, "title"), items: usable(items) };
}
// An item needs a title or a description to be stored.
function usable(items) {
  const kept = items.filter((item) => item.title || item.description);
  if (!kept.length) throw new Error("The feed contains no items.");
  return kept;
}
export function plainText(value) {
  if (!value) return "";
  const doc = new DOMParser().parseFromString(value, "text/html");
  doc
    .querySelectorAll("script, style, iframe, object")
    .forEach((node) => node.remove());
  return doc.body.textContent || "";
}