finance_query_core/client/
yahoo_auth.rs

1//! Yahoo Finance authentication manager.
2//!
3//! This module handles cookie/crumb authentication for Yahoo Finance API requests.
4
5use crate::client::error::YahooError;
6use chrono::{DateTime, Utc};
7use reqwest::{cookie::Jar, ClientBuilder};
8use std::sync::Arc;
9use std::time::Duration;
10use tokio::sync::Mutex;
11use tracing::{debug, error, info, warn};
12
13const MIN_REFRESH_INTERVAL_SECS: i64 = 30;
14
15struct AuthState {
16    crumb: Option<String>,
17    cookie_jar: Arc<Jar>,
18    last_update: Option<DateTime<Utc>>,
19}
20
21/// Manages Yahoo Finance authentication (cookies and crumb).
22///
23/// Yahoo Finance requires a valid crumb token for API requests.
24/// This manager handles obtaining and refreshing the crumb automatically.
25pub struct YahooAuthManager {
26    state: Arc<Mutex<AuthState>>,
27    proxy: Option<String>,
28}
29
30impl YahooAuthManager {
31    /// Create a new YahooAuthManager.
32    ///
33    /// # Arguments
34    /// * `proxy` - Optional proxy URL for authentication requests
35    /// * `cookie_jar` - Shared cookie jar for storing session cookies
36    pub fn new(proxy: Option<String>, cookie_jar: Arc<Jar>) -> Self {
37        Self {
38            state: Arc::new(Mutex::new(AuthState {
39                crumb: None,
40                cookie_jar,
41                last_update: None,
42            })),
43            proxy,
44        }
45    }
46
47    /// Force refresh the authentication credentials.
48    pub async fn refresh(&self) -> Result<(), YahooError> {
49        info!("Refreshing Yahoo authentication...");
50        let state = self.state.lock().await;
51        let cookie_jar = state.cookie_jar.clone();
52        drop(state);
53
54        let mut builder = ClientBuilder::new()
55            .timeout(Duration::from_secs(30))
56            .cookie_provider(cookie_jar.clone())
57            .redirect(reqwest::redirect::Policy::limited(10));
58
59
60        if let Some(proxy_url) = &self.proxy {
61            info!(
62                "Using proxy for Yahoo auth: {}...",
63                &proxy_url.chars().take(30).collect::<String>()
64            );
65            builder = builder
66                .proxy(
67                    reqwest::Proxy::all(proxy_url).map_err(YahooError::NetworkError)?,
68                )
69                // Accept proxy's SSL certificate (Bright Data and similar proxies use self-signed certs)
70                .danger_accept_invalid_certs(true);
71        }
72
73        let client = builder.build().map_err(YahooError::NetworkError)?;
74
75        // Step 1: Visit Yahoo Finance homepage to establish session
76        info!("Step 1: Visiting Yahoo Finance homepage");
77        let _ = client
78            .get("https://finance.yahoo.com/")
79            .header(
80                "User-Agent",
81                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
82            )
83            .header(
84                "Accept",
85                "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
86            )
87            .header("Accept-Language", "en-US,en;q=0.9")
88            .header("Accept-Encoding", "gzip, deflate, br")
89            .header("Connection", "keep-alive")
90            .header("Upgrade-Insecure-Requests", "1")
91            .send()
92            .await
93            .map_err(YahooError::NetworkError)?;
94
95        debug!("Finance homepage visited");
96
97        // Step 2: Visit a quote page to get more cookies
98        info!("Step 2: Visiting quote page");
99        let _ = client
100            .get("https://finance.yahoo.com/quote/AAPL")
101            .header(
102                "User-Agent",
103                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
104            )
105            .header(
106                "Accept",
107                "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
108            )
109            .header("Accept-Language", "en-US,en;q=0.9")
110            .header("Referer", "https://finance.yahoo.com/")
111            .send()
112            .await
113            .map_err(YahooError::NetworkError)?;
114
115        debug!("Quote page visited");
116
117        // Step 3: Try to get crumb from query1
118        info!("Step 3: Attempting to get crumb from query1 endpoint");
119        let crumb_response = client
120            .get("https://query1.finance.yahoo.com/v1/test/getcrumb")
121            .header(
122                "User-Agent",
123                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
124            )
125            .header("Accept", "*/*")
126            .header("Referer", "https://finance.yahoo.com/")
127            .send()
128            .await
129            .map_err(YahooError::NetworkError)?;
130
131        let status = crumb_response.status();
132        debug!("getcrumb response status: {}", status);
133
134        let crumb_text = crumb_response
135            .text()
136            .await
137            .map_err(YahooError::NetworkError)?;
138        let crumb = crumb_text.trim().to_string();
139        debug!(
140            "Crumb received (length: {}): {}",
141            crumb.len(),
142            if crumb.len() < 50 { &crumb } else { "..." }
143        );
144
145
146        // If crumb is invalid (contains HTML or is empty), try alternative method
147        if crumb.is_empty() || crumb.contains("<html") || crumb.contains("Unauthorized") {
148            warn!("Crumb from query1 is invalid, trying query2");
149
150            let crumb_response2 = client
151                .get("https://query2.finance.yahoo.com/v1/test/getcrumb")
152                .header(
153                    "User-Agent",
154                    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
155                )
156                .header("Accept", "*/*")
157                .header("Referer", "https://finance.yahoo.com/")
158                .send()
159                .await
160                .map_err(YahooError::NetworkError)?;
161
162            let crumb_text2 = crumb_response2
163                .text()
164                .await
165                .map_err(YahooError::NetworkError)?;
166            let crumb2 = crumb_text2.trim().to_string();
167
168            if crumb2.is_empty() || crumb2.contains("<html") || crumb2.contains("Unauthorized") {
169                error!("Failed to get valid crumb from both endpoints");
170                return Err(YahooError::AuthFailed(
171                    "Could not obtain valid crumb from Yahoo".to_string(),
172                ));
173            }
174
175            info!(
176                "Successfully obtained crumb from query2 (length: {})",
177                crumb2.len()
178            );
179            let mut state = self.state.lock().await;
180            state.crumb = Some(crumb2);
181            state.last_update = Some(Utc::now());
182            return Ok(());
183        }
184
185        // Successfully obtained crumb from query1
186        info!("Successfully obtained crumb (length: {})", crumb.len());
187        let mut state = self.state.lock().await;
188        state.crumb = Some(crumb);
189        state.last_update = Some(Utc::now());
190
191        Ok(())
192    }
193
194    /// Get the current crumb, refreshing if necessary.
195    ///
196    /// Returns a tuple of (cookie_jar, crumb) for use in API requests.
197    pub async fn get_or_refresh(&self) -> Result<(Arc<Jar>, String), YahooError> {
198        let mut state = self.state.lock().await;
199
200        let needs_refresh = state.crumb.is_none()
201            || state.last_update.is_none()
202            || (Utc::now() - state.last_update.unwrap()).num_seconds() > MIN_REFRESH_INTERVAL_SECS;
203
204        if needs_refresh {
205            debug!(
206                "Auth refresh needed. Crumb exists: {}, Last update: {:?}",
207                state.crumb.is_some(),
208                state.last_update
209            );
210            drop(state); // Release lock before async operation
211            self.refresh().await?;
212            state = self.state.lock().await;
213        } else {
214            debug!(
215                "Using cached crumb (age: {}s)",
216                state
217                    .last_update
218                    .map(|t| (Utc::now() - t).num_seconds())
219                    .unwrap_or(0)
220            );
221        }
222
223        let crumb = state.crumb.clone().ok_or_else(|| {
224            error!("No crumb available after refresh attempt");
225            YahooError::AuthFailed("No crumb available".to_string())
226        })?;
227
228        debug!("Returning crumb (length: {})", crumb.len());
229        Ok((state.cookie_jar.clone(), crumb))
230    }
231
232    /// Get the current crumb without refreshing.
233    pub async fn crumb(&self) -> Option<String> {
234        self.state.lock().await.crumb.clone()
235    }
236}