""" Aggregated news provider. Combines multiple news sources into a single chronologically-sorted stream. Source selection is driven by user preferences in the settings database. """ import asyncio import logging from datetime import datetime, timezone from .base import NewsProvider, NewsItem, NewsFeed from .nos import NOSProvider from .bbc import BBCProvider logger = logging.getLogger(__name__) # Registry of available news providers PROVIDER_REGISTRY: dict[str, type[NewsProvider]] = { "nos": NOSProvider, "bbc": BBCProvider, } class AggregatedNewsProvider: """ Aggregated news provider that combines multiple sources. Fetches from configured sources in parallel and merges results into a single chronologically-sorted stream. Only fetches from enabled categories per source. """ def __init__( self, sources: list[str], category_filters: dict[str, list[str]] | None = None, timeout: int = 10 ): """ Initialize aggregated provider. Args: sources: List of source names to aggregate (e.g., ["nos", "bbc"]) category_filters: Per-source enabled categories. Example: {"nos": ["general", "tech"], "bbc": ["top", "world"]} Empty list or missing entry = all categories allowed. timeout: HTTP request timeout in seconds """ self.sources = sources self.category_filters = category_filters or {} self.timeout = timeout self._providers: dict[str, NewsProvider] = {} # Initialize configured providers for source in sources: if source in PROVIDER_REGISTRY: self._providers[source] = PROVIDER_REGISTRY[source](timeout=timeout) else: logger.warning(f"Unknown news source '{source}' - skipping") def _is_category_enabled(self, source: str, category: str) -> bool: """Check if a category is enabled for a source.""" allowed = self.category_filters.get(source, []) # Empty list = all allowed if not allowed: return True return category in allowed def _get_enabled_categories(self, source: str) -> list[str]: """Get list of enabled categories for a source.""" provider = self._providers.get(source) if not provider: return [] allowed = self.category_filters.get(source, []) if not allowed: # All categories enabled return provider.available_categories # Filter to only enabled ones that exist return [c for c in allowed if c in provider.available_categories] @property def available_sources(self) -> list[str]: """List of initialized source names.""" return list(self._providers.keys()) @property def available_categories(self) -> dict[str, list[str]]: """Map of source -> available categories.""" return { name: provider.available_categories for name, provider in self._providers.items() } def _normalize_timestamp(self, item: NewsItem) -> datetime: """Get UTC timestamp for sorting, with fallback for missing timestamps.""" if item.published: # Ensure UTC if item.published.tzinfo is None: return item.published.replace(tzinfo=timezone.utc) return item.published.astimezone(timezone.utc) # Fallback: use current time (item will sort to top) return datetime.now(timezone.utc) async def get_feed( self, category: str = "general", limit: int = 20 ) -> NewsFeed: """ Get aggregated news feed from all sources. Args: category: Category to fetch. Maps to source-specific categories: - "general"/"top": general news from all sources - "world": international news - "tech": technology news - "business"/"economy": business/economy news - "politics": political news limit: Maximum total items to return (after merging) Returns: NewsFeed with merged, chronologically-sorted items """ # Map generic categories to source-specific ones category_map = { "nos": { "general": "general", "top": "general", "world": "world", "tech": "tech", "business": "economy", "economy": "economy", "politics": "politics", }, "bbc": { "general": "top", "top": "top", "world": "world", "tech": "tech", "business": "business", "economy": "business", "politics": "politics", }, } # Fetch from all sources in parallel async def fetch_source(name: str, provider: NewsProvider) -> list[NewsItem]: try: source_category = category_map.get(name, {}).get(category, category) if source_category not in provider.available_categories: logger.debug(f"Category '{category}' not available for {name}") return [] # Check if category is enabled for this source if not self._is_category_enabled(name, source_category): logger.debug(f"Category '{source_category}' disabled for {name}") return [] feed = await provider.get_feed(source_category, limit=limit) return feed.items except Exception as e: logger.error(f"Failed to fetch from {name}: {e}") return [] tasks = [ fetch_source(name, provider) for name, provider in self._providers.items() ] results = await asyncio.gather(*tasks) # Merge all items all_items: list[NewsItem] = [] for items in results: all_items.extend(items) # Sort by timestamp (newest first) all_items.sort(key=self._normalize_timestamp, reverse=True) # Apply limit all_items = all_items[:limit] return NewsFeed( source="aggregated", category=category, items=all_items, fetched_at=datetime.now(timezone.utc) ) async def get_headlines( self, categories: list[str] | None = None, limit: int = 10 ) -> NewsFeed: """ Get headlines from multiple categories, merged into one feed. Args: categories: Categories to fetch. If None, fetches from all enabled categories across all sources. limit: Maximum total items to return Returns: NewsFeed with merged headlines from all categories """ if categories is None: # Collect all enabled categories across sources all_categories: set[str] = set() for source in self._providers: all_categories.update(self._get_enabled_categories(source)) categories = list(all_categories) if all_categories else ["general"] # Fetch all categories tasks = [self.get_feed(cat, limit=limit) for cat in categories] feeds = await asyncio.gather(*tasks) # Merge and deduplicate by URL seen_urls: set[str] = set() all_items: list[NewsItem] = [] for feed in feeds: for item in feed.items: if item.url not in seen_urls: seen_urls.add(item.url) all_items.append(item) # Sort by timestamp all_items.sort(key=self._normalize_timestamp, reverse=True) return NewsFeed( source="aggregated", category=",".join(categories), items=all_items[:limit], fetched_at=datetime.now(timezone.utc) ) async def close(self): """Close all provider HTTP clients.""" for provider in self._providers.values(): await provider.close()