finance_query_core/client/
scraper.rs1use crate::client::error::YahooError;
7use crate::client::FetchClient;
8use regex::Regex;
9use scraper::{Html, Selector};
10use serde_json::Value;
11use std::sync::Arc;
12use tracing::{debug, warn};
13
14pub async fn scrape_quote(
16 fetch_client: &Arc<FetchClient>,
17 symbol: &str,
18) -> Result<serde_json::Value, YahooError> {
19 let url = format!("https://finance.yahoo.com/quote/{}/", symbol);
20 debug!("Scraping quote from: {}", url);
21 let html = fetch_client.fetch(&url).await?;
22 debug!("Fetched HTML, length: {} bytes", html.len());
23
24 let document = Html::parse_document(&html);
25
26 let name_selector = Selector::parse("h1")
28 .map_err(|e| YahooError::ParseError(format!("Failed to parse name selector: {}", e)))?;
29 let name = document
30 .select(&name_selector)
31 .next()
32 .map(|el| {
33 let full_text: String = el.text().collect();
34 full_text
36 .split('(')
37 .next()
38 .unwrap_or(&full_text)
39 .trim()
40 .to_string()
41 })
42 .unwrap_or_else(|| symbol.to_string());
43
44 debug!("Extracted name: {}", name);
45
46 let price = extract_fin_streamer_value(&document, "regularMarketPrice")
48 .or_else(|| extract_data_field_value(&document, "regularMarketPrice"))
49 .or_else(|| extract_from_json_ld(&document, "price"))
50 .unwrap_or(0.0);
51
52 let change = extract_fin_streamer_value(&document, "regularMarketChange")
53 .or_else(|| extract_data_field_value(&document, "regularMarketChange"))
54 .unwrap_or(0.0);
55
56 let percent_change = extract_fin_streamer_value(&document, "regularMarketChangePercent")
57 .or_else(|| extract_data_field_value(&document, "regularMarketChangePercent"))
58 .unwrap_or(0.0);
59
60 debug!(
61 "Extracted values - price: {}, change: {}, percent_change: {}",
62 price, change, percent_change
63 );
64
65 Ok(serde_json::json!({
66 "symbol": symbol.to_uppercase(),
67 "name": name,
68 "price": price,
69 "change": change,
70 "percent_change": percent_change,
71 }))
72}
73
74fn extract_fin_streamer_value(document: &Html, data_field: &str) -> Option<f64> {
76 let selector =
77 Selector::parse(&format!(r#"fin-streamer[data-field="{}"]"#, data_field)).ok()?;
78 document.select(&selector).next().and_then(|el| {
79 el.value()
81 .attr("data-value")
82 .and_then(|v| v.parse::<f64>().ok())
83 .or_else(|| {
84 let text: String = el.text().collect();
86 parse_numeric_value(&text)
87 })
88 })
89}
90
91fn extract_data_field_value(document: &Html, data_field: &str) -> Option<f64> {
93 let selector = Selector::parse(&format!(r#"span[data-field="{}"]"#, data_field)).ok()?;
94 document.select(&selector).next().and_then(|el| {
95 let text: String = el.text().collect();
96 parse_numeric_value(&text)
97 })
98}
99
100fn extract_from_json_ld(document: &Html, field: &str) -> Option<f64> {
102 let script_selector = Selector::parse(r#"script[type="application/ld+json"]"#).ok()?;
103
104 for script in document.select(&script_selector) {
105 let json_text: String = script.text().collect();
106 if let Ok(json) = serde_json::from_str::<Value>(&json_text) {
107 if field == "price" {
108 if let Some(price_val) = json
109 .get("price")
110 .or_else(|| json.get("offers").and_then(|o| o.get("price")))
111 {
112 if let Some(price_str) = price_val.as_str() {
113 return parse_numeric_value(price_str);
114 } else if let Some(price_num) = price_val.as_f64() {
115 return Some(price_num);
116 }
117 }
118 }
119 }
120 }
121 None
122}
123
124fn parse_numeric_value(text: &str) -> Option<f64> {
126 let cleaned = text
127 .trim()
128 .replace([',', '$', '%', '+'], "")
129 .trim()
130 .to_string();
131
132 cleaned.parse::<f64>().ok()
133}
134
135pub async fn scrape_simple_quote(
137 fetch_client: &Arc<FetchClient>,
138 symbol: &str,
139) -> Result<serde_json::Value, YahooError> {
140 scrape_quote(fetch_client, symbol).await
142}
143
144fn extract_json_object(json_str: &str) -> Result<Value, YahooError> {
146 let mut brace_count = 0;
147 let mut in_string = false;
148 let mut escape_next = false;
149 let mut json_end = 0;
150
151 for (i, ch) in json_str.char_indices() {
152 if escape_next {
153 escape_next = false;
154 continue;
155 }
156
157 match ch {
158 '\\' if in_string => escape_next = true,
159 '"' => in_string = !in_string,
160 '{' if !in_string => {
161 brace_count += 1;
162 }
163 '}' if !in_string => {
164 brace_count -= 1;
165 if brace_count == 0 {
166 json_end = i + 1;
167 break;
168 }
169 }
170 _ => {}
171 }
172 }
173
174 if json_end > 0 {
175 let json_slice = &json_str[..json_end];
176 serde_json::from_str(json_slice)
177 .map_err(|e| YahooError::ParseError(format!("Failed to parse JSON: {}", e)))
178 } else {
179 Err(YahooError::ParseError(
180 "Could not find complete JSON object".to_string(),
181 ))
182 }
183}
184
185fn find_transcript_in_nested_json(value: &Value) -> Option<Value> {
187 let paths = vec![
189 vec!["0", "3", "1", "0"],
190 vec!["0", "3", "1"],
191 vec!["0", "3"],
192 vec!["0"],
193 ];
194
195 for path in paths {
196 let mut current = value;
197 let mut found = true;
198
199 for key in &path {
200 if let Ok(index) = key.parse::<usize>() {
201 if let Some(arr) = current.as_array() {
202 if let Some(item) = arr.get(index) {
203 current = item;
204 } else {
205 found = false;
206 break;
207 }
208 } else {
209 found = false;
210 break;
211 }
212 }
213 }
214
215 if found {
216 if current.get("transcriptContent").is_some() {
218 return Some(current.clone());
219 }
220 if let Some(transcript) = search_for_transcript(current) {
222 return Some(transcript);
223 }
224 }
225 }
226
227 search_for_transcript(value)
229}
230
231fn search_for_transcript(value: &Value) -> Option<Value> {
232 match value {
233 Value::Object(map) => {
234 if map.contains_key("transcriptContent") {
235 return Some(Value::Object(map.clone()));
236 }
237 for v in map.values() {
238 if let Some(result) = search_for_transcript(v) {
239 return Some(result);
240 }
241 }
242 None
243 }
244 Value::Array(arr) => {
245 for item in arr {
246 if let Some(result) = search_for_transcript(item) {
247 return Some(result);
248 }
249 }
250 None
251 }
252 _ => None,
253 }
254}
255
256pub async fn scrape_earnings_calls_list(
258 fetch_client: &Arc<FetchClient>,
259 symbol: &str,
260) -> Result<Vec<serde_json::Value>, YahooError> {
261 let url = format!("https://finance.yahoo.com/quote/{}/earnings-calls/", symbol);
262 debug!("Fetching earnings calls page: {}", url);
263 let html = fetch_client.fetch(&url).await?;
264 debug!("Fetched HTML page, length: {} bytes", html.len());
265
266 let document = Html::parse_document(&html);
267
268 let all_link_selector = Selector::parse("a[href]")
270 .map_err(|e| YahooError::ParseError(format!("Failed to parse all link selector: {}", e)))?;
271
272 let all_links: Vec<String> = document
274 .select(&all_link_selector)
275 .filter_map(|link| link.value().attr("href").map(|s| s.to_string()))
276 .collect();
277
278 debug!("Found {} total links on the page", all_links.len());
279
280 let earnings_links: Vec<String> = all_links
282 .into_iter()
283 .filter(|link| link.contains("earnings_call"))
284 .collect();
285
286 debug!(
287 "Found {} links containing 'earnings_call'",
288 earnings_links.len()
289 );
290
291 let event_id_regex = Regex::new(r"earnings_call-(\d+)")
292 .map_err(|e| YahooError::ParseError(format!("Regex error: {}", e)))?;
293 let quarter_year_regex = Regex::new(r"-([Qq]\d)-(\d{4})-earnings_call")
294 .map_err(|e| YahooError::ParseError(format!("Regex error: {}", e)))?;
295
296 let mut calls = Vec::new();
297 let mut seen_event_ids = std::collections::HashSet::new();
298
299 for href in earnings_links {
300 if let Some(captures) = event_id_regex.captures(&href) {
301 if let Some(event_id_match) = captures.get(1) {
302 let event_id = event_id_match.as_str();
303
304 if seen_event_ids.contains(event_id) {
306 continue;
307 }
308 seen_event_ids.insert(event_id.to_string());
309
310 let quarter = quarter_year_regex
312 .captures(&href)
313 .and_then(|c| c.get(1))
314 .map(|m| m.as_str().to_uppercase());
315 let year = quarter_year_regex
316 .captures(&href)
317 .and_then(|c| c.get(2))
318 .and_then(|m| m.as_str().parse::<i32>().ok());
319
320 let quarter_clone = quarter.clone();
321 let year_clone = year;
322 let title = if let (Some(ref q), Some(y)) = (quarter, year) {
323 format!("{} {}", q, y)
324 } else {
325 "Earnings Call".to_string()
326 };
327
328 let url = if href.starts_with("http") {
330 href.clone()
331 } else {
332 format!("https://finance.yahoo.com{}", href)
333 };
334
335 calls.push(serde_json::json!({
336 "eventId": event_id,
337 "quarter": quarter_clone,
338 "year": year_clone,
339 "title": title,
340 "url": url,
341 }));
342 }
343 }
344 }
345
346 debug!("Parsed {} earnings calls from page", calls.len());
347 if calls.is_empty() {
348 warn!("No earnings_call links found on page. Page may require JavaScript rendering or structure has changed.");
349 let sample_links: Vec<String> = document
351 .select(&all_link_selector)
352 .take(20)
353 .filter_map(|link| link.value().attr("href").map(|s| s.to_string()))
354 .filter(|link| {
355 link.contains("earnings") || link.contains("transcript") || link.contains("call")
356 })
357 .collect();
358 if !sample_links.is_empty() {
359 debug!("Sample earnings-related links found: {:?}", sample_links);
360 }
361 }
362
363 Ok(calls)
364}
365
366pub async fn scrape_earnings_transcript_from_url(
368 fetch_client: &Arc<FetchClient>,
369 url: &str,
370) -> Result<Value, YahooError> {
371 debug!("Fetching earnings transcript from URL: {}", url);
372 let html = fetch_client.fetch(url).await?;
373 debug!("Fetched HTML page, length: {} bytes", html.len());
374
375 let document = Html::parse_document(&html);
376
377 let script_selector = Selector::parse("script")
379 .map_err(|e| YahooError::ParseError(format!("Failed to parse script selector: {}", e)))?;
380
381 let transcript_content_regex = Regex::new(r#""transcriptContent"\s*:\s*\{[^}]*\}"#)
383 .map_err(|e| YahooError::ParseError(format!("Regex error: {}", e)))?;
384
385 for script in document.select(&script_selector) {
387 let script_text: String = script.text().collect();
388
389 if script_text.contains("transcriptContent") || script_text.contains("root.App.main") {
391 if script_text.contains("root.App.main") {
393 if let Some(start) = script_text.find("root.App.main") {
394 let after_main = &script_text[start..];
395 if let Some(assign_pos) = after_main.find('=') {
397 let json_start = &after_main[assign_pos + 1..].trim_start();
398 if let Some(brace_start) = json_start.find('{') {
399 let json_str = &json_start[brace_start..];
400 if let Ok(parsed) = extract_json_object(json_str) {
401 if let Some(transcript_data) =
403 find_transcript_in_nested_json(&parsed)
404 {
405 debug!("Found transcript data in root.App.main");
406 return Ok(transcript_data);
407 }
408 }
409 }
410 }
411 }
412 }
413
414 if script_text.contains("transcriptContent") {
416 if let Some(captures) = transcript_content_regex.find(&script_text) {
417 let start = captures.start().saturating_sub(100);
419 let end = (captures.end() + 1000).min(script_text.len());
420 let json_candidate = &script_text[start..end];
421
422 if let Some(brace_pos) = json_candidate.rfind('{') {
424 let json_str = &json_candidate[brace_pos..];
425 if let Ok(parsed) = extract_json_object(json_str) {
426 if parsed.get("transcriptContent").is_some() {
427 debug!("Found transcript data via transcriptContent pattern");
428 return Ok(parsed);
429 }
430 }
431 }
432 }
433 }
434 }
435 }
436
437 let transcript_selectors = vec![
439 "div[data-module='Transcript']",
440 "div.transcript",
441 "div#transcript",
442 "section[data-testid='transcript']",
443 ];
444
445 for selector_str in transcript_selectors {
446 if let Ok(selector) = Selector::parse(selector_str) {
447 if document.select(&selector).next().is_some() {
448 debug!("Found transcript container with selector: {}", selector_str);
449 return extract_transcript_from_dom(&document, selector_str);
451 }
452 }
453 }
454
455 warn!("Could not find structured transcript data, attempting generic extraction");
457 extract_transcript_from_dom_generic(&document)
458}
459
460fn extract_transcript_from_dom(
461 document: &Html,
462 container_selector: &str,
463) -> Result<Value, YahooError> {
464 let container_sel = Selector::parse(container_selector).map_err(|e| {
465 YahooError::ParseError(format!("Failed to parse container selector: {}", e))
466 })?;
467
468 let mut paragraphs = Vec::new();
469 let mut speakers = Vec::new();
470 let mut speaker_mapping = std::collections::HashMap::new();
471
472 let speaker_selector = Selector::parse("div[class*='speaker'], span[class*='speaker'], strong")
474 .map_err(|e| YahooError::ParseError(format!("Failed to parse speaker selector: {}", e)))?;
475
476 let text_selector = Selector::parse("p, div[class*='text'], div[class*='paragraph']")
477 .map_err(|e| YahooError::ParseError(format!("Failed to parse text selector: {}", e)))?;
478
479 if let Some(container) = document.select(&container_sel).next() {
480 let mut current_speaker = "Unknown".to_string();
481
482 for element in container.select(&text_selector) {
483 let text = element.text().collect::<String>().trim().to_string();
484 if !text.is_empty() {
485 if let Some(speaker_elem) = element.select(&speaker_selector).next() {
487 let speaker_name = speaker_elem.text().collect::<String>().trim().to_string();
488 if !speaker_name.is_empty() {
489 current_speaker = speaker_name.clone();
490 if !speaker_mapping.contains_key(¤t_speaker) {
491 let speaker_id = format!("speaker_{}", speakers.len());
492 speaker_mapping.insert(current_speaker.clone(), speaker_id.clone());
493 speakers.push(serde_json::json!({
494 "speaker": speaker_id,
495 "speaker_data": {
496 "name": current_speaker,
497 "role": None::<String>,
498 "company": None::<String>
499 }
500 }));
501 }
502 }
503 }
504
505 paragraphs.push(serde_json::json!({
506 "speaker": speaker_mapping.get(¤t_speaker).cloned().unwrap_or_else(|| "unknown".to_string()),
507 "text": text
508 }));
509 }
510 }
511 }
512
513 Ok(serde_json::json!({
515 "transcriptContent": {
516 "speaker_mapping": speakers,
517 "transcript": {
518 "paragraphs": paragraphs
519 }
520 },
521 "transcriptMetadata": {
522 "fiscalYear": None::<i32>,
523 "fiscalPeriod": None::<String>,
524 "title": None::<String>,
525 "date": None::<i64>,
526 "eventType": "Earnings Call",
527 "isLatest": false
528 }
529 }))
530}
531
532fn extract_transcript_from_dom_generic(document: &Html) -> Result<Value, YahooError> {
533 let mut paragraphs = Vec::new();
535 let mut speakers = Vec::new();
536
537 let content_selectors = vec![
539 "div[class*='transcript']",
540 "div[class*='earnings']",
541 "article",
542 "main",
543 ];
544
545 for selector_str in content_selectors {
546 if let Ok(selector) = Selector::parse(selector_str) {
547 for container in document.select(&selector) {
548 let text = container.text().collect::<String>();
549 if text.len() > 1000 {
550 let lines: Vec<&str> = text.lines().collect();
553 let mut current_speaker = "Unknown".to_string();
554
555 for line in lines {
556 let trimmed = line.trim();
557 if trimmed.is_empty() {
558 continue;
559 }
560
561 if trimmed.len() < 50
563 && (trimmed
564 .chars()
565 .all(|c| c.is_uppercase() || c.is_whitespace() || c == ':')
566 || trimmed.ends_with(':'))
567 {
568 current_speaker = trimmed.trim_end_matches(':').trim().to_string();
569 if !speakers.iter().any(|s: &Value| {
570 s.get("speaker_data")
571 .and_then(|sd| sd.get("name"))
572 .and_then(|n| n.as_str())
573 == Some(¤t_speaker)
574 }) {
575 let speaker_id = format!("speaker_{}", speakers.len());
576 speakers.push(serde_json::json!({
577 "speaker": speaker_id,
578 "speaker_data": {
579 "name": current_speaker.clone(),
580 "role": None::<String>,
581 "company": None::<String>
582 }
583 }));
584 }
585 } else if trimmed.len() > 20 {
586 let speaker_id = if speakers.is_empty() {
588 let default_id = "speaker_0".to_string();
590 speakers.push(serde_json::json!({
591 "speaker": default_id.clone(),
592 "speaker_data": {
593 "name": current_speaker.clone(),
594 "role": None::<String>,
595 "company": None::<String>
596 }
597 }));
598 default_id
599 } else {
600 speakers
602 .iter()
603 .find(|s| {
604 s.get("speaker_data")
605 .and_then(|sd| sd.get("name"))
606 .and_then(|n| n.as_str())
607 .map(|n| n == current_speaker)
608 .unwrap_or(false)
609 })
610 .and_then(|s| s.get("speaker").and_then(|id| id.as_str()))
611 .map(|s| s.to_string())
612 .unwrap_or_else(|| format!("speaker_{}", speakers.len() - 1))
613 };
614
615 paragraphs.push(serde_json::json!({
616 "speaker": speaker_id,
617 "text": trimmed
618 }));
619 }
620 }
621
622 if !paragraphs.is_empty() {
623 break;
624 }
625 }
626 }
627 }
628 }
629
630 Ok(serde_json::json!({
632 "transcriptContent": {
633 "speaker_mapping": speakers,
634 "transcript": {
635 "paragraphs": paragraphs
636 }
637 },
638 "transcriptMetadata": {
639 "fiscalYear": None::<i32>,
640 "fiscalPeriod": None::<String>,
641 "title": None::<String>,
642 "date": None::<i64>,
643 "eventType": "Earnings Call",
644 "isLatest": false
645 }
646 }))
647}