- 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 | 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.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.sources import get_sources | ||||
| from services.stories import cluster_stories | |||||
| logging.basicConfig( | logging.basicConfig( | ||||
| level=logging.INFO, | level=logging.INFO, | ||||
| @@ -12,9 +17,55 @@ logger = logging.getLogger(__name__) | |||||
| SOURCE_FILE = './resources/sources.txt' | SOURCE_FILE = './resources/sources.txt' | ||||
| STOPWORDS_FILE = './resources/stopwords.txt' | STOPWORDS_FILE = './resources/stopwords.txt' | ||||
| SIMILARITY_THRESHOLD = 0.75 | 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(): | 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.info("Starting duplicate headline detection application") | ||||
| logger.debug("Source file configured at: '%s'", SOURCE_FILE) | logger.debug("Source file configured at: '%s'", SOURCE_FILE) | ||||
| logger.debug("Stopwords file configured at: '%s'", STOPWORDS_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) | logger.critical("Failed during headline preparation: %s", e, exc_info=True) | ||||
| return | 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__': | 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 | import logging | ||||
| from re import findall | |||||
| import re | |||||
| import requests | import requests | ||||
| from services.dates import parse_datetime | |||||
| from services.normalization import normalize_headline | from services.normalization import normalize_headline | ||||
| from structs.headline import Headline | from structs.headline import Headline | ||||
| @@ -9,6 +12,52 @@ logger = logging.getLogger(__name__) | |||||
| DEFAULT_TIMEOUT = 10 | DEFAULT_TIMEOUT = 10 | ||||
| MIN_HEADLINE_WORDS = 3 | 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): | def is_headline(text, stopwords=None): | ||||
| if not text or not isinstance(text, str): | 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) | logger.debug("Parsing HTML content from '%s' for headline candidates", source_url) | ||||
| try: | 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: | except Exception as e: | ||||
| logger.error("Regex extraction failed on content from '%s': %s", source_url, e, exc_info=True) | logger.error("Regex extraction failed on content from '%s': %s", source_url, e, exc_info=True) | ||||
| continue | continue | ||||
| source_headlines_count = 0 | 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() | cleaned_text = link_text.strip() | ||||
| if not cleaned_text: | if not cleaned_text: | ||||
| logger.debug("Skipping empty tag text at position %d from '%s'", tag_idx, source_url) | 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): | 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) | logger.debug("Skipping non-headline tag text at position %d from '%s': %r", tag_idx, source_url, cleaned_text) | ||||
| continue | 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: | try: | ||||
| normalized_headline = normalize_headline(cleaned_text, stopwords) | 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) | headlines.append(headline) | ||||
| source_headlines_count += 1 | source_headlines_count += 1 | ||||
| except Exception as e: | 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 | import logging | ||||
| from urllib.parse import urlparse | |||||
| from services.similarity import cosine_lists | from services.similarity import cosine_lists | ||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||
| @@ -7,12 +9,37 @@ logger = logging.getLogger(__name__) | |||||
| class Headline: | class Headline: | ||||
| display_text: str | display_text: str | ||||
| normalized_text: list[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.display_text = display_text | ||||
| self.normalized_text = normalized_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): | def compare_headlines(self, other_headline): | ||||
| if not isinstance(other_headline, 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() | |||||