import logging import re import string import tomllib logger = logging.getLogger(__name__) def get_aliases(path): """Load headline aliases from a TOML file with an ``[aliases]`` table. Each ``"full phrase" = "canonical"`` entry maps a multi-word name to a canonical short form (e.g. ``"federal reserve" = "fed"``). Keys are lowercased, punctuation-stripped, and whitespace-collapsed to match the text form that ``normalize_headline`` applies aliases to. """ logger.info("Attempting to load aliases from file: '%s'", path) try: with open(path, 'rb') as aliases_file: data = tomllib.load(aliases_file) except FileNotFoundError: logger.error("Aliases file not found at path: '%s'", path, exc_info=True) raise except PermissionError: logger.error("Permission denied accessing aliases file: '%s'", path, exc_info=True) raise except tomllib.TOMLDecodeError as e: logger.error("Failed to parse aliases file '%s': %s", path, e, exc_info=True) raise except Exception as e: logger.error("Failed to load aliases from '%s': %s", path, e, exc_info=True) raise aliases = {} for phrase, canonical in data.get('aliases', {}).items(): # Mirror normalize_headline's pre-alias text form: lowercase, then # strip punctuation (not space-replace), then collapse whitespace. phrase = str(phrase).strip().lower() for char in string.punctuation: phrase = phrase.replace(char, '') phrase = ' '.join(phrase.split()) canonical = str(canonical).strip().lower() if phrase and canonical: aliases[phrase] = canonical logger.info("Successfully loaded %d aliases from '%s'", len(aliases), path) return aliases def get_stopwords(path): logger.info("Attempting to load stopwords from file: '%s'", path) try: with open(path, 'r', encoding='utf-8-sig') as stopwords_file: logger.debug("Opened stopwords file '%s'", path) lines = stopwords_file.read().splitlines() stopwords = set(lines) logger.info("Successfully loaded %d unique stopwords from '%s' (%d total lines)", len(stopwords), path, len(lines)) return stopwords except FileNotFoundError: logger.error("Stopwords file not found at path: '%s'", path, exc_info=True) raise except PermissionError: logger.error("Permission denied accessing stopwords file: '%s'", path, exc_info=True) raise except Exception as e: logger.error("Failed to load stopwords from '%s': %s", path, e, exc_info=True) raise def get_excluded_phrases(path): """Load boilerplate link phrases from a file (one per line, '#' comments). These are strings that appear in navigation/footer links ("skip to content", "your privacy choices", "terms of service") and should never be treated as headlines. Returns a list of stripped, non-empty phrases in file order. """ logger.info("Attempting to load excluded phrases from file: '%s'", path) try: with open(path, 'r', encoding='utf-8-sig') as phrases_file: lines = phrases_file.read().splitlines() except FileNotFoundError: logger.error("Excluded phrases file not found at path: '%s'", path, exc_info=True) raise except PermissionError: logger.error("Permission denied accessing excluded phrases file: '%s'", path, exc_info=True) raise except Exception as e: logger.error("Failed to load excluded phrases from '%s': %s", path, e, exc_info=True) raise phrases = [] for line in lines: stripped = line.strip() if not stripped or stripped.startswith('#'): continue phrases.append(stripped) logger.info("Successfully loaded %d excluded phrases from '%s'", len(phrases), path) return phrases def canonicalize(text): """Lowercase, turn punctuation into spaces, and collapse whitespace. Produces a comparable form of free text for phrase matching — e.g. ``"Terms-of-Service."`` becomes ``"terms of service"``. """ text = (text or '').lower() for char in string.punctuation: text = text.replace(char, ' ') return ' '.join(text.split()) def is_excluded(text, excluded_phrases): """Return True if ``text`` contains an excluded phrase as a contiguous token subsequence (case-insensitive, punctuation-insensitive). Matching is sub-phrase aware, so the entry ``privacy choices`` also matches the link text "Your Privacy Choices" without needing a separate exact row. """ if not excluded_phrases: return False tokens = canonicalize(text).split() if not tokens: return False for phrase in excluded_phrases: phrase_tokens = canonicalize(phrase).split() if not phrase_tokens: continue for start in range(len(tokens) - len(phrase_tokens) + 1): if tokens[start:start + len(phrase_tokens)] == phrase_tokens: logger.debug("Text excluded by phrase %r: %r", phrase, text) return True return False def remove_stopwords(text, stopwords): logger.debug("Removing stopwords from text (%d chars): %r (available stopwords: %d)", len(text), text, len(stopwords)) # split() without arguments handles all whitespace (spaces, tabs, newlines) words = text.split() sentence_words = [] for word in words: # Strip surrounding punctuation and lowercase for comparison cleaned_word = word.strip(string.punctuation).lower() if cleaned_word and cleaned_word not in stopwords: sentence_words.append(word) elif cleaned_word in stopwords: logger.debug("Word filtered out as stopword: %r (original: %r)", cleaned_word, word) else: logger.debug("Word dropped (empty after stripping punctuation): %r", word) logger.debug("Filtered words: input=%d words, output=%d words -> %s", len(words), len(sentence_words), sentence_words) return sentence_words def replace_aliases(text, aliases): """Swap full phrases for canonical forms, longest first, whole-word only. Longer phrases are applied before shorter ones so ``president of the united states`` becomes ``potus`` rather than leaving ``united states`` for the ``us`` rule to snag. ``\b`` boundaries prevent substring matches such as collapsing ``united statesman`` to ``usman``. """ if not aliases: return text text = ' '.join(text.split()) for phrase in sorted(aliases, key=len, reverse=True): pattern = re.compile(r'\b' + re.escape(phrase) + r'\b') text = pattern.sub(aliases[phrase], text) return text def normalize_headline(text, stopwords, aliases=None): logger.debug("Starting normalization of headline: %r", text) headline = text.strip().lower() punctuation = string.punctuation for char in punctuation: headline = headline.replace(char, '') logger.debug("Headline after lowercasing and punctuation removal: %r", headline) headline = replace_aliases(headline, aliases) logger.debug("Headline after replacing aliases: %r", headline) normalized = remove_stopwords(headline, stopwords) logger.debug("Completed normalization for %r -> %s", text, normalized) return normalized