Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

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