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.

normalization.py 7.3 KiB

3 päivää sitten
3 päivää sitten
3 päivää sitten
3 päivää sitten
3 päivää sitten
3 päivää sitten
3 päivää sitten
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import logging
  2. import re
  3. import string
  4. import tomllib
  5. logger = logging.getLogger(__name__)
  6. def get_aliases(path):
  7. """Load headline aliases from a TOML file with an ``[aliases]`` table.
  8. Each ``"full phrase" = "canonical"`` entry maps a multi-word name to a
  9. canonical short form (e.g. ``"federal reserve" = "fed"``). Keys are
  10. lowercased, punctuation-stripped, and whitespace-collapsed to match the
  11. text form that ``normalize_headline`` applies aliases to.
  12. """
  13. logger.info("Attempting to load aliases from file: '%s'", path)
  14. try:
  15. with open(path, 'rb') as aliases_file:
  16. data = tomllib.load(aliases_file)
  17. except FileNotFoundError:
  18. logger.error("Aliases file not found at path: '%s'", path, exc_info=True)
  19. raise
  20. except PermissionError:
  21. logger.error("Permission denied accessing aliases file: '%s'", path, exc_info=True)
  22. raise
  23. except tomllib.TOMLDecodeError as e:
  24. logger.error("Failed to parse aliases file '%s': %s", path, e, exc_info=True)
  25. raise
  26. except Exception as e:
  27. logger.error("Failed to load aliases from '%s': %s", path, e, exc_info=True)
  28. raise
  29. aliases = {}
  30. for phrase, canonical in data.get('aliases', {}).items():
  31. # Mirror normalize_headline's pre-alias text form: lowercase, then
  32. # strip punctuation (not space-replace), then collapse whitespace.
  33. phrase = str(phrase).strip().lower()
  34. for char in string.punctuation:
  35. phrase = phrase.replace(char, '')
  36. phrase = ' '.join(phrase.split())
  37. canonical = str(canonical).strip().lower()
  38. if phrase and canonical:
  39. aliases[phrase] = canonical
  40. logger.info("Successfully loaded %d aliases from '%s'", len(aliases), path)
  41. return aliases
  42. def get_stopwords(path):
  43. logger.info("Attempting to load stopwords from file: '%s'", path)
  44. try:
  45. with open(path, 'r', encoding='utf-8-sig') as stopwords_file:
  46. logger.debug("Opened stopwords file '%s'", path)
  47. lines = stopwords_file.read().splitlines()
  48. stopwords = set(lines)
  49. logger.info("Successfully loaded %d unique stopwords from '%s' (%d total lines)", len(stopwords), path, len(lines))
  50. return stopwords
  51. except FileNotFoundError:
  52. logger.error("Stopwords file not found at path: '%s'", path, exc_info=True)
  53. raise
  54. except PermissionError:
  55. logger.error("Permission denied accessing stopwords file: '%s'", path, exc_info=True)
  56. raise
  57. except Exception as e:
  58. logger.error("Failed to load stopwords from '%s': %s", path, e, exc_info=True)
  59. raise
  60. def get_excluded_phrases(path):
  61. """Load boilerplate link phrases from a file (one per line, '#' comments).
  62. These are strings that appear in navigation/footer links ("skip to content",
  63. "your privacy choices", "terms of service") and should never be treated as
  64. headlines. Returns a list of stripped, non-empty phrases in file order.
  65. """
  66. logger.info("Attempting to load excluded phrases from file: '%s'", path)
  67. try:
  68. with open(path, 'r', encoding='utf-8-sig') as phrases_file:
  69. lines = phrases_file.read().splitlines()
  70. except FileNotFoundError:
  71. logger.error("Excluded phrases file not found at path: '%s'", path, exc_info=True)
  72. raise
  73. except PermissionError:
  74. logger.error("Permission denied accessing excluded phrases file: '%s'", path, exc_info=True)
  75. raise
  76. except Exception as e:
  77. logger.error("Failed to load excluded phrases from '%s': %s", path, e, exc_info=True)
  78. raise
  79. phrases = []
  80. for line in lines:
  81. stripped = line.strip()
  82. if not stripped or stripped.startswith('#'):
  83. continue
  84. phrases.append(stripped)
  85. logger.info("Successfully loaded %d excluded phrases from '%s'", len(phrases), path)
  86. return phrases
  87. def canonicalize(text):
  88. """Lowercase, turn punctuation into spaces, and collapse whitespace.
  89. Produces a comparable form of free text for phrase matching — e.g.
  90. ``"Terms-of-Service."`` becomes ``"terms of service"``.
  91. """
  92. text = (text or '').lower()
  93. for char in string.punctuation:
  94. text = text.replace(char, ' ')
  95. return ' '.join(text.split())
  96. def is_excluded(text, excluded_phrases):
  97. """Return True if ``text`` contains an excluded phrase as a contiguous
  98. token subsequence (case-insensitive, punctuation-insensitive).
  99. Matching is sub-phrase aware, so the entry ``privacy choices`` also matches
  100. the link text "Your Privacy Choices" without needing a separate exact row.
  101. """
  102. if not excluded_phrases:
  103. return False
  104. tokens = canonicalize(text).split()
  105. if not tokens:
  106. return False
  107. for phrase in excluded_phrases:
  108. phrase_tokens = canonicalize(phrase).split()
  109. if not phrase_tokens:
  110. continue
  111. for start in range(len(tokens) - len(phrase_tokens) + 1):
  112. if tokens[start:start + len(phrase_tokens)] == phrase_tokens:
  113. logger.debug("Text excluded by phrase %r: %r", phrase, text)
  114. return True
  115. return False
  116. def remove_stopwords(text, stopwords):
  117. logger.debug("Removing stopwords from text (%d chars): %r (available stopwords: %d)", len(text), text, len(stopwords))
  118. # split() without arguments handles all whitespace (spaces, tabs, newlines)
  119. words = text.split()
  120. sentence_words = []
  121. for word in words:
  122. # Strip surrounding punctuation and lowercase for comparison
  123. cleaned_word = word.strip(string.punctuation).lower()
  124. if cleaned_word and cleaned_word not in stopwords:
  125. sentence_words.append(word)
  126. elif cleaned_word in stopwords:
  127. logger.debug("Word filtered out as stopword: %r (original: %r)", cleaned_word, word)
  128. else:
  129. logger.debug("Word dropped (empty after stripping punctuation): %r", word)
  130. logger.debug("Filtered words: input=%d words, output=%d words -> %s", len(words), len(sentence_words), sentence_words)
  131. return sentence_words
  132. def replace_aliases(text, aliases):
  133. """Swap full phrases for canonical forms, longest first, whole-word only.
  134. Longer phrases are applied before shorter ones so ``president of the united
  135. states`` becomes ``potus`` rather than leaving ``united states`` for the
  136. ``us`` rule to snag. ``\b`` boundaries prevent substring matches such as
  137. collapsing ``united statesman`` to ``usman``.
  138. """
  139. if not aliases:
  140. return text
  141. text = ' '.join(text.split())
  142. for phrase in sorted(aliases, key=len, reverse=True):
  143. pattern = re.compile(r'\b' + re.escape(phrase) + r'\b')
  144. text = pattern.sub(aliases[phrase], text)
  145. return text
  146. def normalize_headline(text, stopwords, aliases=None):
  147. logger.debug("Starting normalization of headline: %r", text)
  148. headline = text.strip().lower()
  149. punctuation = string.punctuation
  150. for char in punctuation:
  151. headline = headline.replace(char, '')
  152. logger.debug("Headline after lowercasing and punctuation removal: %r", headline)
  153. headline = replace_aliases(headline, aliases)
  154. logger.debug("Headline after replacing aliases: %r", headline)
  155. normalized = remove_stopwords(headline, stopwords)
  156. logger.debug("Completed normalization for %r -> %s", text, normalized)
  157. return normalized