diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..029519e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] \ No newline at end of file diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc deleted file mode 100644 index 346100e..0000000 Binary files a/__pycache__/main.cpython-314.pyc and /dev/null differ diff --git a/main.py b/main.py index d788e5a..060d4c8 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,12 @@ +import argparse import logging +from datetime import date + +from services.dates import filter_by_date_window, parse_date_arg from services.headlines import prepare_headlines -from services.normalization import get_stopwords, normalize_headline +from services.normalization import get_stopwords from services.sources import get_sources +from services.stories import cluster_stories logging.basicConfig( level=logging.INFO, @@ -12,9 +17,55 @@ logger = logging.getLogger(__name__) SOURCE_FILE = './resources/sources.txt' STOPWORDS_FILE = './resources/stopwords.txt' SIMILARITY_THRESHOLD = 0.75 +MIN_SOURCES = 2 + +# Used so stories without a parsable date sort behind dated ones. +_MIN_DATE = date.min + + +def _describe_window(since, until): + if since and until: + return " (published %s to %s)" % (since, until) + if since: + return " (published on or after %s)" % since + if until: + return " (published on or before %s)" % until + return "" + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Cluster headlines across news sources and print stories " + "covered by a minimum number of distinct outlets." + ) + parser.add_argument('--min-sources', type=int, default=MIN_SOURCES, metavar='N', + help="Only output stories reported by at least N distinct sources " + "(default: %(default)s).") + parser.add_argument('--threshold', type=float, default=SIMILARITY_THRESHOLD, metavar='T', + help="Cosine similarity used to consider two headlines the same story " + "(default: %(default)s).") + parser.add_argument('--since', type=parse_date_arg, metavar='YYYY-MM-DD', + help="Only consider headlines published on or after this date.") + parser.add_argument('--until', type=parse_date_arg, metavar='YYYY-MM-DD', + help="Only consider headlines published on or before this date.") + parser.add_argument('--verbose', action='store_true', + help='Enable debug logging.') + return parser.parse_args() def main(): + args = parse_args() + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + if args.min_sources < 1: + logger.error("--min-sources must be at least 1 (got %d).", args.min_sources) + return + + if args.since and args.until and args.since > args.until: + logger.error("--since (%s) is after --until (%s).", args.since, args.until) + return + logger.info("Starting duplicate headline detection application") logger.debug("Source file configured at: '%s'", SOURCE_FILE) logger.debug("Stopwords file configured at: '%s'", STOPWORDS_FILE) @@ -43,30 +94,34 @@ def main(): logger.critical("Failed during headline preparation: %s", e, exc_info=True) return - total_comparisons = (len(headlines) * (len(headlines) - 1)) // 2 if len(headlines) > 1 else 0 - logger.info("Beginning pairwise headline comparisons (total comparisons to execute: %d)", total_comparisons) - - duplicate_count = 0 - comparison_idx = 0 - for i in range(len(headlines)): - for j in range(i + 1, len(headlines)): - comparison_idx += 1 - logger.debug("Comparison [%d/%d]: Headline %d vs Headline %d", comparison_idx, total_comparisons, i, j) - try: - similarity_score = headlines[i].compare_headlines(headlines[j]) - logger.debug("Similarity score between [%d] and [%d] is %.4f (threshold: %f)", i, j, similarity_score, SIMILARITY_THRESHOLD) - if similarity_score >= SIMILARITY_THRESHOLD: - duplicate_count += 1 - logger.warning("Duplicate/similar headline match found (score: %.4f < %f): '%s' vs '%s'", - similarity_score, SIMILARITY_THRESHOLD, headlines[i].display_text, headlines[j].display_text) - print(f"Duplicate headlines found: {headlines[i].display_text}") - except Exception as e: - logger.error("Error during comparison between headline %d (%r) and headline %d (%r): %s", - i, headlines[i].display_text, j, headlines[j].display_text, e, exc_info=True) - - logger.info("Headline comparison completed. Evaluated %d pairs and found %d duplicate alerts.", - comparison_idx, duplicate_count) + if args.since is not None or args.until is not None: + headlines, dropped_undated = filter_by_date_window(headlines, args.since, args.until) + logger.info("Date window applied: %d headlines remain (%d dropped with no parseable date).", + len(headlines), dropped_undated) + + logger.info("Clustering headlines into stories (similarity threshold: %.2f)", args.threshold) + stories = cluster_stories(headlines, args.threshold) + + qualifying = [s for s in stories if s.source_count >= args.min_sources] + qualifying.sort(key=lambda s: (s.source_count, s.latest_date or _MIN_DATE), reverse=True) + + print() + window = _describe_window(args.since, args.until) + print("News stories covered by at least %d distinct sources" % args.min_sources + window) + print("=" * 60) + if not qualifying: + print("No stories met the minimum number of sources.") + else: + for story in qualifying: + date_label = story.latest_date.isoformat() if story.latest_date else "no date" + print(f"\n[{story.source_count} source(s)] {story.representative} ({date_label})") + print(" " + ", ".join(story.sources)) + + logger.info( + "Story clustering complete: %d total stories, %d with >= %d distinct sources.", + len(stories), len(qualifying), args.min_sources, + ) if __name__ == '__main__': - main() + main() \ No newline at end of file diff --git a/services/__pycache__/headlines.cpython-314.pyc b/services/__pycache__/headlines.cpython-314.pyc deleted file mode 100644 index 3696f10..0000000 Binary files a/services/__pycache__/headlines.cpython-314.pyc and /dev/null differ diff --git a/services/__pycache__/normalization.cpython-314.pyc b/services/__pycache__/normalization.cpython-314.pyc deleted file mode 100644 index 2b53b51..0000000 Binary files a/services/__pycache__/normalization.cpython-314.pyc and /dev/null differ diff --git a/services/__pycache__/similarity.cpython-314.pyc b/services/__pycache__/similarity.cpython-314.pyc deleted file mode 100644 index 260ad84..0000000 Binary files a/services/__pycache__/similarity.cpython-314.pyc and /dev/null differ diff --git a/services/__pycache__/sources.cpython-314.pyc b/services/__pycache__/sources.cpython-314.pyc deleted file mode 100644 index 1752280..0000000 Binary files a/services/__pycache__/sources.cpython-314.pyc and /dev/null differ diff --git a/services/dates.py b/services/dates.py new file mode 100644 index 0000000..3a9dd19 --- /dev/null +++ b/services/dates.py @@ -0,0 +1,95 @@ +import logging +from datetime import date, datetime, timezone +from email.utils import parsedate_to_datetime + +logger = logging.getLogger(__name__) + +# Common absolute-date formats encountered in news markup when ISO parsing fails. +_FALLBACK_FORMATS = ( + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y/%m/%d %H:%M:%S", + "%B %d, %Y", + "%b %d, %Y", + "%B %d, %Y %I:%M %p", + "%b %d, %Y %I:%M %p", +) + + +def parse_datetime(value): + """Best-effort parse of a publish timestamp into a ``datetime``. + + Handles ISO 8601 (including trailing ``Z`` and offsets), RFC 2822/email + dates, and a few common human formats. Returns ``None`` when the value + cannot be parsed — relative strings like "3 hours ago" are intentionally + skipped. + """ + if not value or not isinstance(value, str): + return None + text = value.strip() + if not text: + return None + + try: + return datetime.fromisoformat(text) + except ValueError: + pass + + try: + return parsedate_to_datetime(text) + except (TypeError, ValueError, OverflowError): + pass + + for fmt in _FALLBACK_FORMATS: + try: + return datetime.strptime(text, fmt) + except ValueError: + pass + + logger.debug("Could not parse datetime from value %r", value) + return None + + +def parse_date_arg(value): + """Parse a ``YYYY-MM-DD`` CLI argument, raising a friendly error otherwise.""" + try: + return date.fromisoformat(value) + except ValueError: + raise ValueError("Invalid date %r; expected YYYY-MM-DD" % value) + + +def date_part(dt): + """Return ``dt`` as a ``date``, normalized to UTC when timezone-aware.""" + if dt.tzinfo is not None: + return dt.astimezone(timezone.utc).date() + return dt.date() + + +def filter_by_date_window(headlines, since=None, until=None): + """Keep headlines whose publication date falls in ``[since, until]``. + + ``since``/``until`` are ``datetime.date`` (or ``None`` for an open bound). + When either bound is set, headlines with no parsable publication date are + dropped because their recency cannot be established; without any bound they + are all kept. Returns ``(kept, dropped_undated)``. + """ + kept = [] + dropped_undated = 0 + bounding = since is not None or until is not None + + for h in headlines: + if h.published_at is None: + if bounding: + dropped_undated += 1 + continue + kept.append(h) + continue + + d = date_part(h.published_at) + if since is not None and d < since: + continue + if until is not None and d > until: + continue + kept.append(h) + + return kept, dropped_undated \ No newline at end of file diff --git a/services/headlines.py b/services/headlines.py index 01614d4..ade23a7 100644 --- a/services/headlines.py +++ b/services/headlines.py @@ -1,6 +1,9 @@ import logging -from re import findall +import re + import requests + +from services.dates import parse_datetime from services.normalization import normalize_headline from structs.headline import Headline @@ -9,6 +12,52 @@ logger = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 MIN_HEADLINE_WORDS = 3 +# Single streaming pass over the HTML: captures " # group 1 attrs, group 2 text + r"|(?:(?:datetime|dateTime)\s*=\s*[\"']([^\"']+)[\"'])" # group 3 datetime attr + r"|<(?:article|li)\b[^>]*>" # container boundary (no group) + r"|<(?:a|span)\b[^>]*>\s*([^<]*?)\s*", # group 4 headline text + re.IGNORECASE | re.DOTALL, +) + + +def _extract_candidates(source_content): + """Yield ``(text, published_at)`` tuples in document order. + + Each headline is associated with the most recent publication timestamp seen + before it. Timestamps reset at each ``
``/``
  • `` boundary so a + headline with no date of its own does not inherit another story's timestamp. + """ + candidates = [] + last_published = None + for match in _CANDIDATE_RE.finditer(source_content): + if match.group(1) is not None: + # A element: prefer an explicit datetime/title + # attribute, otherwise fall back to its inner text. + attrs = match.group(1) + attr_match = re.search(r'(?:datetime|dateTime)\s*=\s*[\"\']([^\"\']+)[\"\']', attrs, re.IGNORECASE) \ + or re.search(r'title\s*=\s*[\"\']([^\"\']+)[\"\']', attrs, re.IGNORECASE) + raw = attr_match.group(1) if attr_match else match.group(2) + parsed = parse_datetime(raw) + if parsed is not None: + last_published = parsed + elif match.group(3) is not None: + # A datetime attribute on some non-
    /
  • boundary: a headline without its own timestamp + # should not inherit the previous article's date. + last_published = None + return candidates + def is_headline(text, stopwords=None): if not text or not isinstance(text, str): @@ -64,14 +113,14 @@ def prepare_headlines(sources, stopwords, timeout=DEFAULT_TIMEOUT): logger.debug("Parsing HTML content from '%s' for headline candidates", source_url) try: - link_texts = findall(r'<(?:a|span)\b[^>]*>\s*([^<]*?)\s*', source_content) - logger.info("Found %d candidate tags in source '%s'", len(link_texts), source_url) + candidates = _extract_candidates(source_content) + logger.info("Found %d candidate tags in source '%s'", len(candidates), source_url) except Exception as e: logger.error("Regex extraction failed on content from '%s': %s", source_url, e, exc_info=True) continue source_headlines_count = 0 - for tag_idx, link_text in enumerate(link_texts, start=1): + for tag_idx, (link_text, published_at) in enumerate(candidates, start=1): cleaned_text = link_text.strip() if not cleaned_text: logger.debug("Skipping empty tag text at position %d from '%s'", tag_idx, source_url) @@ -79,10 +128,10 @@ def prepare_headlines(sources, stopwords, timeout=DEFAULT_TIMEOUT): if not is_headline(cleaned_text, stopwords): logger.debug("Skipping non-headline tag text at position %d from '%s': %r", tag_idx, source_url, cleaned_text) continue - logger.debug("Processing tag [%d/%d] from '%s': %r", tag_idx, len(link_texts), source_url, cleaned_text) + logger.debug("Processing tag [%d/%d] from '%s': %r", tag_idx, len(candidates), source_url, cleaned_text) try: normalized_headline = normalize_headline(cleaned_text, stopwords) - headline = Headline(cleaned_text, normalized_headline) + headline = Headline(cleaned_text, normalized_headline, source_url, published_at) headlines.append(headline) source_headlines_count += 1 except Exception as e: diff --git a/services/stories.py b/services/stories.py new file mode 100644 index 0000000..6372942 --- /dev/null +++ b/services/stories.py @@ -0,0 +1,55 @@ +import logging + +from structs.headline import Headline +from structs.story import Story + +logger = logging.getLogger(__name__) + + +def cluster_stories(headlines: list[Headline], threshold: float) -> list[Story]: + """Group headlines into stories using transitive similarity. + + Any two headlines whose cosine similarity meets ``threshold`` are linked, + and links are unioned transitively, so a chain of near-duplicates collapses + into a single story cluster even when the endpoints are not directly similar. + """ + n = len(headlines) + if n == 0: + logger.info("No headlines to cluster.") + return [] + + parent = list(range(n)) + + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + def union(a, b): + ra, rb = find(a), find(b) + if ra != rb: + parent[rb] = ra + + links = 0 + for i in range(n): + for j in range(i + 1, n): + try: + score = headlines[i].compare_headlines(headlines[j]) + except Exception as e: + logger.error("Error comparing headlines [%d] and [%d] during clustering: %s", + i, j, e, exc_info=True) + continue + if score >= threshold: + union(i, j) + links += 1 + + # Assemble clusters (connected components) keyed by root index. + components = {} + for idx in range(n): + components.setdefault(find(idx), []).append(headlines[idx]) + + stories = [Story(members) for members in components.values()] + logger.info("Clustering complete: %d headlines -> %d stories via %d similarity links.", + n, len(stories), links) + return stories \ No newline at end of file diff --git a/structs/__pycache__/headline.cpython-314.pyc b/structs/__pycache__/headline.cpython-314.pyc deleted file mode 100644 index 24fe8cb..0000000 Binary files a/structs/__pycache__/headline.cpython-314.pyc and /dev/null differ diff --git a/structs/headline.py b/structs/headline.py index a324db5..3ff79e4 100644 --- a/structs/headline.py +++ b/structs/headline.py @@ -1,4 +1,6 @@ import logging +from urllib.parse import urlparse + from services.similarity import cosine_lists logger = logging.getLogger(__name__) @@ -7,12 +9,37 @@ logger = logging.getLogger(__name__) class Headline: display_text: str normalized_text: list[str] + source_url: str + domain: str + published_at: object - def __init__(self, display_text: str, normalized_text: list[str]): + def __init__(self, display_text: str, normalized_text: list[str], source_url: str = "", published_at=None): self.display_text = display_text self.normalized_text = normalized_text - logger.debug("Initialized Headline instance (display_text=%r, token_count=%d): %s", - self.display_text, len(self.normalized_text), self.normalized_text) + self.source_url = source_url + self.domain = self._extract_domain(source_url) + self.published_at = published_at + logger.debug( + "Initialized Headline instance (display_text=%r, token_count=%d, source=%r, domain=%r, published_at=%r)", + self.display_text, len(self.normalized_text), self.source_url, self.domain, self.published_at, + ) + + @staticmethod + def _extract_domain(url: str): + """Return a normalized outlet domain for a source URL, or None if absent. + + Strips scheme, surrounding whitespace and a leading ``www.`` so that + ``https://www.cnn.com/us`` and ``https://cnn.com/politics`` both resolve + to ``cnn.com`` — i.e. one outlet counts once, regardless of section URL. + """ + if not url or not isinstance(url, str) or not url.strip(): + return None + host = urlparse(url.strip()).netloc.lower() + if not host: + return None + if host.startswith("www."): + host = host[4:] + return host def compare_headlines(self, other_headline): if not isinstance(other_headline, Headline): diff --git a/structs/story.py b/structs/story.py new file mode 100644 index 0000000..f9f21c7 --- /dev/null +++ b/structs/story.py @@ -0,0 +1,40 @@ +import logging +from collections import OrderedDict + +from services.dates import date_part +from structs.headline import Headline + +logger = logging.getLogger(__name__) + + +class Story: + """A cluster of headlines, drawn from one or more sources, that all report + the same underlying news story.""" + + def __init__(self, headlines: list[Headline]): + self.headlines = list(headlines) + logger.debug("Created Story with %d headline(s)", len(self.headlines)) + + @property + def sources(self) -> list[str]: + """Distinct outlet domains for this story, in first-seen order.""" + seen = OrderedDict() + for h in self.headlines: + if h.domain: + seen.setdefault(h.domain, None) + return list(seen.keys()) + + @property + def source_count(self) -> int: + return len(self.sources) + + @property + def representative(self) -> str: + """The most descriptive headline text for this story (longest, first on tie).""" + return max(self.headlines, key=lambda h: len(h.display_text)).display_text + + @property + def latest_date(self): + """The most recent publication date among this story's headlines, or None.""" + dates = [date_part(h.published_at) for h in self.headlines if h.published_at is not None] + return max(dates) if dates else None \ No newline at end of file diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index d523ca4..0000000 Binary files a/tests/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tests/__pycache__/test_headlines.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_headlines.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 68d2407..0000000 Binary files a/tests/__pycache__/test_headlines.cpython-314-pytest-9.0.3.pyc and /dev/null differ diff --git a/tests/__pycache__/test_headlines.cpython-314.pyc b/tests/__pycache__/test_headlines.cpython-314.pyc deleted file mode 100644 index c82135d..0000000 Binary files a/tests/__pycache__/test_headlines.cpython-314.pyc and /dev/null differ diff --git a/tests/test_dates.py b/tests/test_dates.py new file mode 100644 index 0000000..fc5380e --- /dev/null +++ b/tests/test_dates.py @@ -0,0 +1,117 @@ +import unittest +from datetime import date, datetime, timezone +from unittest.mock import patch, MagicMock + +from services.dates import ( + parse_datetime, + parse_date_arg, + date_part, + filter_by_date_window, +) +from services.headlines import prepare_headlines +from structs.headline import Headline + + +def h(text, tokens, dt=None, url="https://a.com"): + return Headline(text, tokens, url, dt) + + +class TestParseDatetime(unittest.TestCase): + def test_iso_with_z(self): + dt = parse_datetime("2026-09-14T14:30:00Z") + self.assertIsNotNone(dt) + self.assertEqual(dt.year, 2026) + + def test_iso_with_offset(self): + dt = parse_datetime("2026-09-14T10:30:00-04:00") + self.assertEqual((dt.month, dt.day), (9, 14)) + + def test_rfc2822(self): + dt = parse_datetime("Mon, 14 Sep 2026 12:30:00 GMT") + self.assertEqual((dt.year, dt.month, dt.day), (2026, 9, 14)) + + def test_human_month_day_year(self): + dt = parse_datetime("September 14, 2026") + self.assertEqual((dt.month, dt.day), (9, 14)) + + def test_garbage_and_relative_return_none(self): + self.assertIsNone(parse_datetime("3 hours ago")) + self.assertIsNone(parse_datetime("not a date")) + self.assertIsNone(parse_datetime("")) + self.assertIsNone(parse_datetime(None)) + + +class TestParseDateArg(unittest.TestCase): + def test_valid(self): + self.assertEqual(parse_date_arg("2026-09-14"), date(2026, 9, 14)) + + def test_invalid(self): + with self.assertRaises(ValueError): + parse_date_arg("14-09-2026") + + +class TestDatePart(unittest.TestCase): + def test_aware_normalized_to_utc(self): + dt = datetime(2026, 9, 14, 23, 30, tzinfo=timezone.utc) + self.assertEqual(date_part(dt), date(2026, 9, 14)) + + def test_naive_kept(self): + dt = datetime(2026, 9, 14, 23, 30) + self.assertEqual(date_part(dt), date(2026, 9, 14)) + + +class TestFilterByDateWindow(unittest.TestCase): + def setUp(self): + self.headlines = [ + h("old story", ["old"], datetime(2026, 9, 10)), + h("recent story", ["recent"], datetime(2026, 9, 14)), + h("undated story", ["undated"], None), + ] + + def test_no_bounds_keeps_all(self): + kept, dropped = filter_by_date_window(self.headlines) + self.assertEqual(len(kept), 3) + self.assertEqual(dropped, 0) + + def test_since_drops_undated_and_old(self): + kept, dropped = filter_by_date_window(self.headlines, since=date(2026, 9, 13)) + self.assertEqual([x.display_text for x in kept], ["recent story"]) + self.assertEqual(dropped, 1) + + def test_until(self): + kept, _ = filter_by_date_window(self.headlines, until=date(2026, 9, 12)) + self.assertEqual([x.display_text for x in kept], ["old story"]) + + def test_inclusive_bounds(self): + kept, _ = filter_by_date_window(self.headlines, since=date(2026, 9, 14), until=date(2026, 9, 14)) + self.assertEqual([x.display_text for x in kept], ["recent story"]) + + +class TestDateCapturedFromHtml(unittest.TestCase): + def setUp(self): + self.stopwords = {"the", "a", "an", "in", "on"} + + @patch("services.headlines.requests.get") + def test_time_element_associated_with_headline(self, mock_get): + html = ( + "" + "" + "
    Another Story Here
    " + "" + ) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = html.encode("utf-8") + mock_response.text = html + mock_get.return_value = mock_response + + headlines = prepare_headlines(["https://example.com/news"], self.stopwords) + + by_text = {x.display_text: x for x in headlines} + self.assertIsNotNone(by_text["Breaking News Story"].published_at) + self.assertIsNone(by_text["Another Story Here"].published_at) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_stories.py b/tests/test_stories.py new file mode 100644 index 0000000..85d3254 --- /dev/null +++ b/tests/test_stories.py @@ -0,0 +1,74 @@ +import unittest + +from structs.headline import Headline +from structs.story import Story +from services.stories import cluster_stories + + +def h(text, tokens, url=""): + return Headline(text, tokens, url) + + +class TestHeadlineSourceTracking(unittest.TestCase): + def test_domain_extraction_strips_www_and_lowercases(self): + headline = h("A story", ["a"], "https://www.CNN.com/us") + self.assertEqual(headline.domain, "cnn.com") + + def test_domain_extraction_section_paths_collapse(self): + us = h("A story", ["a"], "https://www.cnn.com/us") + politics = h("A story", ["a"], "https://cnn.com/politics") + self.assertEqual(us.domain, politics.domain) + + def test_domain_is_none_when_source_missing(self): + self.assertIsNone(h("A story", ["a"]).domain) + self.assertIsNone(h("A story", ["a"], "").domain) + self.assertIsNone(h("A story", ["a"], " ").domain) + + +class TestStory(unittest.TestCase): + def test_sources_dedup_preserving_order(self): + story = Story([ + h("Story A", ["a"], "https://www.cnn.com/us"), + h("Story A", ["a"], "https://foxnews.com/politics"), + h("Story A", ["a"], "https://www.cnn.com/politics"), + ]) + self.assertEqual(story.sources, ["cnn.com", "foxnews.com"]) + self.assertEqual(story.source_count, 2) + + def test_representative_is_longest_headline(self): + story = Story([ + h("Short", ["short"], "https://a.com"), + h("A much longer descriptive headline", ["long"], "https://b.com"), + ]) + self.assertEqual(story.representative, "A much longer descriptive headline") + + +class TestClusterStories(unittest.TestCase): + def test_transitive_clustering_merges_chain(self): + # Three headlines sharing tokens pairwise but not as a triple. + headlines = [ + h("alpha beta", ["alpha", "beta"], "https://a.com"), + h("beta gamma", ["beta", "gamma"], "https://b.com"), + h("delta epsilon", ["delta", "epsilon"], "https://c.com"), + ] + stories = cluster_stories(headlines, threshold=0.4) + texts = {s.representative for s in stories} + # alpha/beta and beta/gamma share "beta" -> one cluster; delta/epsilon separate. + self.assertEqual(len(stories), 2) + self.assertIn("alpha beta", texts) + self.assertIn("delta epsilon", texts) + + def test_non_similar_headlines_stay_separate(self): + headlines = [ + h("apple pie", ["apple", "pie"], "https://a.com"), + h("quantum physics", ["quantum", "physics"], "https://b.com"), + ] + stories = cluster_stories(headlines, threshold=0.6) + self.assertEqual(len(stories), 2) + + def test_empty_input(self): + self.assertEqual(cluster_stories([], 0.75), []) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file