Переглянути джерело

Group headlines into stories and filter by source count and date

- 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 artifacts
master
Jared Bell 3 дні тому
джерело
коміт
8f2d8430d6
18 змінених файлів з 548 додано та 34 видалено
  1. +2
    -0
      .gitignore
  2. BIN
      __pycache__/main.cpython-314.pyc
  3. +80
    -25
      main.py
  4. BIN
      services/__pycache__/headlines.cpython-314.pyc
  5. BIN
      services/__pycache__/normalization.cpython-314.pyc
  6. BIN
      services/__pycache__/similarity.cpython-314.pyc
  7. BIN
      services/__pycache__/sources.cpython-314.pyc
  8. +95
    -0
      services/dates.py
  9. +55
    -6
      services/headlines.py
  10. +55
    -0
      services/stories.py
  11. BIN
      structs/__pycache__/headline.cpython-314.pyc
  12. +30
    -3
      structs/headline.py
  13. +40
    -0
      structs/story.py
  14. BIN
      tests/__pycache__/__init__.cpython-314.pyc
  15. BIN
      tests/__pycache__/test_headlines.cpython-314-pytest-9.0.3.pyc
  16. BIN
      tests/__pycache__/test_headlines.cpython-314.pyc
  17. +117
    -0
      tests/test_dates.py
  18. +74
    -0
      tests/test_stories.py

+ 2
- 0
.gitignore Переглянути файл

@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]

BIN
__pycache__/main.cpython-314.pyc Переглянути файл


+ 80
- 25
main.py Переглянути файл

@@ -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()

BIN
services/__pycache__/headlines.cpython-314.pyc Переглянути файл


BIN
services/__pycache__/normalization.cpython-314.pyc Переглянути файл


BIN
services/__pycache__/similarity.cpython-314.pyc Переглянути файл


BIN
services/__pycache__/sources.cpython-314.pyc Переглянути файл


+ 95
- 0
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

+ 55
- 6
services/headlines.py Переглянути файл

@@ -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:


+ 55
- 0
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

BIN
structs/__pycache__/headline.cpython-314.pyc Переглянути файл


+ 30
- 3
structs/headline.py Переглянути файл

@@ -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):


+ 40
- 0
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

BIN
tests/__pycache__/__init__.cpython-314.pyc Переглянути файл


BIN
tests/__pycache__/test_headlines.cpython-314-pytest-9.0.3.pyc Переглянути файл


BIN
tests/__pycache__/test_headlines.cpython-314.pyc Переглянути файл


+ 117
- 0
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 = (
"<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()

+ 74
- 0
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()

Завантаження…
Відмінити
Зберегти