nt_execution/
news_api.rs

1// NewsAPI integration for sentiment analysis
2//
3// Features:
4// - Real-time news from 80,000+ sources
5// - Sentiment analysis ready
6// - Historical news search
7// - Top headlines by country/category
8
9use chrono::{DateTime, Utc};
10use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
11use reqwest::Client;
12use serde::{Deserialize, Serialize};
13use std::num::NonZeroU32;
14use std::time::Duration;
15use tracing::{debug, error};
16
17/// NewsAPI configuration
18#[derive(Debug, Clone)]
19pub struct NewsAPIConfig {
20    /// API key from newsapi.org
21    pub api_key: String,
22    /// Request timeout
23    pub timeout: Duration,
24}
25
26impl Default for NewsAPIConfig {
27    fn default() -> Self {
28        Self {
29            api_key: String::new(),
30            timeout: Duration::from_secs(30),
31        }
32    }
33}
34
35/// NewsAPI client for market sentiment
36pub struct NewsAPIClient {
37    client: Client,
38    config: NewsAPIConfig,
39    base_url: String,
40    rate_limiter: DefaultDirectRateLimiter,
41}
42
43impl NewsAPIClient {
44    /// Create a new NewsAPI client
45    pub fn new(config: NewsAPIConfig) -> Self {
46        let client = Client::builder()
47            .timeout(config.timeout)
48            .build()
49            .expect("Failed to create HTTP client");
50
51        // Free tier: 100 requests per day
52        // Paid tier: 250-100,000 requests per day
53        let quota = Quota::per_hour(NonZeroU32::new(100).unwrap());
54        let rate_limiter = RateLimiter::direct(quota);
55
56        Self {
57            client,
58            config,
59            base_url: "https://newsapi.org/v2".to_string(),
60            rate_limiter,
61        }
62    }
63
64    /// Search for news articles
65    pub async fn search(
66        &self,
67        query: &str,
68        from: Option<DateTime<Utc>>,
69        to: Option<DateTime<Utc>>,
70        language: Option<&str>,
71        sort_by: Option<&str>, // relevancy, popularity, publishedAt
72    ) -> Result<Vec<NewsArticle>, NewsAPIError> {
73        self.rate_limiter.until_ready().await;
74
75        let mut params = vec![
76            ("q", query.to_string()),
77            ("apiKey", self.config.api_key.clone()),
78        ];
79
80        if let Some(from_date) = from {
81            params.push(("from", from_date.format("%Y-%m-%dT%H:%M:%S").to_string()));
82        }
83
84        if let Some(to_date) = to {
85            params.push(("to", to_date.format("%Y-%m-%dT%H:%M:%S").to_string()));
86        }
87
88        if let Some(lang) = language {
89            params.push(("language", lang.to_string()));
90        }
91
92        if let Some(sort) = sort_by {
93            params.push(("sortBy", sort.to_string()));
94        }
95
96        debug!("NewsAPI search: {}", query);
97
98        let response = self
99            .client
100            .get(&format!("{}/everything", self.base_url))
101            .query(&params)
102            .send()
103            .await?;
104
105        if response.status().is_success() {
106            let result: NewsAPIResponse = response.json().await?;
107
108            if result.status == "ok" {
109                Ok(result.articles)
110            } else {
111                Err(NewsAPIError::ApiError(
112                    result.message.unwrap_or_else(|| "Unknown error".to_string()),
113                ))
114            }
115        } else {
116            let error_text = response.text().await.unwrap_or_default();
117            error!("NewsAPI error: {}", error_text);
118            Err(NewsAPIError::ApiError(error_text))
119        }
120    }
121
122    /// Get top headlines
123    pub async fn top_headlines(
124        &self,
125        country: Option<&str>, // us, gb, ca, etc.
126        category: Option<&str>, // business, technology, etc.
127        sources: Option<Vec<String>>,
128    ) -> Result<Vec<NewsArticle>, NewsAPIError> {
129        self.rate_limiter.until_ready().await;
130
131        let mut params = vec![("apiKey", self.config.api_key.clone())];
132
133        if let Some(country_code) = country {
134            params.push(("country", country_code.to_string()));
135        }
136
137        if let Some(cat) = category {
138            params.push(("category", cat.to_string()));
139        }
140
141        if let Some(source_list) = sources {
142            params.push(("sources", source_list.join(",")));
143        }
144
145        let response = self
146            .client
147            .get(&format!("{}/top-headlines", self.base_url))
148            .query(&params)
149            .send()
150            .await?;
151
152        if response.status().is_success() {
153            let result: NewsAPIResponse = response.json().await?;
154
155            if result.status == "ok" {
156                Ok(result.articles)
157            } else {
158                Err(NewsAPIError::ApiError(
159                    result.message.unwrap_or_else(|| "Unknown error".to_string()),
160                ))
161            }
162        } else {
163            let error_text = response.text().await.unwrap_or_default();
164            Err(NewsAPIError::ApiError(error_text))
165        }
166    }
167
168    /// Get news sources
169    pub async fn sources(
170        &self,
171        category: Option<&str>,
172        language: Option<&str>,
173        country: Option<&str>,
174    ) -> Result<Vec<NewsSource>, NewsAPIError> {
175        self.rate_limiter.until_ready().await;
176
177        let mut params = vec![("apiKey", self.config.api_key.clone())];
178
179        if let Some(cat) = category {
180            params.push(("category", cat.to_string()));
181        }
182
183        if let Some(lang) = language {
184            params.push(("language", lang.to_string()));
185        }
186
187        if let Some(country_code) = country {
188            params.push(("country", country_code.to_string()));
189        }
190
191        let response = self
192            .client
193            .get(&format!("{}/sources", self.base_url))
194            .query(&params)
195            .send()
196            .await?;
197
198        if response.status().is_success() {
199            #[derive(Deserialize)]
200            struct SourcesResponse {
201                status: String,
202                sources: Vec<NewsSource>,
203            }
204
205            let result: SourcesResponse = response.json().await?;
206
207            if result.status == "ok" {
208                Ok(result.sources)
209            } else {
210                Err(NewsAPIError::ApiError("Failed to fetch sources".to_string()))
211            }
212        } else {
213            let error_text = response.text().await.unwrap_or_default();
214            Err(NewsAPIError::ApiError(error_text))
215        }
216    }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct NewsArticle {
221    pub source: NewsSourceInfo,
222    pub author: Option<String>,
223    pub title: String,
224    pub description: Option<String>,
225    pub url: String,
226    pub url_to_image: Option<String>,
227    pub published_at: String,
228    pub content: Option<String>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct NewsSourceInfo {
233    pub id: Option<String>,
234    pub name: String,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct NewsSource {
239    pub id: String,
240    pub name: String,
241    pub description: String,
242    pub url: String,
243    pub category: String,
244    pub language: String,
245    pub country: String,
246}
247
248#[derive(Debug, Deserialize)]
249struct NewsAPIResponse {
250    status: String,
251    #[serde(default)]
252    message: Option<String>,
253    #[serde(rename = "totalResults")]
254    total_results: Option<i32>,
255    #[serde(default)]
256    articles: Vec<NewsArticle>,
257}
258
259#[derive(Debug, thiserror::Error)]
260pub enum NewsAPIError {
261    #[error("API error: {0}")]
262    ApiError(String),
263
264    #[error("Network error: {0}")]
265    Network(#[from] reqwest::Error),
266
267    #[error("Parse error: {0}")]
268    Parse(#[from] serde_json::Error),
269
270    #[error("Rate limit exceeded")]
271    RateLimit,
272
273    #[error(transparent)]
274    Other(#[from] anyhow::Error),
275}