RSS Feed Reader
Abstract
Section titled “Abstract”RSS (Really Simple Syndication) is the oldest still-working standard for publishing a list of articles in a machine-readable format. Most blogs, news sites, podcast hosts, and YouTube channels expose an RSS or Atom feed. In this project you will build a Python program that fetches a feed, parses it, and displays each article’s title, summary, publication date, author, and tags — then optionally opens any article in your default browser.
This project teaches:
- How HTTP and feed formats fit together.
- How to use the
feedparserlibrary to handle the messy real-world variations of RSS and Atom. - How to extract structured data and present it nicely.
- How to extend a simple reader into a personalized news aggregator.
What Is RSS?
Section titled “What Is RSS?”An RSS feed is a single XML document served from a URL. It contains:
- Feed-level metadata — title, description, link to the website, last-updated date.
- A list of entries — each one with its own title, link, summary or content, published date, author, and categories (tags).
When you “subscribe to” a feed, your reader app fetches that URL on a schedule and shows you new entries. There is no signup, no algorithm, no tracking — just a URL.
Feeds typically live at URLs like:
https://news.ycombinator.com/rsshttps://www.reddit.com/r/Python/.rsshttps://feeds.bbci.co.uk/news/rss.xmlhttps://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID
Prerequisites
Section titled “Prerequisites”- Python 3.6 or above.
- A text editor or IDE.
- An internet connection.
- Comfort installing packages with
pip.
Install Dependencies
Section titled “Install Dependencies”pip install feedparserfeedparser is the standard Python library for reading both RSS and Atom (a slightly different, newer format). It hides the differences between formats so you only deal with a uniform structure.
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create a folder named
rss-feed-reader. - Inside it, create
rssfeedreader.py. - Open the folder in your editor.
Write the code
Section titled “Write the code”RSS Feed Reader
pch.viewSource"""RSS feed reader -- fetch a feed, clean the summaries, print the headlines.
The original version had two problems worth naming:
* It called `webbrowser.open(...)` at import time, so merely running the
script opened a browser window at whatever link happened to be last. A
reader who ran it to see some headlines got a tab instead.
* It used `entry` after the loop had finished, which is the *last* entry
rather than any chosen one. That works, silently, and means something
different from what it looks like.
Both are fixed here. Opening a link is now an explicit choice, and every
entry is addressed by index.
python rssfeedreader.py # fetch and print
python rssfeedreader.py --open 3 # also open entry 3 in a browser
python rssfeedreader.py --test # HTML stripping, no network
"""
import html
import re
import sys
FEED_URL = "https://www.reddit.com/r/Python/.rss"
TAG = re.compile(r"<[^>]+>")
WHITESPACE = re.compile(r"\s+")
def strip_html(text: str) -> str:
"""Turn a feed summary into plain text.
Feed summaries are HTML fragments, so printing them raw gives a wall of
`<div class="md">`. This removes the tags, unescapes the entities and
collapses the whitespace -- in that order, because unescaping first would
turn `<b>` into a tag that the regex then eats.
A regex is the wrong tool for parsing HTML in general and the right one
here: the goal is to *discard* markup, not to understand it, so the
failure mode is a stray angle bracket rather than a wrong answer.
"""
without_tags = TAG.sub(" ", text or "")
unescaped = html.unescape(without_tags)
return WHITESPACE.sub(" ", unescaped).strip()
def fetch(url: str = FEED_URL):
"""Parse the feed, returning None if it cannot be reached."""
import feedparser
feed = feedparser.parse(url)
if feed.get("bozo") and not feed.get("entries"):
print(f"could not read the feed: {feed.get('bozo_exception')}")
return None
return feed
def show(feed, limit: int = 10) -> None:
title = feed["feed"].get("title", "(untitled feed)")
print(f"{title} -- {len(feed['entries'])} entries\n")
for index, entry in enumerate(feed["entries"][:limit]):
summary = strip_html(entry.get("summary", ""))
print(f"{index:>3}. {entry.get('title', '(no title)')}")
print(f" {entry.get('published', 'no date')} "
f"by {entry.get('author', 'unknown')}")
if summary:
print(f" {summary[:110]}{'...' if len(summary) > 110 else ''}")
print()
def open_entry(feed, index: int) -> None:
"""Open one entry in a browser -- only when explicitly asked.
The original did this unconditionally at the end of the script. Opening a
window is a side effect nobody asked for, and it makes the file unusable
in anything automated.
"""
import webbrowser
entries = feed["entries"]
if not 0 <= index < len(entries):
print(f"no entry {index}; the feed has {len(entries)}")
return
link = entries[index].get("link")
print(f"opening entry {index}: {link}")
webbrowser.open(link)
def main() -> int:
feed = fetch()
if feed is None:
return 1
show(feed)
if "--open" in sys.argv:
position = sys.argv.index("--open") + 1
index = int(sys.argv[position]) if position < len(sys.argv) else 0
open_entry(feed, index)
else:
print("Pass --open N to open entry N in a browser.")
return 0
if __name__ == "__main__":
if "--test" in sys.argv:
import unittest
class TestStrip(unittest.TestCase):
def test_tags_removed(self):
self.assertEqual(
strip_html('<div class="md"><p>Hello</p></div>'), "Hello")
def test_entities_unescaped(self):
self.assertEqual(strip_html("a & b"), "a & b")
def test_escaped_tags_survive_as_text(self):
# <b> is the *text* "<b>", not markup. Unescaping before
# stripping would delete it; this order keeps it.
self.assertEqual(strip_html("<b>"), "<b>")
def test_whitespace_collapsed(self):
self.assertEqual(strip_html("a\n\n b\t c"), "a b c")
def test_empty_input(self):
self.assertEqual(strip_html(""), "")
self.assertEqual(strip_html(None), "")
unittest.main(argv=sys.argv[:1], exit=False)
else:
raise SystemExit(main()) Run it
Section titled “Run it”python rssfeedreader.pySample output:
Feed Title: Python - Reddit
Article 1: How to build a web scraper with Python
Summary: Learn the basics of web scraping using BeautifulSoup...
Published: Mon, 01 Sep 2025 10:30:00 GMT
Author: pythondev123
Tags: programming, python, web-scrapingWhat it produces
Section titled “What it produces”Running the file exactly as it ships takes 2.0 s and prints:
Python -- 25 entries
0. Showcase Thread
2026-08-04T16:05:25+00:00 by /u/AutoModerator
Post all of your code/projects/showcases/AI slop here. Recycles once a month. submitted by /u/AutoModerator [l...
1. Sunday Daily Thread: What's everyone working on this week?
2026-08-09T00:00:08+00:00 by /u/AutoModerator
Weekly Thread: What's Everyone Working On This Week? 🛠️ Hello r/Python ! It's time to share what you've been w...
2. Python 3.15.0 RC1 Is Here — Python 3.15 Is Almost Ready 🚀
2026-08-08T12:08:30+00:00 by /u/Dhileepan_0311
This is an important milestone because the Python team has now entered the release-candidate phase. At this ...
3. Saturday Daily Thread: Resource Request and Sharing! Daily Thread
2026-08-08T00:00:10+00:00 by /u/AutoModerator
Weekly Thread: Resource Request and Sharing 📚 Stumbled upon a useful Python resource? Or are you looking for a...
4. PEP 841 – Adding Frozen Syntax to Optimize Immutable Types
2026-08-07T06:57:33+00:00 by /u/kirara0048
...The first 20 of 43 lines are shown; the run continues past this point.
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Import what you need
Section titled “1. Import what you need”import feedparser
import webbrowserfeedparserdoes all the HTTP + XML work.webbrowseropens URLs in the user’s default browser with one call.
2. Fetch and parse the feed
Section titled “2. Fetch and parse the feed”url = "https://www.reddit.com/r/Python/.rss"
feed = feedparser.parse(url)A single call. Under the hood feedparser:
- Sends an HTTP
GETto the URL. - Reads the response body.
- Parses it as XML.
- Normalizes the differences between RSS 1.0, RSS 2.0, and Atom into a common object structure.
The result is an object that looks like a dictionary. You can also access values with dot notation: feed.feed.title.
3. Read feed-level metadata
Section titled “3. Read feed-level metadata”print("Feed Title:", feed["feed"]["title"])
print("Feed Link:", feed["feed"]["link"])
print("Feed Description:", feed["feed"].get("description", "—"))Note feed["feed"] — yes, the feed object has a feed key for the feed-level fields, separate from the list of entries. It is a quirk of feedparser’s API.
Using .get(key, default) avoids KeyError when a feed omits an optional field.
4. Loop through entries
Section titled “4. Loop through entries”for i, entry in enumerate(feed.entries, start=1):
print(f"\nArticle {i}: {entry.title}")
print(f"Summary: {entry.get('summary', '')}")
print(f"Published: {entry.get('published', 'unknown')}")
print(f"Author: {entry.get('author', 'unknown')}")
tags = [t.term for t in entry.get('tags', [])]
if tags:
print(f"Tags: {', '.join(tags)}")enumerate(..., start=1)gives you both the index (starting at 1) and the entry.- Each entry has at least
titleandlink. Everything else is optional and varies by source. - Tags arrive as a list of objects;
t.termis the actual tag string.
5. Open articles in the browser
Section titled “5. Open articles in the browser”choice = input("\nEnter article number to open (or press Enter to skip): ")
if choice.strip().isdigit():
n = int(choice) - 1
if 0 <= n < len(feed.entries):
webbrowser.open(feed.entries[n].link)webbrowser.open(url) launches your default browser pointed at that URL. Cross-platform with no setup.
Cleaning Up HTML in Summaries
Section titled “Cleaning Up HTML in Summaries”Many feeds put HTML in the summary field — paragraphs, links, embedded images. To show plain text, strip the tags:
import re
def strip_html(text):
return re.sub(r"<[^>]+>", "", text or "").strip()
print(strip_html(entry.summary))For higher-quality output, use BeautifulSoup:
from bs4 import BeautifulSoup
text = BeautifulSoup(entry.summary, "html.parser").get_text()Handling Network Errors
Section titled “Handling Network Errors”feedparser does not raise on bad URLs; it sets a status. Always check it:
feed = feedparser.parse(url)
if feed.bozo: # bozo flag = something went wrong
print(f"Warning: feed may be malformed — {feed.bozo_exception}")
if not feed.entries:
print("No entries found.")Common Mistakes
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
KeyError: 'author' | Feed omits an optional field | Use entry.get('author', 'unknown') |
UnicodeEncodeError printing entries | Terminal cannot render some characters | Set PYTHONIOENCODING=utf-8 or pipe to a file |
Empty feed.entries | URL is HTML, not a feed; or site blocks the user agent | Pass a custom User-Agent via feedparser.parse(url, request_headers={...}) |
webbrowser.open does nothing | Running in a headless environment | Print the URL instead |
Variations to Try
Section titled “Variations to Try”1. Read multiple feeds
Section titled “1. Read multiple feeds”FEEDS = [
"https://news.ycombinator.com/rss",
"https://www.reddit.com/r/Python/.rss",
"https://feeds.bbci.co.uk/news/technology/rss.xml",
]
for url in FEEDS:
feed = feedparser.parse(url)
print(f"\n=== {feed.feed.title} ===")
for entry in feed.entries[:5]:
print(f" • {entry.title}")2. Sort entries by date
Section titled “2. Sort entries by date”Use the parsed published_parsed field, which is a Python struct_time:
import time
entries = sorted(feed.entries,
key=lambda e: e.get("published_parsed", time.gmtime(0)),
reverse=True)3. Save articles offline
Section titled “3. Save articles offline”Write each entry’s text content to a file for offline reading:
from pathlib import Path, PurePath
safe = "".join(c for c in entry.title if c.isalnum() or c == " ")[:80]
Path(f"articles/{safe}.txt").write_text(entry.summary, encoding="utf-8")4. Show only new articles (since last run)
Section titled “4. Show only new articles (since last run)”Store the timestamp of the last fetch in a file. Filter entries newer than that:
last_run = float(Path("last_run.txt").read_text() or 0)
new_entries = [e for e in feed.entries
if time.mktime(e.published_parsed) > last_run]
Path("last_run.txt").write_text(str(time.time()))5. Send a daily email digest
Section titled “5. Send a daily email digest”Combine feedparser with smtplib (Python’s email module) to mail yourself the new entries each morning.
6. Build a GUI reader
Section titled “6. Build a GUI reader”Wrap the loop in a Tkinter app with a feed list on the left and articles on the right. See Simple Blog System for the layout pattern.
7. Convert to OPML
Section titled “7. Convert to OPML”OPML is the standard format readers use to import/export feed subscriptions. Generate one from your list of URLs.
8. Search across feeds
Section titled “8. Search across feeds”After fetching, store all entries in a list and let the user filter by keyword:
keyword = input("Search: ").lower()
matches = [e for e in all_entries if keyword in e.title.lower()]Best Practices
Section titled “Best Practices”- Cache responses. Hitting the same feed every few seconds is rude. Honor the feed’s
etagandmodifiedheaders and pass them on subsequent requests so the server can answer with304 Not Modified.cache.py feed = feedparser.parse(url, etag=last_etag, modified=last_modified) - Set a polite User-Agent identifying your app and a contact URL.
- Limit display length. Truncate long summaries to keep the terminal readable.
- Validate input. When letting the user open entry N, check N is in range.
Real-World Applications
Section titled “Real-World Applications”- Personal news aggregators (think Feedly, NetNewsWire).
- Podcast catchers — podcasts publish RSS feeds with
<enclosure>tags pointing to MP3s. - Monitoring tools — turn build-failure feeds, CVE feeds, or pricing feeds into alerts.
- Content backups — archive a blog by downloading every entry.
- Cross-posting pipelines — read from one platform’s feed, post to another.
Educational Value
Section titled “Educational Value”This project teaches:
- Working with web data — HTTP requests are hidden but real.
- Structured data extraction — feeds, JSON APIs, scraped pages all share the same patterns.
- Defensive coding —
entry.get(key, default)is the difference between robust and brittle. - Module ecosystem — when to reach for a library (
feedparser) instead of parsing XML by hand. - User experience — even a CLI tool benefits from clean formatting and gentle error messages.
Next Steps
Section titled “Next Steps”- Build a subscription manager that stores feed URLs in a JSON file and lets the user add/remove them.
- Add a search index with
sqlite3so you can grep entries across hundreds of feeds. - Turn it into a web service with Flask (see Basic Web Server) that shows your feeds on a web page.
- Bridge feeds to Discord or Slack with their webhook APIs.
- Plug into a TTS engine (
pyttsx3) for an audio news briefing.
Conclusion
Section titled “Conclusion”A few dozen lines of Python plus feedparser is enough to replicate the core of a commercial RSS reader. You learned to fetch a feed, walk its entries, handle missing fields gracefully, and open articles in the browser. The same patterns will serve you anywhere you consume structured data from the web. The full source is on GitHub. Explore more projects on Python Central Hub.
How it fits together
Section titled “How it fits together” flowchart TD
A["feedparser.parse(url)"] --> B{"entries returned?"}
B -->|no| C["report the error
and exit non-zero"]
B -->|yes| D["for each entry"]
D --> E["strip_html(summary)"]
E --> F["remove tags"]
F --> G["unescape entities"]
G --> H["collapse whitespace"]
H --> I["print title, date, author"]
I --> J{"--open N given?"}
J -->|no| K["done"]
J -->|yes| L["webbrowser.open(entry N)"]
Pitfalls
Section titled “Pitfalls”- The original opened a browser window just for running.
webbrowser.open(entry['link'])sat at module level, so anyone who ran the script to read some headlines got a tab instead. Opening a window is now behind--open N. - Using the loop variable after the loop. After
for entry in ...:ends,entryis the last item. The original then printed “the” summary, author and link from it. That works, silently, and means something other than what it looks like. - Order matters when cleaning HTML. Unescaping entities before stripping
tags turns the text
<script>into a real tag, which the stripper then deletes. Measured on the exercise below:5 < 6 && 7 > 6becomes5 6— three tokens of content silently destroyed. - A regex is not an HTML parser.
<a title="1 > 2">link</a>strips to2">link, because the>inside the attribute closes the tag early. That is acceptable when the goal is to discard markup and unacceptable when the goal is to understand it. - A feed that fails to parse still returns an object.
feedparsersetsbozorather than raising, so a network error looks like an empty feed unless you check.
- Measured run: 25 entries from
r/Python, fetched and printed in 1.8 s. strip_htmldoes three things in a fixed order: remove tags, unescape entities, collapse whitespace. Swapping the first two loses content.- Side effects like opening a browser belong behind an explicit flag.
feed['feed']['title']is metadata;feed['entries']is the list. Neither raises when the fetch failed, which is whatbozois for.
-
Why must tags be stripped before HTML entities are unescaped?
pch.quizShowAnswer
B — Unescaping first turns escaped text like <b> into a real tag, which the tag stripper then deletes — silently removing content that was never markup
-
The original script ended with webbrowser.open(entry['link']). What are the two separate problems with that line?
pch.quizShowAnswer
B — It opens a window as an unrequested side effect of running the script, and `entry` after the loop is whichever entry happened to be last
-
feedparser.parse() on an unreachable URL does what?
pch.quizShowAnswer
B — Returns an object with bozo set and no entries — so an unchecked failure looks exactly like an empty feed
Try it yourself
Section titled “Try it yourself”pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading