Files
library-desk/src/apis/bbc.py
T
jpmschweitzerandClaude Opus 4.5 5d4a8dba95 feat: add external API providers and central settings database
- 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>
2025-12-26 12:07:58 +01:00

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}")