|
|
|
|
|
"""
|
|
|
Cryptocurrency News API Client - REAL DATA ONLY
|
|
|
Fetches real news from NewsAPI, CryptoPanic, and RSS feeds
|
|
|
NO MOCK DATA - All news from real sources
|
|
|
"""
|
|
|
|
|
|
import httpx
|
|
|
import logging
|
|
|
import os
|
|
|
import hashlib
|
|
|
import feedparser
|
|
|
from typing import Dict, Any, List, Optional
|
|
|
from datetime import datetime
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
class CryptoNewsClient:
|
|
|
"""
|
|
|
Real Cryptocurrency News API Client
|
|
|
Aggregates news from multiple real sources
|
|
|
"""
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.newsapi_key = os.getenv("NEWSAPI_KEY", "")
|
|
|
self.newsapi_url = "https://newsapi.org/v2"
|
|
|
|
|
|
|
|
|
self.cryptopanic_token = os.getenv("CRYPTOPANIC_TOKEN", "")
|
|
|
self.cryptopanic_url = "https://cryptopanic.com/api/v1"
|
|
|
|
|
|
|
|
|
self.rss_feeds = {
|
|
|
"coindesk": "https://www.coindesk.com/arc/outboundfeeds/rss/",
|
|
|
"cointelegraph": "https://cointelegraph.com/rss",
|
|
|
"decrypt": "https://decrypt.co/feed",
|
|
|
"bitcoinist": "https://bitcoinist.com/feed/",
|
|
|
"cryptoslate": "https://cryptoslate.com/feed/"
|
|
|
}
|
|
|
|
|
|
self.timeout = 15.0
|
|
|
|
|
|
async def get_latest_news(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
|
"""
|
|
|
Get REAL latest cryptocurrency news
|
|
|
Tries multiple sources with fallback
|
|
|
|
|
|
Returns:
|
|
|
List of real news articles
|
|
|
"""
|
|
|
articles = []
|
|
|
|
|
|
|
|
|
if self.newsapi_key:
|
|
|
try:
|
|
|
newsapi_articles = await self._fetch_from_newsapi(limit=limit)
|
|
|
articles.extend(newsapi_articles)
|
|
|
|
|
|
if len(articles) >= limit:
|
|
|
logger.info(f"✅ NewsAPI: Fetched {len(articles)} real articles")
|
|
|
return articles[:limit]
|
|
|
except Exception as e:
|
|
|
logger.warning(f"⚠️ NewsAPI failed: {e}")
|
|
|
|
|
|
|
|
|
if self.cryptopanic_token and len(articles) < limit:
|
|
|
try:
|
|
|
cryptopanic_articles = await self._fetch_from_cryptopanic(
|
|
|
limit=limit - len(articles)
|
|
|
)
|
|
|
articles.extend(cryptopanic_articles)
|
|
|
|
|
|
if len(articles) >= limit:
|
|
|
logger.info(
|
|
|
f"✅ CryptoPanic: Fetched {len(articles)} real articles"
|
|
|
)
|
|
|
return articles[:limit]
|
|
|
except Exception as e:
|
|
|
logger.warning(f"⚠️ CryptoPanic failed: {e}")
|
|
|
|
|
|
|
|
|
if len(articles) < limit:
|
|
|
try:
|
|
|
rss_articles = await self._fetch_from_rss_feeds(
|
|
|
limit=limit - len(articles)
|
|
|
)
|
|
|
articles.extend(rss_articles)
|
|
|
|
|
|
logger.info(f"✅ RSS Feeds: Fetched {len(articles)} real articles")
|
|
|
except Exception as e:
|
|
|
logger.warning(f"⚠️ RSS feeds failed: {e}")
|
|
|
|
|
|
|
|
|
if len(articles) == 0:
|
|
|
raise HTTPException(
|
|
|
status_code=503,
|
|
|
detail="All news sources temporarily unavailable"
|
|
|
)
|
|
|
|
|
|
logger.info(
|
|
|
f"✅ Successfully fetched {len(articles)} real news articles "
|
|
|
f"from multiple sources"
|
|
|
)
|
|
|
return articles[:limit]
|
|
|
|
|
|
async def _fetch_from_newsapi(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
|
"""Fetch REAL news from NewsAPI"""
|
|
|
try:
|
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
|
response = await client.get(
|
|
|
f"{self.newsapi_url}/everything",
|
|
|
params={
|
|
|
"q": "cryptocurrency OR bitcoin OR ethereum OR crypto",
|
|
|
"apiKey": self.newsapi_key,
|
|
|
"language": "en",
|
|
|
"sortBy": "publishedAt",
|
|
|
"pageSize": min(limit, 100)
|
|
|
}
|
|
|
)
|
|
|
response.raise_for_status()
|
|
|
data = response.json()
|
|
|
|
|
|
articles = []
|
|
|
for article in data.get("articles", []):
|
|
|
|
|
|
published_at = article.get("publishedAt", "")
|
|
|
try:
|
|
|
dt = datetime.fromisoformat(
|
|
|
published_at.replace("Z", "+00:00")
|
|
|
)
|
|
|
timestamp = int(dt.timestamp() * 1000)
|
|
|
except:
|
|
|
timestamp = int(datetime.utcnow().timestamp() * 1000)
|
|
|
|
|
|
articles.append({
|
|
|
"title": article.get("title", ""),
|
|
|
"description": article.get("description", ""),
|
|
|
"url": article.get("url", ""),
|
|
|
"source": article.get("source", {}).get("name", "NewsAPI"),
|
|
|
"timestamp": timestamp,
|
|
|
"author": article.get("author"),
|
|
|
"imageUrl": article.get("urlToImage")
|
|
|
})
|
|
|
|
|
|
logger.info(f"✅ NewsAPI: Fetched {len(articles)} articles")
|
|
|
return articles
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ NewsAPI failed: {e}")
|
|
|
raise
|
|
|
|
|
|
async def _fetch_from_cryptopanic(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
|
"""Fetch REAL news from CryptoPanic"""
|
|
|
try:
|
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
|
response = await client.get(
|
|
|
f"{self.cryptopanic_url}/posts/",
|
|
|
params={
|
|
|
"auth_token": self.cryptopanic_token,
|
|
|
"public": "true",
|
|
|
"filter": "hot"
|
|
|
}
|
|
|
)
|
|
|
response.raise_for_status()
|
|
|
data = response.json()
|
|
|
|
|
|
articles = []
|
|
|
for post in data.get("results", [])[:limit]:
|
|
|
|
|
|
created_at = post.get("created_at", "")
|
|
|
try:
|
|
|
dt = datetime.fromisoformat(
|
|
|
created_at.replace("Z", "+00:00")
|
|
|
)
|
|
|
timestamp = int(dt.timestamp() * 1000)
|
|
|
except:
|
|
|
timestamp = int(datetime.utcnow().timestamp() * 1000)
|
|
|
|
|
|
articles.append({
|
|
|
"title": post.get("title", ""),
|
|
|
"description": post.get("title", ""),
|
|
|
"url": post.get("url", ""),
|
|
|
"source": post.get("source", {}).get("title", "CryptoPanic"),
|
|
|
"timestamp": timestamp
|
|
|
})
|
|
|
|
|
|
logger.info(f"✅ CryptoPanic: Fetched {len(articles)} articles")
|
|
|
return articles
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ CryptoPanic failed: {e}")
|
|
|
raise
|
|
|
|
|
|
async def _fetch_from_rss_feeds(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
|
"""Fetch REAL news from RSS feeds"""
|
|
|
articles = []
|
|
|
successful_sources = 0
|
|
|
|
|
|
for source_name, feed_url in self.rss_feeds.items():
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
|
|
|
response = await client.get(feed_url)
|
|
|
response.raise_for_status()
|
|
|
|
|
|
|
|
|
feed = feedparser.parse(response.text)
|
|
|
|
|
|
if feed.bozo and feed.bozo_exception:
|
|
|
logger.warning(f"⚠️ RSS ({source_name}): Feed parsing warning: {feed.bozo_exception}")
|
|
|
|
|
|
if not feed.entries:
|
|
|
logger.warning(f"⚠️ RSS ({source_name}): No entries found")
|
|
|
continue
|
|
|
|
|
|
for entry in feed.entries[:limit]:
|
|
|
|
|
|
try:
|
|
|
if hasattr(entry, "published_parsed") and entry.published_parsed:
|
|
|
dt = datetime(*entry.published_parsed[:6])
|
|
|
elif hasattr(entry, "updated_parsed") and entry.updated_parsed:
|
|
|
dt = datetime(*entry.updated_parsed[:6])
|
|
|
else:
|
|
|
dt = datetime.utcnow()
|
|
|
|
|
|
timestamp = int(dt.timestamp() * 1000)
|
|
|
except Exception as ts_error:
|
|
|
logger.debug(f"Timestamp parsing failed for {source_name}: {ts_error}")
|
|
|
timestamp = int(datetime.utcnow().timestamp() * 1000)
|
|
|
|
|
|
|
|
|
description = ""
|
|
|
if hasattr(entry, "summary"):
|
|
|
description = entry.summary[:300]
|
|
|
elif hasattr(entry, "description"):
|
|
|
description = entry.description[:300]
|
|
|
|
|
|
articles.append({
|
|
|
"title": entry.get("title", "Untitled"),
|
|
|
"description": description,
|
|
|
"url": entry.get("link", ""),
|
|
|
"source": source_name.title(),
|
|
|
"timestamp": timestamp
|
|
|
})
|
|
|
|
|
|
successful_sources += 1
|
|
|
logger.info(
|
|
|
f"✅ RSS ({source_name}): Fetched {len(feed.entries)} articles"
|
|
|
)
|
|
|
|
|
|
if len(articles) >= limit:
|
|
|
break
|
|
|
|
|
|
except httpx.HTTPError as e:
|
|
|
logger.warning(f"⚠️ RSS feed {source_name} HTTP error: {e}")
|
|
|
continue
|
|
|
except Exception as e:
|
|
|
logger.warning(f"⚠️ RSS feed {source_name} failed: {e}")
|
|
|
continue
|
|
|
|
|
|
if successful_sources > 0:
|
|
|
logger.info(f"✅ Successfully fetched from {successful_sources}/{len(self.rss_feeds)} RSS sources")
|
|
|
else:
|
|
|
logger.error(f"❌ All RSS feeds failed")
|
|
|
|
|
|
return articles[:limit]
|
|
|
|
|
|
|
|
|
|
|
|
crypto_news_client = CryptoNewsClient()
|
|
|
|
|
|
|
|
|
__all__ = ["CryptoNewsClient", "crypto_news_client"]
|
|
|
|