finance_query_core/client/
fetch_client.rs1use crate::client::error::YahooError;
7use reqwest::{cookie::Jar, Client, ClientBuilder};
8use std::io::Read;
9use std::sync::Arc;
10use std::time::Duration;
11
12const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
13
14pub struct FetchClient {
19 client: Client,
20 cookie_jar: Arc<Jar>,
21 #[allow(dead_code)]
22 proxy: Option<String>,
23 auth_proxy: Option<String>,
25}
26
27impl FetchClient {
28 pub fn new(proxy: Option<String>) -> Result<Self, YahooError> {
36 let auth_proxy = std::env::var("AUTH_PROXY_URL").ok().or_else(|| proxy.clone());
38
39 let cookie_jar = Arc::new(Jar::default());
40
41 let mut builder = ClientBuilder::new()
42 .timeout(DEFAULT_TIMEOUT)
43 .cookie_store(true)
44 .cookie_provider(cookie_jar.clone())
45 .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36");
46
47 if let Some(proxy_url) = &proxy {
49 builder = builder.proxy(
50 reqwest::Proxy::all(proxy_url).map_err(YahooError::NetworkError)?,
51 );
52 }
53
54 let client = builder.build().map_err(YahooError::NetworkError)?;
55
56 Ok(Self {
57 client,
58 cookie_jar,
59 proxy,
60 auth_proxy,
61 })
62 }
63
64
65 pub fn auth_proxy(&self) -> Option<&String> {
67 self.auth_proxy.as_ref()
68 }
69
70 pub fn client(&self) -> &Client {
72 &self.client
73 }
74
75 pub fn cookie_jar(&self) -> &Arc<Jar> {
77 &self.cookie_jar
78 }
79
80 pub async fn fetch(&self, url: &str) -> Result<String, YahooError> {
82 self.fetch_with_timeout(url, DEFAULT_TIMEOUT).await
83 }
84
85 pub async fn fetch_json(&self, url: &str) -> Result<String, YahooError> {
87 self.fetch_json_with_timeout(url, DEFAULT_TIMEOUT).await
88 }
89
90 pub async fn fetch_json_with_timeout(
93 &self,
94 url: &str,
95 timeout: Duration,
96 ) -> Result<String, YahooError> {
97 let response = match tokio::time::timeout(
98 timeout,
99 self.client
100 .get(url)
101 .timeout(timeout)
102 .header("Accept", "application/json")
103 .header("Accept-Language", "en-US,en;q=0.9")
104 .header(
106 "sec-ch-ua",
107 r#""Chromium";v="122", "Google Chrome";v="122""#,
108 )
109 .header("sec-ch-ua-mobile", "?0")
110 .header("sec-ch-ua-platform", r#""Windows""#)
111 .send(),
112 )
113 .await
114 {
115 Ok(Ok(resp)) => resp,
116 Ok(Err(e)) => return Err(YahooError::NetworkError(e)),
117 Err(_) => {
118 return Err(YahooError::ParseError(format!(
119 "Request to {} timed out after {:?}",
120 url, timeout
121 )));
122 }
123 };
124
125 let status = response.status();
126 if !status.is_success() {
127 return Err(YahooError::HttpError(
128 status.as_u16(),
129 format!(
130 "HTTP {}: {}",
131 status,
132 response.status().canonical_reason().unwrap_or("Unknown")
133 ),
134 ));
135 }
136
137 let content_encoding = response
139 .headers()
140 .get("content-encoding")
141 .and_then(|h| h.to_str().ok())
142 .unwrap_or("")
143 .to_lowercase();
144
145 let bytes = response.bytes().await.map_err(YahooError::NetworkError)?;
146
147 let text = if content_encoding.contains("gzip") || content_encoding.contains("deflate") {
149 let mut decoder = flate2::read::GzDecoder::new(&bytes[..]);
151 let mut decompressed = String::new();
152 decoder.read_to_string(&mut decompressed).map_err(|e| {
153 YahooError::ParseError(format!("Failed to decompress gzip response: {}", e))
154 })?;
155 decompressed
156 } else if content_encoding.contains("br") {
157 return Err(YahooError::ParseError(
159 "Brotli compression detected but not automatically decompressed. This should not happen.".to_string()
160 ));
161 } else {
162 match String::from_utf8(bytes.to_vec()) {
164 Ok(text) => text,
165 Err(_) => {
166 let mut decoder = flate2::read::GzDecoder::new(&bytes[..]);
169 let mut decompressed = String::new();
170 match decoder.read_to_string(&mut decompressed) {
171 Ok(_) => decompressed,
172 Err(_) => {
173 return Err(YahooError::ParseError(format!(
175 "Response is not valid UTF-8 and not gzip compressed (length: {} bytes)",
176 bytes.len()
177 )));
178 }
179 }
180 }
181 }
182 };
183
184 Ok(text)
185 }
186
187
188 pub async fn fetch_with_timeout(&self, url: &str, timeout: Duration) -> Result<String, YahooError> {
190 let response = match tokio::time::timeout(
193 timeout,
194 self.client
195 .get(url)
196 .timeout(timeout) .header(
198 "Accept",
199 "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
200 )
201 .header("Accept-Language", "en-US,en;q=0.9")
202 .header("Accept-Encoding", "gzip, deflate, br")
203 .header(
204 "sec-ch-ua",
205 r#""Chromium";v="122", "Google Chrome";v="122""#,
206 )
207 .header("sec-ch-ua-mobile", "?0")
208 .header("sec-ch-ua-platform", r#""Windows""#)
209 .send(),
210 )
211 .await
212 {
213 Ok(Ok(resp)) => resp,
214 Ok(Err(e)) => return Err(YahooError::NetworkError(e)),
215 Err(_) => {
216 return Err(YahooError::ParseError(format!(
218 "Request to {} timed out after {:?}",
219 url, timeout
220 )));
221 }
222 };
223
224 let status = response.status();
225 if !status.is_success() {
226 return Err(YahooError::HttpError(
227 status.as_u16(),
228 format!(
229 "HTTP {}: {}",
230 status,
231 response.status().canonical_reason().unwrap_or("Unknown")
232 ),
233 ));
234 }
235
236 response.text().await.map_err(YahooError::NetworkError)
237 }
238
239 pub async fn fetch_response(&self, url: &str) -> Result<reqwest::Response, YahooError> {
241 let response = self
242 .client
243 .get(url)
244 .header(
245 "Accept",
246 "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
247 )
248 .header("Accept-Language", "en-US,en;q=0.9")
249 .header("Accept-Encoding", "gzip, deflate, br")
250 .header(
251 "sec-ch-ua",
252 r#""Chromium";v="122", "Google Chrome";v="122""#,
253 )
254 .header("sec-ch-ua-mobile", "?0")
255 .header("sec-ch-ua-platform", r#""Windows""#)
256 .send()
257 .await
258 .map_err(YahooError::NetworkError)?;
259
260 Ok(response)
261 }
262}