Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

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