Your morning probably starts the same way everyone's does: open the news site, scroll for ten minutes, absorb three headlines, close it. Then you do it again at lunch. The alternative is a script that collects the headlines for you while you sleep and emails you one tidy summary at 7 AM — a newspaper, but it only prints the pages you asked for.

That's what we're building today: a news digest. It connects three skills you may already have from this series — fetching data from the internet, sending email with Python, and plain Python logic — into one script that runs itself.

Why RSS (And Not Web Scraping)

When a site publishes new content, it usually offers an RSS feed: a machine-readable list of recent articles in XML format. The feed for Hacker News, for example, is just https://news.ycombinator.com/rss.

RSS beats scraping HTML for this project on every axis:

  • Stable: feeds are designed for machines; scrapers break every time a site redesigns
  • Less code: no selectors, no HTML parsing, just XML
  • Cleaner: you get title, link, and publish date already separated

Scraping remains the fallback for sites without feeds — the web scraping guide covers that path. But whenever a feed exists, use it.

Setup: The Usual Two Minutes

You need requests (for downloading the feeds) and a Gmail App Password (for sending the email). If you've done any previous project in this series, you have both:

pip install requests

# App Password setup (skip if you already have one):
# 1. Enable 2-Step Verification on your Google Account
# 2. Generate an App Password under Google account settings
export GMAIL_ADDRESS="yourname@gmail.com"
export GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"

The full walkthrough is in the email sending guide if you need it.

What a Feed Looks Like

Fetch any feed URL in a browser and you'll see XML. Every news item is an <item> block with three fields we care about:

<item>
  <title>Some headline</title>
  <link>https://example.com/story</link>
  <pubDate>Sat, 16 Aug 2026 12:00:00 GMT</pubDate>
</item>

Our job: download the feed, pull out these three fields from every <item>, keep only the recent ones, and email the result. Four steps.

Step 1: Download and Parse a Feed

"""morning-digest.py — fetch RSS feeds and email yourself a daily summary."""
import os
import smtplib
from datetime import datetime, timezone
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import parsedate_to_datetime
from xml.etree import ElementTree

import requests

# ── Config ────────────────────────────────────────
GMAIL_ADDRESS = os.environ["GMAIL_ADDRESS"]
GMAIL_APP_PASSWORD = os.environ["GMAIL_APP_PASSWORD"]
FEEDS = [
    "https://hnrss.org/newest",                # Hacker News mirror
    "https://lwn.net/headlines/rss",           # Linux & open source news
    "https://www.npr.org/rss/rss.php?id=1001", # NPR technology
]
HOURS = 24                       # how far back to look
KEYWORDS = []                    # optional filter, e.g. ["python", "linux"]

def fetch_feed(url):
    """Download a feed and return (feed_title, [(headline, link, pub_date)])."""
    response = requests.get(url, timeout=15)
    response.raise_for_status()
    root = ElementTree.fromstring(response.content)
    feed_title = root.findtext("channel/title") or url
    items = []
    for item in root.findall(".//item"):
        headline = (item.findtext("title") or "").strip()
        link = (item.findtext("link") or "").strip()
        pub = item.findtext("pubDate") or ""
        items.append((headline, link, pub))
    return feed_title, items

ElementTree is Python's built-in XML parser. findall(".//item") finds every <item> block no matter how deep it sits, and findtext("title") reads the text inside a tag. Three lines per item, and the whole feed is ours.

Step 2: Keep Only Fresh Headlines

Feeds contain old stories too — some feeds go back years. The <pubDate> field tells us when each one was published. It's written in RFC 822 format (Sat, 16 Aug 2026 12:00:00 GMT), and Python's email.utils module — yes, the email module — has a parser for exactly that:

def is_recent(pub_date, hours):
    """True if the article was published within the last `hours`."""
    try:
        published = parsedate_to_datetime(pub_date)
    except (TypeError, ValueError):
        return False   # unreadable date → skip it
    age = datetime.now(timezone.utc) - published
    return age.total_seconds() <= hours * 3600

Two details worth knowing: parsedate_to_datetime returns a timezone-aware datetime, which is why we compare against datetime.now(timezone.utc) — comparing aware and naive datetimes crashes with a TypeError, and the try/except turns that into a polite skip. And age.total_seconds() gives you the age as a plain number, which is much easier to compare than date objects.

Step 3: Optional Keyword Filter

Reading 60 headlines is barely better than scrolling the news site. Want only the Python and Linux stories? Set KEYWORDS = ["python", "linux"] and add this filter:

if KEYWORDS:
    recent = [it for it in recent
              if any(k.lower() in it[0].lower() for k in KEYWORDS)]

The .lower() calls make the match case-insensitive. Leave KEYWORDS = [] and the filter does nothing — every recent headline comes through.

Step 4: Build the Email (Plain Text + HTML)

