Skip to main content

finance_query_core/client/
scraper.rs

1//! Web scraping utilities for Yahoo Finance.
2//!
3//! This module provides functions for scraping data from Yahoo Finance web pages
4//! when API endpoints are not available or suitable.
5
6use 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
14/// Scrape quote data from Yahoo Finance web page.
15pub 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    // Extract company name from h1
27    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            // Format is usually "Company Name (SYMBOL)"
35            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    // Try multiple strategies to extract price data
47    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
74// Extract value from fin-streamer elements (current Yahoo format)
75fn 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        // Try data-value attribute first
80        el.value()
81            .attr("data-value")
82            .and_then(|v| v.parse::<f64>().ok())
83            .or_else(|| {
84                // Fallback to text content
85                let text: String = el.text().collect();
86                parse_numeric_value(&text)
87            })
88    })
89}
90
91// Fallback: Extract from old data-field format
92fn 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
100// Extract from JSON-LD structured data
101fn 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
124// Parse numeric value from string, handling commas, currency symbols, percentages
125fn 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
135/// Scrape simple quote data from Yahoo Finance web page.
136pub async fn scrape_simple_quote(
137    fetch_client: &Arc<FetchClient>,
138    symbol: &str,
139) -> Result<serde_json::Value, YahooError> {
140    // For simple quotes, we can use the same scraping logic but return less data
141    scrape_quote(fetch_client, symbol).await
142}
143
144// Helper function to extract a JSON object from a string
145fn 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
185// Helper function to find transcript data in nested JSON structure (root.App.main[0][3][1][0])
186fn find_transcript_in_nested_json(value: &Value) -> Option<Value> {
187    // Try common paths in Yahoo Finance structure
188    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            // Check if this contains transcript data
217            if current.get("transcriptContent").is_some() {
218                return Some(current.clone());
219            }
220            // Recursively search in this object
221            if let Some(transcript) = search_for_transcript(current) {
222                return Some(transcript);
223            }
224        }
225    }
226
227    // Fallback: recursive search
228    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
256/// Scrape list of earnings calls from Yahoo Finance.
257pub 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    // Get all links first (matching Python's approach: tree.xpath("//a/@href"))
269    let all_link_selector = Selector::parse("a[href]")
270        .map_err(|e| YahooError::ParseError(format!("Failed to parse all link selector: {}", e)))?;
271
272    // Collect all href attributes from all links
273    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    // Filter links containing "earnings_call"
281    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                // Skip duplicates
305                if seen_event_ids.contains(event_id) {
306                    continue;
307                }
308                seen_event_ids.insert(event_id.to_string());
309
310                // Extract quarter and year
311                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                // Build URL - handle both absolute and relative URLs
329                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        // Log sample links for debugging
350        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
366/// Scrape earnings transcript from a URL.
367pub 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    // Try to extract embedded JSON data from script tags
378    let script_selector = Selector::parse("script")
379        .map_err(|e| YahooError::ParseError(format!("Failed to parse script selector: {}", e)))?;
380
381    // Pre-compile regex outside the loop for Strategy 2
382    let transcript_content_regex = Regex::new(r#""transcriptContent"\s*:\s*\{[^}]*\}"#)
383        .map_err(|e| YahooError::ParseError(format!("Regex error: {}", e)))?;
384
385    // Look for script tags containing transcript data
386    for script in document.select(&script_selector) {
387        let script_text: String = script.text().collect();
388
389        // Look for common patterns like "transcriptContent" or "root.App.main"
390        if script_text.contains("transcriptContent") || script_text.contains("root.App.main") {
391            // Strategy 1: Look for root.App.main pattern
392            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                    // Find the assignment
396                    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                                // Navigate through the structure
402                                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            // Strategy 2: Direct transcriptContent pattern
415            if script_text.contains("transcriptContent") {
416                if let Some(captures) = transcript_content_regex.find(&script_text) {
417                    // Try to extract a larger JSON context
418                    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                    // Find the opening brace before transcriptContent
423                    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    // Fallback: Try to parse transcript from DOM structure
438    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                // Extract transcript from DOM
450                return extract_transcript_from_dom(&document, selector_str);
451            }
452        }
453    }
454
455    // If no structured data found, try to extract from common patterns
456    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    // Try to find speaker elements and transcript paragraphs
473    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                // Check if this element contains a speaker name
486                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(&current_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(&current_speaker).cloned().unwrap_or_else(|| "unknown".to_string()),
507                    "text": text
508                }));
509            }
510        }
511    }
512
513    // Build response structure matching API format
514    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    // Generic extraction - look for any text content that might be transcript
534    let mut paragraphs = Vec::new();
535    let mut speakers = Vec::new();
536
537    // Try to find any content that looks like a transcript
538    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                    // Likely a transcript if it's long
551                    // Split by common patterns (speaker names, timestamps, etc.)
552                    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                        // Check if line looks like a speaker name
562                        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(&current_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                            // Likely transcript text
587                            let speaker_id = if speakers.is_empty() {
588                                // Create a default speaker if none exists
589                                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                                // Find existing speaker or use the last one
601                                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    // Build response structure
631    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}