Python RSS Reader
2 min readApr 28, 2026
#!/usr/bin/env python3
import sys
import urllib.request
import xml.etree.ElementTree as ET
from html import escape
from datetime import datetime
from pathlib import Path
def fetch_rss(url: str) -> bytes:
req = urllib.request.Request(
url,
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/123.0.0.0 Safari/537.36"
)
}
)
with urllib.request.urlopen(req) as resp:
return resp.read()
def parse_rss(data: bytes):
root = ET.fromstring(data)
# Try to handle both RSS 2.0 and Atom
channel = root.find("channel")
if channel is not None: # RSS
title = (channel.findtext("title") or "").strip()
items = []
for item in channel.findall("item"):
items.append(
{
"title": (item.findtext("title") or "").strip(),
"link": (item.findtext("link") or "").strip(),
"description": (item.findtext("description") or "").strip(),
"pubDate": (item.findtext("pubDate") or "").strip(),
}
)
return title, items
# Atom fallback
title = (root.findtext("{http://www.w3.org/2005/Atom}title") or "").strip()
items = []
for entry in root.findall("{http://www.w3.org/2005/Atom}entry"):
link_el = entry.find("{http://www.w3.org/2005/Atom}link")
link = link_el.get("href") if link_el is not None else ""
items.append(
{
"title": (entry.findtext("{http://www.w3.org/2005/Atom}title") or "").strip(),
"link": link.strip(),
"description": (entry.findtext("{http://www.w3.org/2005/Atom}summary") or "").strip(),
"pubDate": (entry.findtext("{http://www.w3.org/2005/Atom}updated") or "").strip(),
}
)
return title, items
def build_html(feed_title: str, items: list[dict]) -> str:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
rows = []
for item in items:
title = escape(item["title"]) or "(no title)"
link = escape(item["link"])
desc = escape(item["description"])
pub = escape(item["pubDate"])
row = f"""
<article class="item">
<h2 class="item-title">
<a href="{link}" target="_blank" rel="noopener noreferrer">{title}</a>
</h2>
<p class="item-meta">{pub}</p>
<p class="item-desc">{desc}</p>
</article>
"""
rows.append(row)
items_html = "\n".join(rows)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{escape(feed_title or "RSS Feed")}</title>
<style>
body {{
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 2rem auto;
max-width: 800px;
line-height: 1.5;
color: #222;
background: #f7f7f7;
}}
h1 {{
margin-bottom: 0.25rem;
}}
.feed-meta {{
color: #666;
font-size: 0.9rem;
margin-bottom: 1.5rem;
}}
.item {{
background: #fff;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}}
.item-title {{
margin: 0 0 0.25rem 0;
font-size: 1.1rem;
}}
.item-title a {{
color: #0056b3;
text-decoration: none;
}}
.item-title a:hover {{
text-decoration: underline;
}}
.item-meta {{
margin: 0 0 0.5rem 0;
color: #777;
font-size: 0.85rem;
}}
.item-desc {{
margin: 0;
}}
</style>
</head>
<body>
<header>
<h1>{escape(feed_title or "RSS Feed")}</h1>
<p class="feed-meta">Generated at {escape(now)}</p>
</header>
<main>
{items_html}
</main>
</body>
</html>
"""
def main():
if len(sys.argv) < 2:
print(f"Usage: {Path(sys.argv[0]).name} <rss_or_atom_url> [output.html]", file=sys.stderr)
sys.exit(1)
url = sys.argv[1]
output_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("feed.html")
try:
data = fetch_rss(url)
title, items = parse_rss(data)
html = build_html(title, items)
output_path.write_text(html, encoding="utf-8")
print(f"Wrote {len(items)} items to {output_path}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(2)
if __name__ == "__main__":
main()Get Sonny Smith’s stories in your inbox
Join Medium for free to get updates from this writer.
Usage:
python rss_reader.py "https://example.com/feed.xml"
# or with explicit output file
python rss_reader.py "https://example.com/feed.xml" myfeed.html



