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