How to turn any RSS or Atom feed into JSON and CSV with Python
RSS and Atom still power podcasts, changelogs, and "what's new" pages. The formats differ, dates are inconsistent, and descriptions are often HTML. Here's a small Python script (stdlib + requests) that discovers a feed, normalizes items, and writes a CSV.
1. Discover the feed from a website
Many pages advertise their feed in <head>:
import re, requests from urllib.parse import urljoin, urlparse UA = {"User-Agent": "rss-tutorial/1.0"} def discover_feeds(page_url): html = requests.get(page_url, headers=UA, timeout=20).text found = [] for m in re.finditer(r'''<link[^>]+rel=["']alternate["'][^>]*>''', html, re.I): tag = m.group(0) if not re.search(r'type=["\']application/(rss|atom)\+xml["\']', tag, re.I): continue href = re.search(r'href=["\']([^"\']+)["\']', tag, re.I) if href: found.append(urljoin(page_url, href.group(1))) origin = f"{urlparse(page_url).scheme}://{urlparse(page_url).netloc}" for path in ("/feed", "/rss", "/atom.xml", "/feed.xml", "/rss.xml"): found.append(origin + path) return list(dict.fromkeys(found)) On https://www.nasa.gov/ this finds https://www.nasa.gov/feed/ via <link rel="alternate">. If you already have a feed URL (e.g. https://hnrss.org/frontpage), skip discovery and fetch it directly.
2. Parse RSS 2.0 / Atom into one shape
import gzip, xml.etree.ElementTree as ET from datetime import datetime, timezone from email.utils import parsedate_to_datetime from html import unescape def _local(tag): return tag.split("}", 1)[-1].lower() if tag else "" def _text(el): if el is None: return "" parts = [el.text or ""] + [ET.tostring(c, encoding="unicode") + (c.tail or "") for c in el] return unescape("".join(parts)).strip() def _strip_html(s): return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s or "")).strip() def parse_date(s): if not s: return "" try: return parsedate_to_datetime(s).astimezone(timezone.utc).isoformat() except Exception: pass try: return datetime.fromisoformat(s.strip().replace("Z", "+00:00")).astimezone(timezone.utc).isoformat() except Exception: return s.strip() def fetch_xml(url): body = requests.get(url, headers=UA, timeout=30, allow_redirects=True).content if url.endswith(".gz") or body[:2] == b"\x1f\x8b": body = gzip.decompress(body) return body def parse_feed(body): root = ET.fromstring(body) items = [] if _local(root.tag) in ("rss", "rdf"): channel = root.find("channel") if _local(root.tag) == "rss" else root entries = (channel.findall("item") if channel is not None else []) or root.findall(".//item") for it in entries: get = lambda *names: _text(next((c for c in it if _local(c.tag) in names), None)) items.append({"title": get("title"), "link": get("link"), "published": parse_date(get("pubdate", "date")), "author": get("author", "creator"), "summary": _strip_html(get("description", "summary", "encoded"))[:300]}) elif _local(root.tag) == "feed": # Atom for it in [c for c in root if _local(c.tag) == "entry"]: title = _text(next((c for c in it if _local(c.tag) == "title"), None)) link = next((c.attrib.get("href", "") for c in it if _local(c.tag) == "link" and c.attrib.get("rel", "alternate") in ("alternate", "")), "") pub = _text(next((c for c in it if _local(c.tag) in ("published", "updated")), None)) author_el = next((c for c in it if _local(c.tag) == "author"), None) author = _text(next((c for c in author_el if _local(c.tag) == "name"), None)) if author_el is not None else "" summary = _text(next((c for c in it if _local(c.tag) in ("summary", "content")), None)) items.append({"title": title, "link": link, "published": parse_date(pub), "author": author, "summary": _strip_html(summary)[:300]}) else: raise ValueError(f"Unknown feed root: {root.tag}") return items https://hnrss.org/frontpage (RSS 2.0) yields 20 normalized rows; https://github.blog/feed/atom/ (Atom) uses the same fields from <entry>, <link href>, and <published>.
3. Write CSV (or JSON)
import csv rows = parse_feed(fetch_xml("https://hnrss.org/frontpage")) with open("feed.csv", "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=["title", "link", "published", "author", "summary"]) w.writeheader() w.writerows(rows) For JSON: json.dump(rows, open("feed.json", "w"), ensure_ascii=False, indent=2).
Gotchas you'll hit
- HTML in descriptions — RSS
description/ Atomcontentoften contain markup; strip or keep it on purpose. - Missing dates — RSS uses RFC 822 (
parsedate_to_datetime); Atom uses ISO-8601; some items have neither. - Atom vs RSS links — Atom:
<link href="...">; RSS: element text. - Gzip and redirects — decompress on magic bytes; follow redirects.
- XML namespaces — strip
{http://...}with_local()sodc:creatorand plaintitleboth work. - Guessed paths that 404 — prefer
<link rel="alternate">over/feedfallbacks.
If you don't want to maintain it
Disclosure: I built a hosted version of this, RSS Feed Scraper on Apify, which also handles JSON Feed, keyword/date filters, cross-feed dedupe, and monitoring. Pricing is about $0.30 per 1,000 feed items; failed feeds, filtered items, duplicates, and already-seen items are free. The script above is enough for a handful of feeds on a cron job.