diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..7a60b85e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+*.pyc
diff --git a/news_fetcher.py b/news_fetcher.py
new file mode 100644
index 00000000..4954eac3
--- /dev/null
+++ b/news_fetcher.py
@@ -0,0 +1,116 @@
+"""Fetch today's news headlines from public RSS feeds."""
+
+import urllib.request
+import xml.etree.ElementTree as ET
+from datetime import datetime, timezone
+
+
+# Public RSS feed sources
+RSS_FEEDS = {
+ "BBC News": "https://feeds.bbci.co.uk/news/rss.xml",
+ "CNN": "http://rss.cnn.com/rss/edition.rss",
+ "Reuters": "https://feeds.reuters.com/reuters/topNews",
+}
+
+DEFAULT_TIMEOUT = 10 # seconds
+
+
+def fetch_rss(url, timeout=DEFAULT_TIMEOUT):
+ """Fetch and parse an RSS feed from the given URL.
+
+ Args:
+ url: The RSS feed URL.
+ timeout: Request timeout in seconds.
+
+ Returns:
+ A list of dicts with keys 'title', 'link', and 'published'.
+ """
+ req = urllib.request.Request(
+ url,
+ headers={"User-Agent": "NewsFetcher/1.0"},
+ )
+ with urllib.request.urlopen(req, timeout=timeout) as response:
+ data = response.read()
+
+ return parse_rss_xml(data)
+
+
+def parse_rss_xml(xml_bytes):
+ """Parse RSS XML bytes into a list of article dicts.
+
+ Args:
+ xml_bytes: Raw XML content as bytes.
+
+ Returns:
+ A list of dicts with keys 'title', 'link', and 'published'.
+ """
+ root = ET.fromstring(xml_bytes)
+ items = root.findall(".//item")
+
+ articles = []
+ for item in items:
+ title_el = item.find("title")
+ link_el = item.find("link")
+ pub_date_el = item.find("pubDate")
+
+ articles.append({
+ "title": title_el.text.strip() if title_el is not None and title_el.text else "",
+ "link": link_el.text.strip() if link_el is not None and link_el.text else "",
+ "published": pub_date_el.text.strip() if pub_date_el is not None and pub_date_el.text else "",
+ })
+
+ return articles
+
+
+def fetch_news(sources=None, max_items=5):
+ """Fetch top news headlines from multiple RSS sources.
+
+ Args:
+ sources: A dict mapping source names to RSS URLs.
+ Defaults to RSS_FEEDS.
+ max_items: Maximum number of headlines per source.
+
+ Returns:
+ A dict mapping source names to lists of article dicts.
+ """
+ if sources is None:
+ sources = RSS_FEEDS
+
+ all_news = {}
+ for name, url in sources.items():
+ try:
+ articles = fetch_rss(url)
+ all_news[name] = articles[:max_items]
+ except Exception as exc:
+ all_news[name] = [{"title": f"Error fetching feed: {exc}", "link": "", "published": ""}]
+
+ return all_news
+
+
+def display_news(news):
+ """Print news headlines to the console.
+
+ Args:
+ news: A dict mapping source names to lists of article dicts.
+ """
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ print(f"\n{'='*60}")
+ print(f" Today's News Headlines — {today}")
+ print(f"{'='*60}\n")
+
+ for source, articles in news.items():
+ print(f"📰 {source}")
+ print(f"{'-'*40}")
+ for i, article in enumerate(articles, 1):
+ print(f" {i}. {article['title']}")
+ if article["link"]:
+ print(f" 🔗 {article['link']}")
+ if article["published"]:
+ print(f" 📅 {article['published']}")
+ print()
+
+
+if __name__ == "__main__":
+ print("Fetching today's news...")
+ news = fetch_news()
+ display_news(news)
diff --git a/test_news_fetcher.py b/test_news_fetcher.py
new file mode 100644
index 00000000..59cb41fd
--- /dev/null
+++ b/test_news_fetcher.py
@@ -0,0 +1,82 @@
+"""Tests for news_fetcher module."""
+
+import unittest
+from news_fetcher import parse_rss_xml, display_news
+
+
+SAMPLE_RSS = b"""\
+
+
+
+ Sample News
+ -
+ First headline
+ https://example.com/1
+ Mon, 10 Feb 2026 00:00:00 GMT
+
+ -
+ Second headline
+ https://example.com/2
+ Mon, 10 Feb 2026 01:00:00 GMT
+
+
+
+"""
+
+
+class TestParseRssXml(unittest.TestCase):
+ """Tests for the parse_rss_xml helper."""
+
+ def test_parses_titles(self):
+ articles = parse_rss_xml(SAMPLE_RSS)
+ self.assertEqual(len(articles), 2)
+ self.assertEqual(articles[0]["title"], "First headline")
+ self.assertEqual(articles[1]["title"], "Second headline")
+
+ def test_parses_links(self):
+ articles = parse_rss_xml(SAMPLE_RSS)
+ self.assertEqual(articles[0]["link"], "https://example.com/1")
+
+ def test_parses_pub_date(self):
+ articles = parse_rss_xml(SAMPLE_RSS)
+ self.assertEqual(articles[0]["published"], "Mon, 10 Feb 2026 00:00:00 GMT")
+
+ def test_empty_feed(self):
+ empty_rss = b"""\
+
+Empty
+"""
+ articles = parse_rss_xml(empty_rss)
+ self.assertEqual(articles, [])
+
+ def test_missing_optional_elements(self):
+ rss_no_link = b"""\
+
+
+
+ - Only title
+
+
+"""
+ articles = parse_rss_xml(rss_no_link)
+ self.assertEqual(len(articles), 1)
+ self.assertEqual(articles[0]["title"], "Only title")
+ self.assertEqual(articles[0]["link"], "")
+ self.assertEqual(articles[0]["published"], "")
+
+
+class TestDisplayNews(unittest.TestCase):
+ """Tests for the display_news function (smoke test)."""
+
+ def test_display_does_not_raise(self):
+ news = {
+ "Test Source": [
+ {"title": "Headline", "link": "https://example.com", "published": "today"},
+ ]
+ }
+ # Should not raise
+ display_news(news)
+
+
+if __name__ == "__main__":
+ unittest.main()