rmi-backend/app/routers/news.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

371 lines
13 KiB
Python

"""
News Router - THE Crypto News Aggregator Feed
=============================================
200+ sources. Real data only. No fake articles.
Endpoints:
GET /api/v1/news/feed - Full aggregated feed with filters
GET /api/v1/news/sources - Active sources list with counts
GET /api/v1/news/sentiment - Real-time sentiment overview
GET /api/v1/news/headlines - Top headlines only
GET /api/v1/news/stats - Source/category statistics
POST /api/v1/news/comment - Comment on article (social)
GET /api/v1/news/comments/:article_id - Get comments for article
"""
import asyncio
import hashlib
import logging
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from app.auth import require_public_profile
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/news", tags=["news"])
# ─── Models ───────────────────────────────────────────────────────
class NewsArticle(BaseModel):
id: str | None = None
title: str = ""
url: str = ""
description: str | None = None
source: str = ""
published_at: str | None = None
image_url: str | None = None
category: str | None = None
sentiment: str | None = None
kind: str | None = None
tier: str | None = None
reddit_score: int | None = None
reddit_comments: int | None = None
risk_score: float | None = None
token_address: str | None = None
chain: str | None = None
comment_count: int | None = 0
class NewsFeedResponse(BaseModel):
status: str = "success"
articles: list[NewsArticle] = []
total: int = 0
sources: list[str] = []
source_count: int = 0
sentiment_summary: dict[str, int] = {}
tiers: list[str] = []
cached: bool = False
fetched_at: str = ""
class CommentRequest(BaseModel):
article_id: str
author: str = "anon"
content: str
parent_id: str | None = None
class CommentResponse(BaseModel):
id: str
article_id: str
author: str
content: str
created_at: str
parent_id: str | None = None
likes: int = 0
# ─── In-memory cache ──────────────────────────────────────────────
_NEWS_CACHE = []
_CACHE_TS: datetime | None = None
_CACHE_TTL = timedelta(minutes=3)
# In-memory comments store (ephemeral - persists via Redis later)
_COMMENTS: dict[str, list[dict]] = {}
# ─── Helpers ──────────────────────────────────────────────────────
def _make_comment_id(article_id: str, content: str) -> str:
h = hashlib.md5(f"{article_id}:{content}:{datetime.now(UTC).isoformat()}".encode()).hexdigest()
return f"comment-{h[:12]}"
async def _refresh_cache(include_rss: bool, include_reddit: bool, include_internal: bool):
"""Background cache refresh task."""
global _NEWS_CACHE, _CACHE_TS
try:
from app.news_service import get_news_service
svc = get_news_service()
result = await svc.fetch_all(
limit=200,
include_rss=include_rss,
include_reddit=include_reddit,
include_internal=include_internal,
)
_NEWS_CACHE = result
_CACHE_TS = datetime.now(UTC)
logger.info(
f"News cache refreshed: {result.get('total', 0)} articles from {result.get('source_count', 0)} sources"
)
except Exception as e:
logger.warning(f"Background news refresh failed: {e}")
# ─── Main Feed ────────────────────────────────────────────────────
@router.get("/feed", response_model=NewsFeedResponse)
async def get_news_feed(
limit: int = Query(50, ge=1, le=200),
sentiment: str | None = Query(None, description="bullish, bearish, neutral, slightly_bullish, slightly_bearish"),
source: str | None = Query(None),
category: str | None = Query(None),
tier: str | None = Query(None, description="news, social, market, rmi, api"),
kind: str | None = Query(None, description="external, internal, social, api"),
include_rss: bool = Query(True),
include_reddit: bool = Query(True),
include_internal: bool = Query(True),
refresh: bool = Query(False, description="Force refresh, bypass cache"),
):
"""Full aggregated news feed from all sources."""
global _NEWS_CACHE, _CACHE_TS
# Return cache if fresh
if not refresh and _NEWS_CACHE and _CACHE_TS and (datetime.now(UTC) - _CACHE_TS) < _CACHE_TTL:
result = _NEWS_CACHE
else:
# Serve stale cache immediately while refreshing in background
if _NEWS_CACHE and not refresh:
result = _NEWS_CACHE
# Background refresh
asyncio.create_task(_refresh_cache(include_rss, include_reddit, include_internal))
else:
# First load or forced refresh - fetch inline but with limits
try:
from app.news_service import get_news_service
svc = get_news_service()
result = await svc.fetch_all(
limit=200,
include_rss=include_rss,
include_reddit=include_reddit,
include_internal=include_internal,
)
_NEWS_CACHE = result
_CACHE_TS = datetime.now(UTC)
except Exception as e:
logger.error(f"News fetch failed: {e}")
if _NEWS_CACHE:
result = _NEWS_CACHE
else:
raise HTTPException(status_code=500, detail=f"News aggregation failed: {e!s}") from e
articles = result.get("articles", [])
# Apply filters
if sentiment:
articles = [a for a in articles if a.get("sentiment", "").lower() == sentiment.lower()]
if source:
articles = [a for a in articles if source.lower() in a.get("source", "").lower()]
if category:
articles = [a for a in articles if a.get("category", "").lower() == category.lower()]
if tier:
articles = [a for a in articles if a.get("tier", "").lower() == tier.lower()]
if kind:
articles = [a for a in articles if a.get("kind", "").lower() == kind.lower()]
articles = articles[:limit]
# Enrich with comment counts
for a in articles:
aid = a.get("id", "")
a["comment_count"] = len(_COMMENTS.get(aid, []))
return NewsFeedResponse(
status="success",
articles=[NewsArticle(**a) for a in articles],
total=len(articles),
sources=result.get("sources", []),
source_count=result.get("source_count", 0),
sentiment_summary=result.get("sentiment_summary", {}),
tiers=result.get("tiers", []),
cached=bool(_NEWS_CACHE),
fetched_at=(_CACHE_TS or datetime.now(UTC)).isoformat(),
)
# ─── Headlines ────────────────────────────────────────────────────
@router.get("/headlines")
async def get_headlines(
count: int = Query(10, ge=1, le=50),
category: str | None = Query(None),
):
"""Top headlines only - fast, lightweight."""
feed = await get_news_feed(limit=count, category=category, include_reddit=False)
return {
"headlines": [
{
"title": a.title,
"url": a.url,
"source": a.source,
"published_at": a.published_at,
"category": a.category,
"sentiment": a.sentiment,
"kind": a.kind,
}
for a in feed.articles[:count]
],
"count": len(feed.articles[:count]),
}
# ─── Sources ──────────────────────────────────────────────────────
@router.get("/sources")
async def get_news_sources():
"""Get all active news sources."""
feed = await get_news_feed(limit=200, refresh=False)
sources = {}
for a in feed.articles:
src = a.source
sources[src] = sources.get(src, 0) + 1
return {
"sources": [
{
"name": name,
"article_count": count,
"tier": a.tier if hasattr(a, "tier") else "unknown",
}
for name, count in sorted(sources.items(), key=lambda x: -x[1])
for a in [next((art for art in feed.articles if art.source == name), None)]
],
"total_sources": len(sources),
}
# ─── Sentiment ────────────────────────────────────────────────────
@router.get("/sentiment")
async def get_sentiment():
"""Real-time sentiment overview from current news feed."""
feed = await get_news_feed(limit=200, refresh=False)
return {
"sentiment": feed.sentiment_summary,
"sample_size": feed.total,
"fetched_at": feed.fetched_at,
"trending": [a.title for a in feed.articles[:5] if a.sentiment in ("bullish", "bearish")],
}
# ─── Categories ───────────────────────────────────────────────────
@router.get("/categories")
async def get_categories():
"""Get all news categories with counts."""
feed = await get_news_feed(limit=200, refresh=False)
cats = {}
for a in feed.articles:
cat = a.category or "general"
cats[cat] = cats.get(cat, 0) + 1
return {
"categories": [{"name": n, "count": c} for n, c in sorted(cats.items(), key=lambda x: -x[1])],
}
# ─── Stats ────────────────────────────────────────────────────────
@router.get("/stats")
async def get_stats():
"""Aggregate statistics about the news pipeline."""
feed = await get_news_feed(limit=200, refresh=False)
source_counts = {}
tier_counts = {}
for a in feed.articles:
src = a.source or "unknown"
source_counts[src] = source_counts.get(src, 0) + 1
t = a.tier or "unknown"
tier_counts[t] = tier_counts.get(t, 0) + 1
return {
"total_articles": feed.total,
"total_sources": feed.source_count,
"sentiment": feed.sentiment_summary,
"source_breakdown": dict(sorted(source_counts.items(), key=lambda x: -x[1])[:20]),
"tier_breakdown": tier_counts,
"fetched_at": feed.fetched_at,
}
# ─── Comments / Social ────────────────────────────────────────────
@router.post("/comment", response_model=CommentResponse)
async def post_comment(req: CommentRequest, user: dict = Depends(require_public_profile)):
"""Post a comment on any news article. Requires public profile."""
if not req.article_id or not req.content.strip():
raise HTTPException(status_code=400, detail="article_id and content required")
comment = {
"id": _make_comment_id(req.article_id, req.content),
"article_id": req.article_id,
"author": user.get("display_name") or user.get("email", "anon")[:50],
"content": req.content[:2000],
"created_at": datetime.now(UTC).isoformat(),
"parent_id": req.parent_id,
"likes": 0,
}
if req.article_id not in _COMMENTS:
_COMMENTS[req.article_id] = []
_COMMENTS[req.article_id].append(comment)
return CommentResponse(**comment)
@router.get("/comments/{article_id}", response_model=list[CommentResponse])
async def get_comments(article_id: str):
"""Get comments for a specific article."""
comments = _COMMENTS.get(article_id, [])
return [CommentResponse(**c) for c in sorted(comments, key=lambda x: x["created_at"])]
# ─── Internal: for cron jobs to post scanner findings as news ─────
@router.post("/internal/scanner-alert")
async def post_scanner_alert(token_name: str, chain: str, risk_score: float, address: str, flags: str = ""):
"""Internal endpoint for cron jobs to inject scanner findings into news feed."""
global _NEWS_CACHE
content_hash = hashlib.md5(f"internal:{address}:{chain}".encode()).hexdigest()
article = {
"id": f"rmi-{content_hash[:12]}",
"title": f"RMI Scanner: {token_name} ({chain.upper()}) - Risk {risk_score}/100",
"url": f"https://rugmunch.io/scanner?address={address}&chain={chain}",
"description": f"Scanner detected {token_name} on {chain}. Risk: {risk_score}/100. Flags: {flags}. Address: {address}",
"source": "RMI Scanner",
"published_at": datetime.now(UTC).isoformat(),
"category": "security",
"sentiment": "bearish" if risk_score > 50 else "neutral",
"kind": "internal",
"tier": "rmi",
"risk_score": risk_score,
"token_address": address,
"chain": chain,
}
# Prepend to cache
if _NEWS_CACHE:
_NEWS_CACHE["articles"] = [article, *_NEWS_CACHE.get("articles", [])]
_NEWS_CACHE["total"] = len(_NEWS_CACHE["articles"])
return {"status": "injected", "id": article["id"]}