- 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>
150 lines
4.9 KiB
Python
150 lines
4.9 KiB
Python
"""
|
|
NOS.nl Dutch news RSS client.
|
|
|
|
Free RSS feeds from Netherlands public broadcaster.
|
|
https://nos.nl/feeds
|
|
|
|
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 NOSProvider(NewsProvider):
|
|
"""NOS.nl RSS feed implementation."""
|
|
|
|
# Available NOS RSS feeds
|
|
FEEDS: dict[str, str] = {
|
|
# News
|
|
"general": "https://feeds.nos.nl/nosnieuwsalgemeen",
|
|
"domestic": "https://feeds.nos.nl/nosnieuwsbinnenland",
|
|
"world": "https://feeds.nos.nl/nosnieuwsbuitenland",
|
|
"politics": "https://feeds.nos.nl/nosnieuwspolitiek",
|
|
"economy": "https://feeds.nos.nl/nosnieuwseconomie",
|
|
"remarkable": "https://feeds.nos.nl/nosnieuwsopmerkelijk",
|
|
"culture": "https://feeds.nos.nl/nosnieuwscultuurenmedia",
|
|
"tech": "https://feeds.nos.nl/nosnieuwstech",
|
|
# Sports
|
|
"sports": "https://feeds.nos.nl/nossportalgemeen",
|
|
"football": "https://feeds.nos.nl/nosvoetbal",
|
|
"cycling": "https://feeds.nos.nl/nossportwielrennen",
|
|
"skating": "https://feeds.nos.nl/nossportschaatsen",
|
|
"tennis": "https://feeds.nos.nl/nossporttennis",
|
|
"f1": "https://feeds.nos.nl/nossportformule1",
|
|
}
|
|
|
|
def __init__(self, timeout: int = 10):
|
|
"""
|
|
Initialize NOS 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 "nos"
|
|
|
|
@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 (general, domestic, world, 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
|
|
|
|
# Extract image URL if available
|
|
image_url = None
|
|
if hasattr(entry, 'media_content') and entry.media_content:
|
|
image_url = entry.media_content[0].get('url')
|
|
elif hasattr(entry, 'enclosures') and entry.enclosures:
|
|
for enc in entry.enclosures:
|
|
if enc.get('type', '').startswith('image/'):
|
|
image_url = enc.get('href')
|
|
break
|
|
|
|
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"NOS feed request failed for '{category}': {e}")
|
|
raise ValueError(f"Failed to fetch NOS feed: {e}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse NOS feed '{category}': {e}")
|
|
raise ValueError(f"Failed to parse NOS feed: {e}")
|