Skip to content

RSS Feed Reader

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 feedparser library 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.

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/rss
  • https://www.reddit.com/r/Python/.rss
  • https://feeds.bbci.co.uk/news/rss.xml
  • https://www.youtube.com/feeds/videos.xml?channel_id=CHANNEL_ID
  • Python 3.6 or above.
  • A text editor or IDE.
  • An internet connection.
  • Comfort installing packages with pip.
install
pip install feedparser

feedparser 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.

  1. Create a folder named rss-feed-reader.
  2. Inside it, create rssfeedreader.py.
  3. Open the folder in your editor.
RSS Feed Reader pch.viewSource
RSS Feed Reader
"""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 `&lt;b&gt;` 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 &amp; b"), "a & b")

            def test_escaped_tags_survive_as_text(self):
                # &lt;b&gt; is the *text* "<b>", not markup. Unescaping before
                # stripping would delete it; this order keeps it.
                self.assertEqual(strip_html("&lt;b&gt;"), "<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
python rssfeedreader.py

Sample output:

text
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-scraping

Running the file exactly as it ships takes 2.0 s and prints:

python rssfeedreader.py
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.

rssfeedreader.py
import feedparser
import webbrowser
  • feedparser does all the HTTP + XML work.
  • webbrowser opens URLs in the user’s default browser with one call.
rssfeedreader.py
url = "https://www.reddit.com/r/Python/.rss"
feed = feedparser.parse(url)

A single call. Under the hood feedparser:

  1. Sends an HTTP GET to the URL.
  2. Reads the response body.
  3. Parses it as XML.
  4. 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.

rssfeedreader.py
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.

rssfeedreader.py
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 title and link. Everything else is optional and varies by source.
  • Tags arrive as a list of objects; t.term is the actual tag string.
rssfeedreader.py
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.

Many feeds put HTML in the summary field — paragraphs, links, embedded images. To show plain text, strip the tags:

strip_html.py
import re
 
def strip_html(text):
    return re.sub(r"<[^>]+>", "", text or "").strip()
 
print(strip_html(entry.summary))

For higher-quality output, use BeautifulSoup:

bs4_clean.py
from bs4 import BeautifulSoup
text = BeautifulSoup(entry.summary, "html.parser").get_text()

feedparser does not raise on bad URLs; it sets a status. Always check it:

check_status.py
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.")
ProblemCauseFix
KeyError: 'author'Feed omits an optional fieldUse entry.get('author', 'unknown')
UnicodeEncodeError printing entriesTerminal cannot render some charactersSet PYTHONIOENCODING=utf-8 or pipe to a file
Empty feed.entriesURL is HTML, not a feed; or site blocks the user agentPass a custom User-Agent via feedparser.parse(url, request_headers={...})
webbrowser.open does nothingRunning in a headless environmentPrint the URL instead
multi_feed.py
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}")

Use the parsed published_parsed field, which is a Python struct_time:

sort.py
import time
entries = sorted(feed.entries,
                 key=lambda e: e.get("published_parsed", time.gmtime(0)),
                 reverse=True)

Write each entry’s text content to a file for offline reading:

offline.py
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:

incremental.py
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()))

Combine feedparser with smtplib (Python’s email module) to mail yourself the new entries each morning.

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.

OPML is the standard format readers use to import/export feed subscriptions. Generate one from your list of URLs.

After fetching, store all entries in a list and let the user filter by keyword:

search.py
keyword = input("Search: ").lower()
matches = [e for e in all_entries if keyword in e.title.lower()]
  • Cache responses. Hitting the same feed every few seconds is rude. Honor the feed’s etag and modified headers and pass them on subsequent requests so the server can answer with 304 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.
  • 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.

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 codingentry.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.
  • Build a subscription manager that stores feed URLs in a JSON file and lets the user add/remove them.
  • Add a search index with sqlite3 so 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.

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.

diagram Diagram mermaid
  • 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, entry is 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 &lt;script&gt; into a real tag, which the stripper then deletes. Measured on the exercise below: 5 &lt; 6 &amp;&amp; 7 &gt; 6 becomes 5 6 — three tokens of content silently destroyed.
  • A regex is not an HTML parser. <a title="1 > 2">link</a> strips to 2">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. feedparser sets bozo rather 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_html does 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 what bozo is for.
pch.quizTag pch.quizDefaultTitle
  1. Why must tags be stripped before HTML entities are unescaped?

    pch.quizShowAnswer

    B — Unescaping first turns escaped text like &lt;b&gt; into a real tag, which the tag stripper then deletes — silently removing content that was never markup

  2. 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

  3. 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

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading