From 4f338a92d6e1c381656c66d1721d905f2d2bbb7a Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Fri, 6 Mar 2026 16:54:16 +0000 Subject: [PATCH] refactor: yahoo code restructure --- src/api/yahoo/client.rs | 334 +++++++++++++++++++++ src/api/yahoo/mod.rs | 49 ++++ src/api/{yahoo.rs => yahoo/parse.rs} | 422 +++------------------------ 3 files changed, 417 insertions(+), 388 deletions(-) create mode 100644 src/api/yahoo/client.rs create mode 100644 src/api/yahoo/mod.rs rename src/api/{yahoo.rs => yahoo/parse.rs} (52%) diff --git a/src/api/yahoo/client.rs b/src/api/yahoo/client.rs new file mode 100644 index 0000000..ece144a --- /dev/null +++ b/src/api/yahoo/client.rs @@ -0,0 +1,334 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::Mutex; +use std::time::Duration; + +use crate::api::types::{Interval, Period}; +use crate::error::IdxError; + +use super::parse::{ChartResponse, QuoteSummaryResponse}; + +const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; +const BASE_URL: &str = "https://query2.finance.yahoo.com"; +const COOKIE_FETCH_URL: &str = "https://fc.yahoo.com"; +const CRUMB_FETCH_URL: &str = "https://query1.finance.yahoo.com/v1/test/getcrumb"; + +pub(super) struct YahooClient { + agent: ureq::Agent, + crumb: Mutex>, +} + +impl YahooClient { + pub(super) fn new() -> Self { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(5))) + .timeout_recv_body(Some(Duration::from_secs(10))) + .build() + .into(); + + Self { + agent, + crumb: Mutex::new(None), + } + } + + fn chart_url(symbol: &str, period: &Period, interval: &Interval) -> String { + format!( + "{BASE_URL}/v8/finance/chart/{symbol}?range={}&interval={}", + period.as_str(), + interval.as_str() + ) + } + + fn quote_summary_url(symbol: &str, crumb: &str) -> String { + format!( + "{BASE_URL}/v10/finance/quoteSummary/{symbol}?modules=defaultKeyStatistics,financialData,incomeStatementHistory&crumb={crumb}" + ) + } + + fn cookie_jar_path() -> PathBuf { + PathBuf::from(format!("/tmp/idx_yf_{}.txt", std::process::id())) + } + + pub(super) fn parse_crumb_body(raw: &str) -> Result { + let crumb = raw.trim(); + if crumb.is_empty() { + return Err(IdxError::Http("received empty Yahoo crumb".to_string())); + } + + let normalized = crumb.to_ascii_lowercase(); + if normalized.contains("") || normalized.contains(" Result { + let jar = std::fs::read_to_string(path).map_err(|e| { + IdxError::Http(format!( + "failed to read Yahoo cookie jar {}: {e}", + path.display() + )) + })?; + + let cookies: Vec = jar + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + + let candidate = trimmed.strip_prefix("#HttpOnly_").unwrap_or(trimmed); + if candidate.starts_with('#') { + return None; + } + + let fields: Vec<_> = candidate.split('\t').collect(); + if fields.len() < 7 { + return None; + } + + let name = fields[5].trim(); + let value = fields[6].trim(); + if name.is_empty() { + return None; + } + + Some(format!("{name}={value}")) + }) + .collect(); + + if cookies.is_empty() { + return Err(IdxError::Http(format!( + "Yahoo cookie jar {} did not contain any cookies", + path.display() + ))); + } + + Ok(cookies.join("; ")) + } + + fn chrome_curl_binary() -> Option<&'static str> { + // curl-impersonate-chrome ships per-version binaries (curl_chrome131 etc). + // Try latest versions first; no --impersonate flag needed; the binary is the impersonation. + const CANDIDATES: &[&str] = &[ + "curl_chrome136", + "curl_chrome133a", + "curl_chrome131", + "curl_chrome124", + "curl_chrome120", + "curl_chrome116", + ]; + CANDIDATES + .iter() + .copied() + .find(|bin| Command::new(bin).arg("--version").output().is_ok()) + } + + fn run_curl(stage: &str, binary: &str, args: &[&str]) -> Result { + let output = Command::new(binary).args(args).output().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + return IdxError::Http(format!( + "curl-impersonate binary '{binary}' not found; install nixpkgs#curl-impersonate-chrome" + )); + } + IdxError::Http(format!("failed to run {binary} for Yahoo {stage}: {e}")) + })?; + + if output.status.success() { + return Ok(output); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.trim(); + Err(IdxError::Http(format!( + "Yahoo {stage} {binary} failed (status {}): {}", + output.status, + if detail.is_empty() { + "no output" + } else { + detail + } + ))) + } + + fn fetch_crumb_via_curl(&self) -> Result { + let binary = Self::chrome_curl_binary().ok_or_else(|| { + IdxError::Http( + "no curl_chrome* binary found; install nixpkgs#curl-impersonate-chrome".to_string(), + ) + })?; + + let cookie_jar = Self::cookie_jar_path(); + let cookie_jar_str = cookie_jar.to_str().ok_or_else(|| { + IdxError::Io(format!( + "failed to encode Yahoo cookie jar path {}", + cookie_jar.display() + )) + })?; + + // Step 1: fetch fc.yahoo.com to set A3 cookie (returns 404 but writes cookie jar). + // We allow non-zero exit here since 404 still writes the cookie. + let _ = Command::new(binary) + .args([ + "--silent", + "--cookie-jar", + cookie_jar_str, + COOKIE_FETCH_URL, + "--output", + "/dev/null", + ]) + .output(); + + // Step 2: fetch crumb with cookie jar (Chrome TLS fingerprint + A3 cookie). + let output = Self::run_curl( + "crumb fetch", + binary, + &["--silent", "--cookie", cookie_jar_str, CRUMB_FETCH_URL], + )?; + + let body = String::from_utf8_lossy(&output.stdout); + Self::parse_crumb_body(&body) + } + + fn get_or_init_crumb(&self) -> Result { + let mut guard = self + .crumb + .lock() + .map_err(|e| IdxError::Io(format!("crumb lock poisoned: {e}")))?; + + if let Some(crumb) = guard.as_ref() { + return Ok(crumb.clone()); + } + + let crumb = self.fetch_crumb_via_curl()?; + *guard = Some(crumb.clone()); + Ok(crumb) + } + + fn clear_crumb(&self) -> Result<(), IdxError> { + let mut guard = self + .crumb + .lock() + .map_err(|e| IdxError::Io(format!("crumb lock poisoned: {e}")))?; + *guard = None; + Ok(()) + } + + pub(super) fn fetch_chart( + &self, + symbol: &str, + period: &Period, + interval: &Interval, + ) -> Result { + let mut wait = Duration::from_millis(250); + for attempt in 0..3 { + let url = Self::chart_url(symbol, period, interval); + let response = self.agent.get(&url).header("User-Agent", USER_AGENT).call(); + match response { + Ok(ok) => { + return ok + .into_body() + .read_json::() + .map_err(|e| IdxError::ParseError(e.to_string())); + } + Err(ureq::Error::StatusCode(429)) => { + if attempt < 2 { + std::thread::sleep(wait + jitter()); + wait *= 2; + } + } + Err(ureq::Error::StatusCode(404)) => { + return Err(IdxError::SymbolNotFound(symbol.to_string())); + } + Err(e) => return Err(IdxError::Http(e.to_string())), + } + } + Err(IdxError::RateLimited) + } + + pub(super) fn fetch_quote_summary( + &self, + symbol: &str, + ) -> Result { + for auth_attempt in 0..2 { + let crumb = self.get_or_init_crumb()?; + let cookie_header = + Self::cookie_header_from_jar(&Self::cookie_jar_path()).unwrap_or_default(); + let url = Self::quote_summary_url(symbol, &crumb); + let mut wait = Duration::from_millis(250); + + for attempt in 0..3 { + let mut req = self.agent.get(&url).header("User-Agent", USER_AGENT); + if !cookie_header.is_empty() { + req = req.header("Cookie", &cookie_header); + } + let response = req.call(); + match response { + Ok(ok) => { + return ok + .into_body() + .read_json::() + .map_err(|e| IdxError::ParseError(e.to_string())); + } + Err(ureq::Error::StatusCode(401)) => { + if auth_attempt == 0 { + self.clear_crumb()?; + break; + } + return Err(IdxError::Http( + "yahoo quoteSummary returned unauthorized (401)".to_string(), + )); + } + Err(ureq::Error::StatusCode(429)) => { + if attempt < 2 { + std::thread::sleep(wait + jitter()); + wait *= 2; + } + } + Err(ureq::Error::StatusCode(404)) => { + return Err(IdxError::SymbolNotFound(symbol.to_string())); + } + Err(e) => return Err(IdxError::Http(e.to_string())), + } + } + } + + Err(IdxError::RateLimited) + } +} + +fn jitter() -> Duration { + Duration::from_millis(fastrand::u64(0..100)) +} + +#[cfg(test)] +mod tests { + use super::YahooClient; + + #[test] + fn parses_crumb_body_trimmed() { + let crumb = YahooClient::parse_crumb_body(" abc123xyz\n").expect("crumb should parse"); + assert_eq!(crumb, "abc123xyz"); + + let empty = YahooClient::parse_crumb_body(" \n").expect_err("empty crumb must fail"); + assert!(matches!(empty, crate::error::IdxError::Http(_))); + + let html = YahooClient::parse_crumb_body("blocked") + .expect_err("html crumb must fail"); + assert!(matches!(html, crate::error::IdxError::Http(_))); + + let rate_limited = YahooClient::parse_crumb_body("Too Many Requests") + .expect_err("rate limited crumb must fail"); + assert!(matches!(rate_limited, crate::error::IdxError::Http(_))); + } +} diff --git a/src/api/yahoo/mod.rs b/src/api/yahoo/mod.rs new file mode 100644 index 0000000..e87ecfe --- /dev/null +++ b/src/api/yahoo/mod.rs @@ -0,0 +1,49 @@ +mod client; +mod parse; + +use crate::api::MarketDataProvider; +use crate::api::types::{Fundamentals, Interval, Ohlc, Period, Quote}; +use crate::error::IdxError; + +use client::YahooClient; +use parse::{parse_fundamentals, parse_history_with_verbose, parse_quote}; + +pub(crate) use parse::{parse_fundamentals_from_str, parse_history_from_str, parse_quote_from_str}; + +pub struct YahooProvider { + client: YahooClient, + verbose: bool, +} + +impl YahooProvider { + pub fn new(verbose: bool) -> Self { + Self { + client: YahooClient::new(), + verbose, + } + } +} + +impl MarketDataProvider for YahooProvider { + fn quote(&self, symbol: &str) -> Result { + let chart = self + .client + .fetch_chart(symbol, &Period::OneDay, &Interval::Day)?; + parse_quote(symbol, &chart) + } + + fn fundamentals(&self, symbol: &str) -> Result { + let quote_summary = self.client.fetch_quote_summary(symbol)?; + parse_fundamentals(symbol, "e_summary) + } + + fn history( + &self, + symbol: &str, + period: &Period, + interval: &Interval, + ) -> Result, IdxError> { + let chart = self.client.fetch_chart(symbol, period, interval)?; + parse_history_with_verbose(&chart, self.verbose) + } +} diff --git a/src/api/yahoo.rs b/src/api/yahoo/parse.rs similarity index 52% rename from src/api/yahoo.rs rename to src/api/yahoo/parse.rs index e21860d..6907190 100644 --- a/src/api/yahoo.rs +++ b/src/api/yahoo/parse.rs @@ -1,364 +1,10 @@ use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::Mutex; -use std::time::Duration; use serde::Deserialize; -use crate::api::MarketDataProvider; -use crate::api::types::{Fundamentals, Interval, Ohlc, Period, Quote}; +use crate::api::types::{Fundamentals, Ohlc, Quote}; use crate::error::IdxError; -const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; -const BASE_URL: &str = "https://query2.finance.yahoo.com"; -const COOKIE_FETCH_URL: &str = "https://fc.yahoo.com"; -const CRUMB_FETCH_URL: &str = "https://query1.finance.yahoo.com/v1/test/getcrumb"; - -pub struct YahooProvider { - agent: ureq::Agent, - verbose: bool, - crumb: Mutex>, -} - -impl YahooProvider { - pub fn new(verbose: bool) -> Self { - let agent: ureq::Agent = ureq::Agent::config_builder() - .timeout_connect(Some(Duration::from_secs(5))) - .timeout_recv_body(Some(Duration::from_secs(10))) - .build() - .into(); - - Self { - agent, - verbose, - crumb: Mutex::new(None), - } - } - - fn chart_url(symbol: &str, period: &Period, interval: &Interval) -> String { - format!( - "{BASE_URL}/v8/finance/chart/{symbol}?range={}&interval={}", - period.as_str(), - interval.as_str() - ) - } - - fn quote_summary_url(symbol: &str, crumb: &str) -> String { - format!( - "{BASE_URL}/v10/finance/quoteSummary/{symbol}?modules=defaultKeyStatistics,financialData,incomeStatementHistory&crumb={crumb}" - ) - } - - fn cookie_jar_path() -> PathBuf { - PathBuf::from(format!("/tmp/idx_yf_{}.txt", std::process::id())) - } - - fn parse_crumb_body(raw: &str) -> Result { - let crumb = raw.trim(); - if crumb.is_empty() { - return Err(IdxError::Http("received empty Yahoo crumb".to_string())); - } - - let normalized = crumb.to_ascii_lowercase(); - if normalized.contains("") || normalized.contains(" Result { - let jar = std::fs::read_to_string(path).map_err(|e| { - IdxError::Http(format!( - "failed to read Yahoo cookie jar {}: {e}", - path.display() - )) - })?; - - let cookies: Vec = jar - .lines() - .filter_map(|line| { - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - - let candidate = trimmed.strip_prefix("#HttpOnly_").unwrap_or(trimmed); - if candidate.starts_with('#') { - return None; - } - - let fields: Vec<_> = candidate.split('\t').collect(); - if fields.len() < 7 { - return None; - } - - let name = fields[5].trim(); - let value = fields[6].trim(); - if name.is_empty() { - return None; - } - - Some(format!("{name}={value}")) - }) - .collect(); - - if cookies.is_empty() { - return Err(IdxError::Http(format!( - "Yahoo cookie jar {} did not contain any cookies", - path.display() - ))); - } - - Ok(cookies.join("; ")) - } - - fn chrome_curl_binary() -> Option<&'static str> { - // curl-impersonate-chrome ships per-version binaries (curl_chrome131 etc). - // Try latest versions first; no --impersonate flag needed — the binary IS the impersonation. - const CANDIDATES: &[&str] = &[ - "curl_chrome136", - "curl_chrome133a", - "curl_chrome131", - "curl_chrome124", - "curl_chrome120", - "curl_chrome116", - ]; - CANDIDATES - .iter() - .copied() - .find(|bin| Command::new(bin).arg("--version").output().is_ok()) - } - - fn run_curl(stage: &str, binary: &str, args: &[&str]) -> Result { - let output = Command::new(binary).args(args).output().map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - return IdxError::Http(format!( - "curl-impersonate binary '{binary}' not found; install nixpkgs#curl-impersonate-chrome" - )); - } - IdxError::Http(format!("failed to run {binary} for Yahoo {stage}: {e}")) - })?; - - if output.status.success() { - return Ok(output); - } - - let stderr = String::from_utf8_lossy(&output.stderr); - let detail = stderr.trim(); - Err(IdxError::Http(format!( - "Yahoo {stage} {binary} failed (status {}): {}", - output.status, - if detail.is_empty() { - "no output" - } else { - detail - } - ))) - } - - fn fetch_crumb_via_curl(&self) -> Result { - let binary = Self::chrome_curl_binary().ok_or_else(|| { - IdxError::Http( - "no curl_chrome* binary found; install nixpkgs#curl-impersonate-chrome".to_string(), - ) - })?; - - let cookie_jar = Self::cookie_jar_path(); - let cookie_jar_str = cookie_jar.to_str().ok_or_else(|| { - IdxError::Io(format!( - "failed to encode Yahoo cookie jar path {}", - cookie_jar.display() - )) - })?; - - // Step 1: fetch fc.yahoo.com to set A3 cookie (returns 404 but writes cookie jar) - // We allow non-zero exit here since 404 still writes the cookie - let _ = Command::new(binary) - .args([ - "--silent", - "--cookie-jar", - cookie_jar_str, - COOKIE_FETCH_URL, - "--output", - "/dev/null", - ]) - .output(); - - // Step 2: fetch crumb with cookie jar (Chrome TLS fingerprint + A3 cookie) - let output = Self::run_curl( - "crumb fetch", - binary, - &["--silent", "--cookie", cookie_jar_str, CRUMB_FETCH_URL], - )?; - - let body = String::from_utf8_lossy(&output.stdout); - Self::parse_crumb_body(&body) - } - - fn get_or_init_crumb(&self) -> Result { - let mut guard = self - .crumb - .lock() - .map_err(|e| IdxError::Io(format!("crumb lock poisoned: {e}")))?; - - if let Some(crumb) = guard.as_ref() { - return Ok(crumb.clone()); - } - - let crumb = self.fetch_crumb_via_curl()?; - *guard = Some(crumb.clone()); - Ok(crumb) - } - - fn clear_crumb(&self) -> Result<(), IdxError> { - let mut guard = self - .crumb - .lock() - .map_err(|e| IdxError::Io(format!("crumb lock poisoned: {e}")))?; - *guard = None; - Ok(()) - } - - fn fetch_chart( - &self, - symbol: &str, - period: &Period, - interval: &Interval, - ) -> Result { - let mut wait = Duration::from_millis(250); - for attempt in 0..3 { - let url = Self::chart_url(symbol, period, interval); - let response = self.agent.get(&url).header("User-Agent", USER_AGENT).call(); - match response { - Ok(ok) => { - let chart = ok - .into_body() - .read_json::() - .map_err(|e| IdxError::ParseError(e.to_string()))?; - if let Some(err) = chart.chart.error.as_ref() { - return Err(map_yahoo_error(symbol, "chart", err)); - } - return Ok(chart); - } - Err(ureq::Error::StatusCode(429)) => { - if attempt < 2 { - std::thread::sleep(wait + jitter()); - wait *= 2; - } - } - Err(ureq::Error::StatusCode(404)) => { - return Err(IdxError::SymbolNotFound(symbol.to_string())); - } - Err(e) => return Err(IdxError::Http(e.to_string())), - } - } - Err(IdxError::RateLimited) - } - - fn fetch_quote_summary(&self, symbol: &str) -> Result { - for auth_attempt in 0..2 { - let crumb = self.get_or_init_crumb()?; - // Read the A3 cookie written during crumb fetch and pass it to quoteSummary - let cookie_header = - Self::cookie_header_from_jar(&Self::cookie_jar_path()).unwrap_or_default(); - let url = Self::quote_summary_url(symbol, &crumb); - let mut wait = Duration::from_millis(250); - - for attempt in 0..3 { - let mut req = self.agent.get(&url).header("User-Agent", USER_AGENT); - if !cookie_header.is_empty() { - req = req.header("Cookie", &cookie_header); - } - let response = req.call(); - match response { - Ok(ok) => { - let quote_summary = ok - .into_body() - .read_json::() - .map_err(|e| IdxError::ParseError(e.to_string()))?; - if let Some(err) = quote_summary.quote_summary.error.as_ref() { - return Err(map_yahoo_error(symbol, "quoteSummary", err)); - } - return Ok(quote_summary); - } - Err(ureq::Error::StatusCode(401)) => { - if auth_attempt == 0 { - self.clear_crumb()?; - break; - } - return Err(IdxError::Http( - "yahoo quoteSummary returned unauthorized (401)".to_string(), - )); - } - Err(ureq::Error::StatusCode(429)) => { - if attempt < 2 { - std::thread::sleep(wait + jitter()); - wait *= 2; - } - } - Err(ureq::Error::StatusCode(404)) => { - return Err(IdxError::SymbolNotFound(symbol.to_string())); - } - Err(e) => return Err(IdxError::Http(e.to_string())), - } - } - } - - Err(IdxError::RateLimited) - } -} - -fn jitter() -> Duration { - Duration::from_millis(fastrand::u64(0..100)) -} - -fn round_price(value: f64) -> i64 { - value.round() as i64 -} - -// verbose behavior is configured on YahooProvider and threaded into history parsing. - -fn map_yahoo_error(symbol: &str, endpoint: &str, err: &ChartError) -> IdxError { - if err.code.eq_ignore_ascii_case("Not Found") { - return IdxError::SymbolNotFound(symbol.to_string()); - } - IdxError::Http(format!( - "yahoo {endpoint} error {}: {}", - err.code, err.description - )) -} - -impl MarketDataProvider for YahooProvider { - fn quote(&self, symbol: &str) -> Result { - let chart = self.fetch_chart(symbol, &Period::OneDay, &Interval::Day)?; - parse_quote(symbol, &chart) - } - - fn fundamentals(&self, symbol: &str) -> Result { - let quote_summary = self.fetch_quote_summary(symbol)?; - parse_fundamentals(symbol, "e_summary) - } - - fn history( - &self, - symbol: &str, - period: &Period, - interval: &Interval, - ) -> Result, IdxError> { - let chart = self.fetch_chart(symbol, period, interval)?; - parse_history_with_verbose(&chart, self.verbose) - } -} - pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result { let chart: ChartResponse = serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?; @@ -368,7 +14,7 @@ pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result Result { +pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result { if let Some(err) = chart.chart.error.as_ref() { return Err(map_yahoo_error(symbol, "chart", err)); } @@ -382,7 +28,7 @@ fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result { let meta = result.meta.as_ref().ok_or(IdxError::ProviderUnavailable)?; let raw_price = meta .regular_market_price - .ok_or(IdxError::SymbolNotFound(symbol.to_string()))?; + .ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?; let raw_prev_close = meta.previous_close.or(meta.chart_previous_close); let price = round_price(raw_price); @@ -446,7 +92,10 @@ pub(crate) fn parse_fundamentals_from_str( parse_fundamentals(symbol, "e_summary) } -fn parse_history_with_verbose(chart: &ChartResponse, verbose: bool) -> Result, IdxError> { +pub(super) fn parse_history_with_verbose( + chart: &ChartResponse, + verbose: bool, +) -> Result, IdxError> { if let Some(err) = chart.chart.error.as_ref() { return Err(map_yahoo_error("unknown", "chart", err)); } @@ -522,7 +171,7 @@ fn parse_history_with_verbose(chart: &ChartResponse, verbose: bool) -> Result Result { @@ -575,26 +224,40 @@ fn parse_fundamentals( }) } +fn round_price(value: f64) -> i64 { + value.round() as i64 +} + +pub(super) fn map_yahoo_error(symbol: &str, endpoint: &str, err: &ChartError) -> IdxError { + if err.code.eq_ignore_ascii_case("Not Found") { + return IdxError::SymbolNotFound(symbol.to_string()); + } + IdxError::Http(format!( + "yahoo {endpoint} error {}: {}", + err.code, err.description + )) +} + #[derive(Debug, Deserialize)] -struct ChartResponse { +pub(super) struct ChartResponse { chart: ChartRoot, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct QuoteSummaryResponse { +pub(super) struct QuoteSummaryResponse { quote_summary: QuoteSummaryRoot, } #[derive(Debug, Deserialize)] -struct QuoteSummaryRoot { +pub(super) struct QuoteSummaryRoot { result: Option>, error: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct QuoteSummaryResult { +pub(super) struct QuoteSummaryResult { #[serde(default)] default_key_statistics: QuoteSummarySection, #[serde(default)] @@ -602,20 +265,20 @@ struct QuoteSummaryResult { } #[derive(Debug, Deserialize)] -struct ChartRoot { +pub(super) struct ChartRoot { result: Option>, error: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct ChartError { +pub(super) struct ChartError { code: String, description: String, } #[derive(Debug, Deserialize)] -struct ChartResult { +pub(super) struct ChartResult { meta: Option, timestamp: Option>, indicators: Option, @@ -624,7 +287,7 @@ struct ChartResult { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] #[allow(dead_code)] -struct ChartMeta { +pub(super) struct ChartMeta { symbol: Option, regular_market_price: Option, previous_close: Option, @@ -640,12 +303,12 @@ struct ChartMeta { } #[derive(Debug, Deserialize)] -struct Indicators { +pub(super) struct Indicators { quote: Option>, } #[derive(Debug, Deserialize)] -struct IndicatorQuote { +pub(super) struct IndicatorQuote { open: Option>>, high: Option>>, low: Option>>, @@ -681,7 +344,7 @@ impl QuoteSummarySectionExt for QuoteSummarySection { enum QuoteSummaryValue { Wrapped { raw: Option }, Direct(YahooNumber), - // Catch-all for empty objects {}, null, strings, booleans — return None for all numeric extractions + // Catch-all for empty objects {}, null, strings, booleans; return None for numeric extractions. Unknown(serde_json::Value), } @@ -749,7 +412,7 @@ impl YahooNumber { #[cfg(test)] mod tests { use super::{ - ChartResponse, YahooProvider, parse_fundamentals_from_str, parse_history_from_str, + ChartResponse, parse_fundamentals_from_str, parse_history_from_str, parse_history_with_verbose, parse_quote, parse_quote_from_str, }; @@ -823,21 +486,4 @@ mod tests { let err = parse_quote_from_str("INVALID.JK", raw).expect_err("expected symbol error"); assert!(matches!(err, crate::error::IdxError::SymbolNotFound(_))); } - - #[test] - fn parses_crumb_body_trimmed() { - let crumb = YahooProvider::parse_crumb_body(" abc123xyz\n").expect("crumb should parse"); - assert_eq!(crumb, "abc123xyz"); - - let empty = YahooProvider::parse_crumb_body(" \n").expect_err("empty crumb must fail"); - assert!(matches!(empty, crate::error::IdxError::Http(_))); - - let html = YahooProvider::parse_crumb_body("blocked") - .expect_err("html crumb must fail"); - assert!(matches!(html, crate::error::IdxError::Http(_))); - - let rate_limited = YahooProvider::parse_crumb_body("Too Many Requests") - .expect_err("rate limited crumb must fail"); - assert!(matches!(rate_limited, crate::error::IdxError::Http(_))); - } }