- Add central settings database client (system_settings on postgres-shared) - User-scoped settings with global fallback - API config storage with enabled/disabled toggle - Per-source category filtering for news - Add modular external API providers in src/apis/: - OpenMeteoProvider: weather with geocoding (free, no key) - NOSProvider: Dutch news RSS feeds - BBCProvider: English news RSS feeds - AggregatedNewsProvider: merges sources with category filtering - AlphaVantageProvider: financial quotes (API key from settings DB) - Add provider dependencies and lifecycle management - Add requirements-dev.txt with pip-audit for security auditing - Add MEMORY_REMEMBER_PLAN.md documenting volatile/document memory architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
153 lines
5.3 KiB
Python
153 lines
5.3 KiB
Python
"""
|
|
BBC News RSS client.
|
|
|
|
Free RSS feeds from BBC News.
|
|
https://www.bbc.com/news/10628494 (RSS feed directory)
|
|
|
|
No API key required.
|
|
"""
|
|
|
|
import httpx
|
|
import feedparser
|
|
import logging
|
|
from datetime import datetime
|
|
from email.utils import parsedate_to_datetime
|
|
from typing import Optional
|
|
|
|
from .base import NewsProvider, NewsItem, NewsFeed
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class BBCProvider(NewsProvider):
|
|
"""BBC News RSS feed implementation."""
|
|
|
|
# Available BBC RSS feeds
|
|
FEEDS: dict[str, str] = {
|
|
# News
|
|
"top": "https://feeds.bbci.co.uk/news/rss.xml",
|
|
"world": "https://feeds.bbci.co.uk/news/world/rss.xml",
|
|
"uk": "https://feeds.bbci.co.uk/news/uk/rss.xml",
|
|
"business": "https://feeds.bbci.co.uk/news/business/rss.xml",
|
|
"politics": "https://feeds.bbci.co.uk/news/politics/rss.xml",
|
|
"health": "https://feeds.bbci.co.uk/news/health/rss.xml",
|
|
"education": "https://feeds.bbci.co.uk/news/education/rss.xml",
|
|
"science": "https://feeds.bbci.co.uk/news/science_and_environment/rss.xml",
|
|
"tech": "https://feeds.bbci.co.uk/news/technology/rss.xml",
|
|
"entertainment": "https://feeds.bbci.co.uk/news/entertainment_and_arts/rss.xml",
|
|
"asia": "https://feeds.bbci.co.uk/news/world/asia/rss.xml",
|
|
"europe": "https://feeds.bbci.co.uk/news/world/europe/rss.xml",
|
|
"africa": "https://feeds.bbci.co.uk/news/world/africa/rss.xml",
|
|
# Sports
|
|
"sports": "https://feeds.bbci.co.uk/sport/rss.xml",
|
|
"football": "https://feeds.bbci.co.uk/sport/football/rss.xml",
|
|
"cricket": "https://feeds.bbci.co.uk/sport/cricket/rss.xml",
|
|
"tennis": "https://feeds.bbci.co.uk/sport/tennis/rss.xml",
|
|
"rugby": "https://feeds.bbci.co.uk/sport/rugby-union/rss.xml",
|
|
"f1": "https://feeds.bbci.co.uk/sport/motorsport/rss.xml",
|
|
"golf": "https://feeds.bbci.co.uk/sport/golf/rss.xml",
|
|
}
|
|
|
|
def __init__(self, timeout: int = 10):
|
|
"""
|
|
Initialize BBC RSS client.
|
|
|
|
Args:
|
|
timeout: HTTP request timeout in seconds
|
|
"""
|
|
self.timeout = timeout
|
|
self._client: Optional[httpx.AsyncClient] = None
|
|
|
|
@property
|
|
def client(self) -> httpx.AsyncClient:
|
|
"""Lazy-initialize HTTP client."""
|
|
if self._client is None or self._client.is_closed:
|
|
self._client = httpx.AsyncClient(timeout=self.timeout)
|
|
return self._client
|
|
|
|
async def close(self):
|
|
"""Close HTTP client."""
|
|
if self._client and not self._client.is_closed:
|
|
await self._client.aclose()
|
|
self._client = None
|
|
|
|
@property
|
|
def source_name(self) -> str:
|
|
"""Provider name."""
|
|
return "bbc"
|
|
|
|
@property
|
|
def available_categories(self) -> list[str]:
|
|
"""List of available category keys."""
|
|
return list(self.FEEDS.keys())
|
|
|
|
async def get_feed(self, category: str, limit: int = 10) -> NewsFeed:
|
|
"""
|
|
Get news feed for a category.
|
|
|
|
Args:
|
|
category: Feed category (top, world, uk, business, etc.)
|
|
limit: Maximum number of items to return
|
|
|
|
Returns:
|
|
NewsFeed with standardized news items
|
|
|
|
Raises:
|
|
ValueError: If category is not available
|
|
"""
|
|
if category not in self.FEEDS:
|
|
raise ValueError(
|
|
f"Unknown category '{category}'. "
|
|
f"Available: {', '.join(self.available_categories)}"
|
|
)
|
|
|
|
feed_url = self.FEEDS[category]
|
|
|
|
try:
|
|
response = await self.client.get(feed_url)
|
|
response.raise_for_status()
|
|
|
|
# Parse RSS feed
|
|
feed = feedparser.parse(response.text)
|
|
|
|
items = []
|
|
for entry in feed.entries[:limit]:
|
|
# Parse publication date
|
|
published = None
|
|
if hasattr(entry, 'published'):
|
|
try:
|
|
published = parsedate_to_datetime(entry.published)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
# BBC uses media:thumbnail for images
|
|
image_url = None
|
|
if hasattr(entry, 'media_thumbnail') and entry.media_thumbnail:
|
|
image_url = entry.media_thumbnail[0].get('url')
|
|
elif hasattr(entry, 'media_content') and entry.media_content:
|
|
image_url = entry.media_content[0].get('url')
|
|
|
|
items.append(NewsItem(
|
|
title=entry.get('title', 'No title'),
|
|
description=entry.get('summary') or entry.get('description'),
|
|
url=entry.get('link', ''),
|
|
published=published,
|
|
source=self.source_name,
|
|
category=category,
|
|
image_url=image_url
|
|
))
|
|
|
|
return NewsFeed(
|
|
source=self.source_name,
|
|
category=category,
|
|
items=items,
|
|
fetched_at=datetime.now()
|
|
)
|
|
|
|
except httpx.HTTPError as e:
|
|
logger.error(f"BBC feed request failed for '{category}': {e}")
|
|
raise ValueError(f"Failed to fetch BBC feed: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse BBC feed '{category}': {e}")
|
|
raise ValueError(f"Failed to parse BBC feed: {e}")
|