mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
refactor: yahoo code restructure
This commit is contained in:
parent
343dac92ee
commit
4f338a92d6
3 changed files with 417 additions and 388 deletions
334
src/api/yahoo/client.rs
Normal file
334
src/api/yahoo/client.rs
Normal file
|
|
@ -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<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String, IdxError> {
|
||||||
|
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("<html>") || normalized.contains("<!doctype html") {
|
||||||
|
return Err(IdxError::Http(
|
||||||
|
"received HTML instead of a Yahoo crumb".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if normalized.contains("too many requests") {
|
||||||
|
return Err(IdxError::Http(
|
||||||
|
"Yahoo crumb request was rate limited".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(crumb.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cookie_header_from_jar(path: &Path) -> Result<String, IdxError> {
|
||||||
|
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<String> = 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<Output, IdxError> {
|
||||||
|
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<String, IdxError> {
|
||||||
|
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<String, IdxError> {
|
||||||
|
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<ChartResponse, IdxError> {
|
||||||
|
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::<ChartResponse>()
|
||||||
|
.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<QuoteSummaryResponse, IdxError> {
|
||||||
|
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::<QuoteSummaryResponse>()
|
||||||
|
.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("<html>blocked</html>")
|
||||||
|
.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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/api/yahoo/mod.rs
Normal file
49
src/api/yahoo/mod.rs
Normal file
|
|
@ -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<Quote, IdxError> {
|
||||||
|
let chart = self
|
||||||
|
.client
|
||||||
|
.fetch_chart(symbol, &Period::OneDay, &Interval::Day)?;
|
||||||
|
parse_quote(symbol, &chart)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
|
||||||
|
let quote_summary = self.client.fetch_quote_summary(symbol)?;
|
||||||
|
parse_fundamentals(symbol, "e_summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
let chart = self.client.fetch_chart(symbol, period, interval)?;
|
||||||
|
parse_history_with_verbose(&chart, self.verbose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,364 +1,10 @@
|
||||||
use std::collections::HashMap;
|
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 serde::Deserialize;
|
||||||
|
|
||||||
use crate::api::MarketDataProvider;
|
use crate::api::types::{Fundamentals, Ohlc, Quote};
|
||||||
use crate::api::types::{Fundamentals, Interval, Ohlc, Period, Quote};
|
|
||||||
use crate::error::IdxError;
|
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<Option<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<String, IdxError> {
|
|
||||||
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("<html>") || normalized.contains("<!doctype html") {
|
|
||||||
return Err(IdxError::Http(
|
|
||||||
"received HTML instead of a Yahoo crumb".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if normalized.contains("too many requests") {
|
|
||||||
return Err(IdxError::Http(
|
|
||||||
"Yahoo crumb request was rate limited".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(crumb.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cookie_header_from_jar(path: &Path) -> Result<String, IdxError> {
|
|
||||||
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<String> = 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<Output, IdxError> {
|
|
||||||
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<String, IdxError> {
|
|
||||||
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<String, IdxError> {
|
|
||||||
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<ChartResponse, IdxError> {
|
|
||||||
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::<ChartResponse>()
|
|
||||||
.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<QuoteSummaryResponse, IdxError> {
|
|
||||||
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::<QuoteSummaryResponse>()
|
|
||||||
.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<Quote, IdxError> {
|
|
||||||
let chart = self.fetch_chart(symbol, &Period::OneDay, &Interval::Day)?;
|
|
||||||
parse_quote(symbol, &chart)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError> {
|
|
||||||
let quote_summary = self.fetch_quote_summary(symbol)?;
|
|
||||||
parse_fundamentals(symbol, "e_summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn history(
|
|
||||||
&self,
|
|
||||||
symbol: &str,
|
|
||||||
period: &Period,
|
|
||||||
interval: &Interval,
|
|
||||||
) -> Result<Vec<Ohlc>, 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<Quote, IdxError> {
|
pub(crate) fn parse_quote_from_str(symbol: &str, raw: &str) -> Result<Quote, IdxError> {
|
||||||
let chart: ChartResponse =
|
let chart: ChartResponse =
|
||||||
serde_json::from_str(raw).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
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<Quote, Idx
|
||||||
parse_quote(symbol, &chart)
|
parse_quote(symbol, &chart)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||||
if let Some(err) = chart.chart.error.as_ref() {
|
if let Some(err) = chart.chart.error.as_ref() {
|
||||||
return Err(map_yahoo_error(symbol, "chart", err));
|
return Err(map_yahoo_error(symbol, "chart", err));
|
||||||
}
|
}
|
||||||
|
|
@ -382,7 +28,7 @@ fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||||
let meta = result.meta.as_ref().ok_or(IdxError::ProviderUnavailable)?;
|
let meta = result.meta.as_ref().ok_or(IdxError::ProviderUnavailable)?;
|
||||||
let raw_price = meta
|
let raw_price = meta
|
||||||
.regular_market_price
|
.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 raw_prev_close = meta.previous_close.or(meta.chart_previous_close);
|
||||||
|
|
||||||
let price = round_price(raw_price);
|
let price = round_price(raw_price);
|
||||||
|
|
@ -446,7 +92,10 @@ pub(crate) fn parse_fundamentals_from_str(
|
||||||
parse_fundamentals(symbol, "e_summary)
|
parse_fundamentals(symbol, "e_summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_history_with_verbose(chart: &ChartResponse, verbose: bool) -> Result<Vec<Ohlc>, IdxError> {
|
pub(super) fn parse_history_with_verbose(
|
||||||
|
chart: &ChartResponse,
|
||||||
|
verbose: bool,
|
||||||
|
) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
if let Some(err) = chart.chart.error.as_ref() {
|
if let Some(err) = chart.chart.error.as_ref() {
|
||||||
return Err(map_yahoo_error("unknown", "chart", err));
|
return Err(map_yahoo_error("unknown", "chart", err));
|
||||||
}
|
}
|
||||||
|
|
@ -522,7 +171,7 @@ fn parse_history_with_verbose(chart: &ChartResponse, verbose: bool) -> Result<Ve
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_fundamentals(
|
pub(super) fn parse_fundamentals(
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
quote_summary: &QuoteSummaryResponse,
|
quote_summary: &QuoteSummaryResponse,
|
||||||
) -> Result<Fundamentals, IdxError> {
|
) -> Result<Fundamentals, IdxError> {
|
||||||
|
|
@ -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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ChartResponse {
|
pub(super) struct ChartResponse {
|
||||||
chart: ChartRoot,
|
chart: ChartRoot,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct QuoteSummaryResponse {
|
pub(super) struct QuoteSummaryResponse {
|
||||||
quote_summary: QuoteSummaryRoot,
|
quote_summary: QuoteSummaryRoot,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct QuoteSummaryRoot {
|
pub(super) struct QuoteSummaryRoot {
|
||||||
result: Option<Vec<QuoteSummaryResult>>,
|
result: Option<Vec<QuoteSummaryResult>>,
|
||||||
error: Option<ChartError>,
|
error: Option<ChartError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct QuoteSummaryResult {
|
pub(super) struct QuoteSummaryResult {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
default_key_statistics: QuoteSummarySection,
|
default_key_statistics: QuoteSummarySection,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|
@ -602,20 +265,20 @@ struct QuoteSummaryResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ChartRoot {
|
pub(super) struct ChartRoot {
|
||||||
result: Option<Vec<ChartResult>>,
|
result: Option<Vec<ChartResult>>,
|
||||||
error: Option<ChartError>,
|
error: Option<ChartError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ChartError {
|
pub(super) struct ChartError {
|
||||||
code: String,
|
code: String,
|
||||||
description: String,
|
description: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ChartResult {
|
pub(super) struct ChartResult {
|
||||||
meta: Option<ChartMeta>,
|
meta: Option<ChartMeta>,
|
||||||
timestamp: Option<Vec<i64>>,
|
timestamp: Option<Vec<i64>>,
|
||||||
indicators: Option<Indicators>,
|
indicators: Option<Indicators>,
|
||||||
|
|
@ -624,7 +287,7 @@ struct ChartResult {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
struct ChartMeta {
|
pub(super) struct ChartMeta {
|
||||||
symbol: Option<String>,
|
symbol: Option<String>,
|
||||||
regular_market_price: Option<f64>,
|
regular_market_price: Option<f64>,
|
||||||
previous_close: Option<f64>,
|
previous_close: Option<f64>,
|
||||||
|
|
@ -640,12 +303,12 @@ struct ChartMeta {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct Indicators {
|
pub(super) struct Indicators {
|
||||||
quote: Option<Vec<IndicatorQuote>>,
|
quote: Option<Vec<IndicatorQuote>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct IndicatorQuote {
|
pub(super) struct IndicatorQuote {
|
||||||
open: Option<Vec<Option<f64>>>,
|
open: Option<Vec<Option<f64>>>,
|
||||||
high: Option<Vec<Option<f64>>>,
|
high: Option<Vec<Option<f64>>>,
|
||||||
low: Option<Vec<Option<f64>>>,
|
low: Option<Vec<Option<f64>>>,
|
||||||
|
|
@ -681,7 +344,7 @@ impl QuoteSummarySectionExt for QuoteSummarySection {
|
||||||
enum QuoteSummaryValue {
|
enum QuoteSummaryValue {
|
||||||
Wrapped { raw: Option<YahooNumber> },
|
Wrapped { raw: Option<YahooNumber> },
|
||||||
Direct(YahooNumber),
|
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),
|
Unknown(serde_json::Value),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -749,7 +412,7 @@ impl YahooNumber {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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,
|
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");
|
let err = parse_quote_from_str("INVALID.JK", raw).expect_err("expected symbol error");
|
||||||
assert!(matches!(err, crate::error::IdxError::SymbolNotFound(_)));
|
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("<html>blocked</html>")
|
|
||||||
.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(_)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue