Add RSS/Atom support, expand sources and excluded phrases
- services/feeds.py parses RSS 2.0/1.0 and Atom into (title, date) entries
- prepare_headlines auto-detects feeds by content and parses them directly
- strip feeds./rss./moxie. subdomains so feeds collapse to the outlet domain
- add 7 verified RSS feeds (BBC, Guardian, Al Jazeera, WaPo, The Hill, Vox, CNBC)
- get_sources skips blank lines and # comments
- broaden excluded-phrase list (skip links, share/follow, newsletter, utility)
4 dagar sedan |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103 |
- import os
- import tempfile
- import unittest
- from datetime import datetime, timezone
- from unittest.mock import patch, MagicMock
-
- from services.feeds import looks_like_feed, parse_feed
- from services.sources import get_sources
- from services.headlines import prepare_headlines
- from structs.headline import Headline
-
-
- RSS_2_0 = """<?xml version="1.0" encoding="UTF-8"?>
- <rss version="2.0"><channel>
- <title>Test feed</title>
- <item><title>First news story</title><link>https://x/1</link><pubDate>Mon, 14 Sep 2026 12:00:00 GMT</pubDate></item>
- <item><title>Second news story</title><link>https://x/2</link></item>
- </channel></rss>
- """
-
- ATOM = """<?xml version="1.0" encoding="utf-8"?>
- <feed xmlns="http://www.w3.org/2005/Atom">
- <entry><title>Atom story one</title><published>2026-09-14T14:30:00Z</published></entry>
- <entry><title>Atom story two</title><updated>2026-09-13T10:00:00Z</updated></entry>
- </feed>
- """
-
-
- class TestLooksLikeFeed(unittest.TestCase):
- def test_rss_and_atom_detected(self):
- self.assertTrue(looks_like_feed(RSS_2_0))
- self.assertTrue(looks_like_feed(ATOM))
-
- def test_html_not_detected(self):
- self.assertFalse(looks_like_feed("<html><body><a>Story</a></body></html>"))
-
- def test_empty_not_detected(self):
- self.assertFalse(looks_like_feed(""))
- self.assertFalse(looks_like_feed(None))
-
-
- class TestParseFeed(unittest.TestCase):
- def test_rss_2_0(self):
- items = parse_feed(RSS_2_0)
- titles = [t for t, _ in items]
- self.assertEqual(titles, ["First news story", "Second news story"])
- self.assertEqual(items[0][1].year, 2026)
- self.assertIsNone(items[1][1])
-
- def test_atom(self):
- items = parse_feed(ATOM)
- titles = [t for t, _ in items]
- self.assertEqual(titles, ["Atom story one", "Atom story two"])
- self.assertIsNotNone(items[0][1])
- self.assertIsNotNone(items[1][1])
-
- def test_malformed_returns_empty(self):
- self.assertEqual(parse_feed("not xml at all <<<"), [])
-
-
- class TestGetSources(unittest.TestCase):
- def test_skips_comments_and_blanks(self):
- path = None
- try:
- with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
- f.write("# a comment\n\nhttps://a.com\n\nhttps://b.com\n# another\n")
- path = f.name
- self.assertEqual(get_sources(path), ["https://a.com", "https://b.com"])
- finally:
- if path:
- os.unlink(path)
-
-
- class TestFeedDomainNormalization(unittest.TestCase):
- def test_feed_subdomain_stripped(self):
- self.assertEqual(Headline("a", ["a"], "https://feeds.npr.org/1001/rss.xml").domain, "npr.org")
- self.assertEqual(Headline("a", ["a"], "https://rss.nytimes.com/x").domain, "nytimes.com")
- self.assertEqual(Headline("a", ["a"], "https://www.vox.com/rss/index.xml").domain, "vox.com")
-
-
- class TestPrepareHeadlinesFeed(unittest.TestCase):
- def setUp(self):
- self.stopwords = {"the", "a", "an", "in", "on", "and", "of", "to"}
-
- @patch("services.headlines.requests.get")
- def test_feed_source_is_parsed(self, mock_get):
- mock_response = MagicMock()
- mock_response.status_code = 200
- mock_response.content = RSS_2_0.encode("utf-8")
- mock_response.text = RSS_2_0
- mock_get.return_value = mock_response
-
- headlines = prepare_headlines(["https://feeds.bbci.co.uk/news/world/rss.xml"], self.stopwords)
-
- titles = [h.display_text for h in headlines]
- self.assertEqual(titles, ["First news story", "Second news story"])
- # Domain comes from the feed host, normalized to the outlet.
- self.assertEqual(headlines[0].domain, "bbci.co.uk")
- self.assertIsNotNone(headlines[0].published_at)
-
-
- if __name__ == "__main__":
- unittest.main()
|