The digest email is built with MIMEMultipart("alternative"): one message, two bodies. Email apps that display HTML show the pretty version with clickable links; anything else (plain-text readers, preview panes, your terminal) falls back to the text version:

def build_digest():
    plain = []
    html = ["<html><body>"]
    for url in FEEDS:
        feed_title, items = fetch_feed(url)
        recent = [it for it in items if is_recent(it[2], HOURS)]
        if KEYWORDS:
            recent = [it for it in recent
                      if any(k.lower() in it[0].lower() for k in KEYWORDS)]
        plain.append(f"\n{feed_title} ({len(recent)} new)")
        html.append(f"<h2>{feed_title} ({len(recent)} new)</h2><ul>")
        for headline, link, _ in recent:
            plain.append(f"- {headline}\n  {link}")
            html.append(f'<li><a href="{link}">{headline}</a></li>')
        html.append("</ul>")
    plain.append(f"\nSent by morning-digest.py at {datetime.now().strftime('%H:%M')}")
    html.append("</body></html>")
    return "\n".join(plain), "".join(html)

Step 5: Send It

The sending part is the exact pattern from the email guide, minus the guesswork:

def send_digest(plain, html):
    msg = MIMEMultipart("alternative")
    msg["Subject"] = "Your Morning Digest"
    msg["From"] = GMAIL_ADDRESS
    msg["To"] = GMAIL_ADDRESS
    msg.attach(MIMEText(plain, "plain"))
    msg.attach(MIMEText(html, "html"))
    server = smtplib.SMTP_SSL("smtp.gmail.com", 465)
    server.login(GMAIL_ADDRESS, GMAIL_APP_PASSWORD)
    server.send_message(msg)
    server.quit()

plain, html = build_digest()
print(plain)          # see what you're sending
send_digest(plain, html)
print("Digest sent.")

Run it and you'll see the digest printed in your terminal, then a copy lands in your inbox:

Hacker News: Newest (20 new)
- Credit card debt rises to $1.26T, nearing all-time record
  https://abc7.com/story/credit-card-debt-rises-126-trillion...
- The key that never exists: a threshold signing ceremony in the browser
  https://808bits.com/articles/threshold-signing-ceremony...

LWN.net (4 new)
- [$] The state of the kernel cycle
  https://lwn.net/Articles/...

Make It a Morning Routine

Now the payoff: this script runs itself. The Cron Jobs guide covers scheduling in depth; the one-liner is:

0 7 * * * /usr/bin/python3 /home/you/morning-digest.py >> /var/log/digest.log 2>&1

Every morning at 7:00, fresh headlines are waiting in your inbox. Windows users: Task Scheduler runs the same command. Mac users: cron works, or use launchd if you prefer.

Where to Find Feeds

Most sites link their feed from the homepage (look for the RSS icon), or you can try these addresses:

Source Feed URL
Hacker News (mirror) https://hnrss.org/newest
Hacker News (official) https://news.ycombinator.com/rss
BBC News https://feeds.bbci.co.uk/news/rss.xml
NPR Technology https://www.npr.org/rss/rss.php?id=1001
LWN.net https://lwn.net/headlines/rss
Reddit (any subreddit) https://www.reddit.com/r/python/.rss

Gotchas (And Their Fixes)

Symptom Cause Fix
ConnectTimeout A feed is down or blocked Try a different feed; the timeout keeps one bad feed from hanging the script
Stories missing from digest Wrong timezone or old date parsedate_to_datetime handles timezones — make sure the feed actually has pubDate
TypeError: can't subtract offset-naive... Comparing naive and aware datetimes The try/except in is_recent already skips these — keep it
HTML looks broken in the email link contains characters like & Rare for clean feeds; could html.escape() the values if it happens
Zero new items every morning Feeds updated, or your cron box has a flaky network Run by hand and print len(items) per feed to see which one is empty
Feed sends 200 items HOURS window too wide Lower HOURS, or add KEYWORDS

One more tip: if a feed ever sends you garbage (ads, spam posts), remove it from FEEDS — nothing else needs to change. The list is the configuration file.

What's Next?

You now have a pipeline that fetches, filters, and delivers. Natural upgrades from other guides in this series: run it on a schedule and let Python tidy your inbox so the digest doesn't drown in newsletters — or skip email entirely and send the digest to Telegram. If you'd rather watch prices than headlines, the price tracker guide swaps RSS feeds for product pages. And when you're ready for the biggest upgrade of all, add AI to your Python scripts — one AI pass turns a wall of headlines into a five-bullet morning brief.

Build it once, and "reading the news" becomes a five-minute coffee ritual instead of an hour of doomscrolling.

All code in this article was tested and runs successfully on Python 3.8.10 (Ubuntu 20.04) with requests 2.32.4 — verified against hnrss.org, lwn.net, and NPR feeds with a real Gmail account, August 2026.