- Track source domain and publication time on each headline - Cluster near-duplicate headlines into stories (union-find) - Output stories covered by >=N distinct sources (--min-sources) - Add --since/--until date window - Ignore __pycache__ build artifactsmaster
| @@ -0,0 +1,2 @@ | |||
| __pycache__/ | |||
| *.py[cod] | |||
| @@ -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() | |||
| @@ -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 | |||
| @@ -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 <time> markers (with an optional | |||
| # datetime/title attribute or inner text) and <a>/<span> headline text, so every | |||
| # headline can be associated with the most recent publication timestamp seen | |||
| # before it in document order. | |||
| _CANDIDATE_RE = re.compile( | |||
| r"<time\b([^>]*)>(.*?)</time>" # 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*</(?:a|span)>", # 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 ``<article>``/``<li>`` 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 <time ...>...</time> 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-<time> element. | |||
| parsed = parse_datetime(match.group(3)) | |||
| if parsed is not None: | |||
| last_published = parsed | |||
| elif match.group(4) is not None: | |||
| candidates.append((match.group(4), last_published)) | |||
| else: | |||
| # <article>/<li> 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*</(?:a|span)>', 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: | |||
| @@ -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 | |||
| @@ -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): | |||
| @@ -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 | |||
| @@ -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 = ( | |||
| "<html><body>" | |||
| "<article><time datetime='2026-09-14T14:30:00Z'>Sep 14</time>" | |||
| "<a href='/news'>Breaking News Story</a></article>" | |||
| "<article><span>Another Story Here</span></article>" | |||
| "</body></html>" | |||
| ) | |||
| 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() | |||
| @@ -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() | |||