You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

136 lines
5.6 KiB

  1. import argparse
  2. import logging
  3. from datetime import date
  4. from services.dates import filter_by_date_window, parse_date_arg
  5. from services.headlines import prepare_headlines
  6. from services.normalization import get_excluded_phrases, get_stopwords
  7. from services.sources import get_sources
  8. from services.stories import cluster_stories
  9. logging.basicConfig(
  10. level=logging.INFO,
  11. format='%(asctime)s [%(levelname)s] [%(name)s]: %(message)s'
  12. )
  13. logger = logging.getLogger(__name__)
  14. SOURCE_FILE = './resources/sources.txt'
  15. STOPWORDS_FILE = './resources/stopwords.txt'
  16. EXCLUDED_PHRASES_FILE = './resources/excluded_phrases.txt'
  17. SIMILARITY_THRESHOLD = 0.68
  18. MIN_SOURCES = 3
  19. # Used so stories without a parsable date sort behind dated ones.
  20. _MIN_DATE = date.min
  21. def _describe_window(since, until):
  22. if since and until:
  23. return " (published %s to %s)" % (since, until)
  24. if since:
  25. return " (published on or after %s)" % since
  26. if until:
  27. return " (published on or before %s)" % until
  28. return ""
  29. def parse_args():
  30. parser = argparse.ArgumentParser(
  31. description="Cluster headlines across news sources and print stories "
  32. "covered by a minimum number of distinct outlets."
  33. )
  34. parser.add_argument('--min-sources', type=int, default=MIN_SOURCES, metavar='N',
  35. help="Only output stories reported by at least N distinct sources "
  36. "(default: %(default)s).")
  37. parser.add_argument('--threshold', type=float, default=SIMILARITY_THRESHOLD, metavar='T',
  38. help="Cosine similarity used to consider two headlines the same story "
  39. "(default: %(default)s).")
  40. parser.add_argument('--since', type=parse_date_arg, metavar='YYYY-MM-DD',
  41. help="Only consider headlines published on or after this date.")
  42. parser.add_argument('--until', type=parse_date_arg, metavar='YYYY-MM-DD',
  43. help="Only consider headlines published on or before this date.")
  44. parser.add_argument('--verbose', action='store_true',
  45. help='Enable debug logging.')
  46. return parser.parse_args()
  47. def main():
  48. args = parse_args()
  49. if args.verbose:
  50. logging.getLogger().setLevel(logging.DEBUG)
  51. if args.min_sources < 1:
  52. logger.error("--min-sources must be at least 1 (got %d).", args.min_sources)
  53. return
  54. if args.since and args.until and args.since > args.until:
  55. logger.error("--since (%s) is after --until (%s).", args.since, args.until)
  56. return
  57. logger.info("Starting duplicate headline detection application")
  58. logger.debug("Source file configured at: '%s'", SOURCE_FILE)
  59. logger.debug("Stopwords file configured at: '%s'", STOPWORDS_FILE)
  60. try:
  61. logger.info("Loading news sources from '%s'", SOURCE_FILE)
  62. sources = get_sources(SOURCE_FILE)
  63. logger.info("Loaded %d sources successfully", len(sources) if sources else 0)
  64. except Exception as e:
  65. logger.critical("Failed to load sources from '%s': %s", SOURCE_FILE, e, exc_info=True)
  66. return
  67. try:
  68. logger.info("Loading stopwords from '%s'", STOPWORDS_FILE)
  69. stopwords = get_stopwords(STOPWORDS_FILE)
  70. logger.info("Loaded %d stopwords successfully", len(stopwords) if stopwords else 0)
  71. except Exception as e:
  72. logger.critical("Failed to load stopwords from '%s': %s", STOPWORDS_FILE, e, exc_info=True)
  73. return
  74. try:
  75. logger.info("Loading excluded phrases from '%s'", EXCLUDED_PHRASES_FILE)
  76. excluded_phrases = get_excluded_phrases(EXCLUDED_PHRASES_FILE)
  77. logger.info("Loaded %d excluded phrases successfully", len(excluded_phrases))
  78. except Exception as e:
  79. logger.critical("Failed to load excluded phrases from '%s': %s", EXCLUDED_PHRASES_FILE, e, exc_info=True)
  80. return
  81. try:
  82. logger.info("Fetching and preparing headlines from %d sources", len(sources))
  83. headlines = prepare_headlines(sources, stopwords, excluded_phrases=excluded_phrases)
  84. logger.info("Total prepared headlines available for comparison: %d", len(headlines))
  85. except Exception as e:
  86. logger.critical("Failed during headline preparation: %s", e, exc_info=True)
  87. return
  88. if args.since is not None or args.until is not None:
  89. headlines, dropped_undated = filter_by_date_window(headlines, args.since, args.until)
  90. logger.info("Date window applied: %d headlines remain (%d dropped with no parseable date).",
  91. len(headlines), dropped_undated)
  92. logger.info("Clustering headlines into stories (similarity threshold: %.2f)", args.threshold)
  93. stories = cluster_stories(headlines, args.threshold)
  94. qualifying = [s for s in stories if s.source_count >= args.min_sources]
  95. qualifying.sort(key=lambda s: (s.source_count, s.latest_date or _MIN_DATE), reverse=True)
  96. print()
  97. window = _describe_window(args.since, args.until)
  98. print("News stories covered by at least %d distinct sources" % args.min_sources + window)
  99. print("=" * 60)
  100. if not qualifying:
  101. print("No stories met the minimum number of sources.")
  102. else:
  103. for story in qualifying:
  104. date_label = story.latest_date.isoformat() if story.latest_date else "no date"
  105. print(f"\n[{story.source_count} source(s)] {story.representative} ({date_label})")
  106. print(" " + ", ".join(story.sources))
  107. logger.info(
  108. "Story clustering complete: %d total stories, %d with >= %d distinct sources.",
  109. len(stories), len(qualifying), args.min_sources,
  110. )
  111. if __name__ == '__main__':
  112. main()