Refactor project structure to organize APIs (#24)

* Refactor project structure "api"
* fix bug conversione delle valute fiat in stablecoin in BinanceWrapper
* Refactor: WrapperHandler for managing API wrappers with retry logic; update related modules and tests
* Refactor: Update ProductInfo and Price classes to include aggregation methods; remove standalone aggregation functions
* fix docs
This commit was merged in pull request #24.
This commit is contained in:
Giacomo Bertolazzi
2025-10-11 21:36:13 +02:00
committed by GitHub
parent 517842c834
commit 093a7f5a48
40 changed files with 284 additions and 238 deletions

View File

@@ -0,0 +1,78 @@
from agno.tools import Toolkit
from app.api.wrapper_handler import WrapperHandler
from app.api.base.news import NewsWrapper, Article
from app.api.news.news_api import NewsApiWrapper
from app.api.news.googlenews import GoogleNewsWrapper
from app.api.news.cryptopanic_api import CryptoPanicWrapper
from app.api.news.duckduckgo import DuckDuckGoWrapper
__all__ = ["NewsAPIsTool", "NewsApiWrapper", "GoogleNewsWrapper", "CryptoPanicWrapper", "DuckDuckGoWrapper", "Article"]
class NewsAPIsTool(NewsWrapper, Toolkit):
"""
Aggregates multiple news API wrappers and manages them using WrapperHandler.
This class supports retrieving top headlines and latest news articles by querying multiple sources:
- GoogleNewsWrapper
- DuckDuckGoWrapper
- NewsApiWrapper
- CryptoPanicWrapper
By default, it returns results from the first successful wrapper.
Optionally, it can be configured to collect articles from all wrappers.
If no wrapper succeeds, an exception is raised.
"""
def __init__(self):
"""
Initialize the NewsAPIsTool with multiple news API wrappers.
The tool uses WrapperHandler to manage and invoke the different news API wrappers.
The following wrappers are included in this order:
- GoogleNewsWrapper.
- DuckDuckGoWrapper.
- NewsApiWrapper.
- CryptoPanicWrapper.
"""
wrappers: list[type[NewsWrapper]] = [GoogleNewsWrapper, DuckDuckGoWrapper, NewsApiWrapper, CryptoPanicWrapper]
self.handler = WrapperHandler.build_wrappers(wrappers)
Toolkit.__init__( # type: ignore
self,
name="News APIs Toolkit",
tools=[
self.get_top_headlines,
self.get_latest_news,
self.get_top_headlines_aggregated,
self.get_latest_news_aggregated,
],
)
def get_top_headlines(self, limit: int = 100) -> list[Article]:
return self.handler.try_call(lambda w: w.get_top_headlines(limit))
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
return self.handler.try_call(lambda w: w.get_latest_news(query, limit))
def get_top_headlines_aggregated(self, limit: int = 100) -> dict[str, list[Article]]:
"""
Calls get_top_headlines on all wrappers/providers and returns a dictionary mapping their names to their articles.
Args:
limit (int): Maximum number of articles to retrieve from each provider.
Returns:
dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles
Raises:
Exception: If all wrappers fail to provide results.
"""
return self.handler.try_call_all(lambda w: w.get_top_headlines(limit))
def get_latest_news_aggregated(self, query: str, limit: int = 100) -> dict[str, list[Article]]:
"""
Calls get_latest_news on all wrappers/providers and returns a dictionary mapping their names to their articles.
Args:
query (str): The search query to find relevant news articles.
limit (int): Maximum number of articles to retrieve from each provider.
Returns:
dict[str, list[Article]]: A dictionary mapping providers names to their list of Articles
Raises:
Exception: If all wrappers fail to provide results.
"""
return self.handler.try_call_all(lambda w: w.get_latest_news(query, limit))

View File

@@ -0,0 +1,79 @@
import os
from typing import Any
import requests
from enum import Enum
from app.api.base.news import NewsWrapper, Article
class CryptoPanicFilter(Enum):
RISING = "rising"
HOT = "hot"
BULLISH = "bullish"
BEARISH = "bearish"
IMPORTANT = "important"
SAVED = "saved"
LOL = "lol"
ANY = ""
class CryptoPanicKind(Enum):
NEWS = "news"
MEDIA = "media"
ALL = "all"
def extract_articles(response: dict[str, Any]) -> list[Article]:
articles: list[Article] = []
if 'results' in response:
for item in response['results']:
article = Article()
article.source = item.get('source', {}).get('title', '')
article.time = item.get('published_at', '')
article.title = item.get('title', '')
article.description = item.get('description', '')
articles.append(article)
return articles
class CryptoPanicWrapper(NewsWrapper):
"""
A wrapper for the CryptoPanic API (Documentation: https://cryptopanic.com/developers/api/)
Requires an API key set in the environment variable CRYPTOPANIC_API_KEY.
It is free to use, but has rate limits and restrictions based on the plan type (the free plan is 'developer' with 100 req/month).
Supports different plan types via the CRYPTOPANIC_API_PLAN environment variable (developer, growth, enterprise).
"""
def __init__(self):
self.api_key = os.getenv("CRYPTOPANIC_API_KEY", "")
assert self.api_key, "CRYPTOPANIC_API_KEY environment variable not set"
# Set here for the future, but currently not needed
plan_type = os.getenv("CRYPTOPANIC_API_PLAN", "developer").lower()
assert plan_type in ["developer", "growth", "enterprise"], "Invalid CRYPTOPANIC_API_PLAN value"
self.base_url = f"https://cryptopanic.com/api/{plan_type}/v2"
self.filter = CryptoPanicFilter.ANY
self.kind = CryptoPanicKind.NEWS
def get_base_params(self) -> dict[str, str]:
params: dict[str, str] = {}
params['public'] = 'true' # recommended for app and bots
params['auth_token'] = self.api_key
params['kind'] = self.kind.value
if self.filter != CryptoPanicFilter.ANY:
params['filter'] = self.filter.value
return params
def set_filter(self, filter: CryptoPanicFilter):
self.filter = filter
def get_top_headlines(self, limit: int = 100) -> list[Article]:
return self.get_latest_news("", limit) # same endpoint so just call the other method
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
params = self.get_base_params()
params['currencies'] = query
response = requests.get(f"{self.base_url}/posts/", params=params)
assert response.status_code == 200, f"Error fetching data: {response}"
json_response = response.json()
articles = extract_articles(json_response)
return articles[:limit]

View File

@@ -0,0 +1,34 @@
import json
from typing import Any
from agno.tools.duckduckgo import DuckDuckGoTools
from app.api.base.news import Article, NewsWrapper
def extract_article(result: dict[str, Any]) -> Article:
article = Article()
article.source = result.get("source", "")
article.time = result.get("date", "")
article.title = result.get("title", "")
article.description = result.get("body", "")
return article
class DuckDuckGoWrapper(NewsWrapper):
"""
A wrapper for DuckDuckGo News search using the Tool from agno.tools.duckduckgo.
It can be rewritten to use direct API calls if needed in the future, but currently is easy to write and use.
"""
def __init__(self):
self.tool = DuckDuckGoTools()
self.query = "crypto"
def get_top_headlines(self, limit: int = 100) -> list[Article]:
results = self.tool.duckduckgo_news(self.query, max_results=limit)
json_results = json.loads(results)
return [extract_article(result) for result in json_results]
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
results = self.tool.duckduckgo_news(query or self.query, max_results=limit)
json_results = json.loads(results)
return [extract_article(result) for result in json_results]

View File

@@ -0,0 +1,38 @@
from typing import Any
from gnews import GNews # type: ignore
from app.api.base.news import Article, NewsWrapper
def extract_article(result: dict[str, Any]) -> Article:
article = Article()
article.source = result.get("source", "")
article.time = result.get("publishedAt", "")
article.title = result.get("title", "")
article.description = result.get("description", "")
return article
class GoogleNewsWrapper(NewsWrapper):
"""
A wrapper for the Google News RSS Feed (Documentation: https://github.com/ranahaani/GNews/?tab=readme-ov-file#about-gnews)
It does not require an API key and is free to use.
"""
def get_top_headlines(self, limit: int = 100) -> list[Article]:
gnews = GNews(language='en', max_results=limit, period='7d')
results: list[dict[str, Any]] = gnews.get_top_news() # type: ignore
articles: list[Article] = []
for result in results:
article = extract_article(result)
articles.append(article)
return articles
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
gnews = GNews(language='en', max_results=limit, period='7d')
results: list[dict[str, Any]] = gnews.get_news(query) # type: ignore
articles: list[Article] = []
for result in results:
article = extract_article(result)
articles.append(article)
return articles

View File

@@ -0,0 +1,54 @@
import os
from typing import Any
import newsapi # type: ignore
from app.api.base.news import Article, NewsWrapper
def extract_article(result: dict[str, Any]) -> Article:
article = Article()
article.source = result.get("source", {}).get("name", "")
article.time = result.get("publishedAt", "")
article.title = result.get("title", "")
article.description = result.get("description", "")
return article
class NewsApiWrapper(NewsWrapper):
"""
A wrapper for the NewsAPI (Documentation: https://newsapi.org/docs/get-started)
Requires an API key set in the environment variable NEWS_API_KEY.
It is free to use, but has rate limits and restrictions based on the plan type (the free plan is 'developer' with 100 req/day).
"""
def __init__(self):
api_key = os.getenv("NEWS_API_KEY")
assert api_key, "NEWS_API_KEY environment variable not set"
self.client = newsapi.NewsApiClient(api_key=api_key)
self.category = "business" # Cryptocurrency is under business
self.language = "en"
self.max_page_size = 100
def __calc_pages(self, limit: int, page_size: int) -> tuple[int, int]:
page_size = min(self.max_page_size, limit)
pages = (limit // page_size) + (1 if limit % page_size > 0 else 0)
return pages, page_size
def get_top_headlines(self, limit: int = 100) -> list[Article]:
pages, page_size = self.__calc_pages(limit, self.max_page_size)
articles: list[Article] = []
for page in range(1, pages + 1):
headlines: dict[str, Any] = self.client.get_top_headlines(q="", category=self.category, language=self.language, page_size=page_size, page=page) # type: ignore
results = [extract_article(article) for article in headlines.get("articles", [])] # type: ignore
articles.extend(results)
return articles
def get_latest_news(self, query: str, limit: int = 100) -> list[Article]:
pages, page_size = self.__calc_pages(limit, self.max_page_size)
articles: list[Article] = []
for page in range(1, pages + 1):
everything: dict[str, Any] = self.client.get_everything(q=query, language=self.language, sort_by="publishedAt", page_size=page_size, page=page) # type: ignore
results = [extract_article(article) for article in everything.get("articles", [])] # type: ignore
articles.extend(results)
return articles