diff --git a/app/models/provider/yahoo_finance.rb b/app/models/provider/yahoo_finance.rb index 75b82520c..d4a992184 100644 --- a/app/models/provider/yahoo_finance.rb +++ b/app/models/provider/yahoo_finance.rb @@ -25,12 +25,13 @@ class Provider::YahooFinance < Provider # Pool of modern browser user-agents to rotate through # Based on https://github.com/ranaroussi/yfinance/pull/2277 + # UPDATED user-agents string on 2026-02-27 with current versions of browsers (Chrome 145, Firefox 148, Safari 26) USER_AGENTS = [ - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0" + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0" ].freeze def initialize @@ -39,21 +40,11 @@ class Provider::YahooFinance < Provider end def healthy? - begin - # Test with a known stable ticker (Apple) - response = client.get("#{base_url}/v8/finance/chart/AAPL") do |req| - req.params["interval"] = "1d" - req.params["range"] = "1d" - end - - data = JSON.parse(response.body) - result = data.dig("chart", "result") - health_status = result.present? && result.any? - - health_status - rescue => e - false - end + data = fetch_authenticated_chart("AAPL", { "interval" => "1d", "range" => "1d" }) + result = data.dig("chart", "result") + result.present? && result.any? + rescue => e + false end def usage @@ -201,6 +192,9 @@ class Provider::YahooFinance < Provider req.params["crumb"] = crumb end data = JSON.parse(response.body) + if data.dig("quoteSummary", "error", "code") == "Unauthorized" + raise AuthenticationError, "Yahoo Finance authentication failed after crumb refresh" + end end result = data.dig("quoteSummary", "result", 0) @@ -271,14 +265,13 @@ class Provider::YahooFinance < Provider period2 = end_date.end_of_day.to_time.utc.to_i throttle_request - response = client.get("#{base_url}/v8/finance/chart/#{symbol}") do |req| - req.params["period1"] = period1 - req.params["period2"] = period2 - req.params["interval"] = "1d" - req.params["includeAdjustedClose"] = true - end + data = fetch_authenticated_chart(symbol, { + "period1" => period1, + "period2" => period2, + "interval" => "1d", + "includeAdjustedClose" => true + }) - data = JSON.parse(response.body) chart_data = data.dig("chart", "result", 0) raise Error, "No chart data found for #{symbol}" unless chart_data @@ -452,24 +445,48 @@ class Provider::YahooFinance < Provider rates end + # Makes a single authenticated GET to /v8/finance/chart/:symbol. + # If Yahoo returns a stale-crumb error (200 OK with Unauthorized body), + # clears the crumb cache and retries once with fresh credentials. + def fetch_authenticated_chart(symbol, params) + cookie, crumb = fetch_cookie_and_crumb + response = authenticated_client(cookie).get("#{base_url}/v8/finance/chart/#{symbol}") do |req| + params.each { |k, v| req.params[k] = v } + req.params["crumb"] = crumb + end + data = JSON.parse(response.body) + + if data.dig("chart", "error", "code") == "Unauthorized" + clear_crumb_cache + cookie, crumb = fetch_cookie_and_crumb + response = authenticated_client(cookie).get("#{base_url}/v8/finance/chart/#{symbol}") do |req| + params.each { |k, v| req.params[k] = v } + req.params["crumb"] = crumb + end + data = JSON.parse(response.body) + if data.dig("chart", "error", "code") == "Unauthorized" + raise AuthenticationError, "Yahoo Finance authentication failed after crumb refresh" + end + end + + data + end + def fetch_chart_data(symbol, start_date, end_date, &block) period1 = start_date.to_time.utc.to_i period2 = end_date.end_of_day.to_time.utc.to_i begin throttle_request - response = client.get("#{base_url}/v8/finance/chart/#{symbol}") do |req| - req.params["period1"] = period1 - req.params["period2"] = period2 - req.params["interval"] = "1d" - req.params["includeAdjustedClose"] = true - end - - data = JSON.parse(response.body) + data = fetch_authenticated_chart(symbol, { + "period1" => period1, + "period2" => period2, + "interval" => "1d", + "includeAdjustedClose" => true + }) # Check for Yahoo Finance errors if data.dig("chart", "error") - error_msg = data.dig("chart", "error", "description") || "Unknown Yahoo Finance error" return nil end @@ -489,7 +506,7 @@ class Provider::YahooFinance < Provider end results.sort_by(&:date) - rescue Faraday::Error => e + rescue Faraday::Error, JSON::ParserError => e nil end end diff --git a/test/models/provider/yahoo_finance_test.rb b/test/models/provider/yahoo_finance_test.rb index e7a569b65..6dd0d30a2 100644 --- a/test/models/provider/yahoo_finance_test.rb +++ b/test/models/provider/yahoo_finance_test.rb @@ -10,24 +10,42 @@ class Provider::YahooFinanceTest < ActiveSupport::TestCase # ================================ test "healthy? returns true when API is working" do - # Mock successful response mock_response = mock mock_response.stubs(:body).returns('{"chart":{"result":[{"meta":{"symbol":"AAPL"}}]}}') - @provider.stubs(:client).returns(mock_client = mock) + @provider.stubs(:fetch_cookie_and_crumb).returns([ "test_cookie", "test_crumb" ]) + @provider.stubs(:authenticated_client).returns(mock_client = mock) mock_client.stubs(:get).returns(mock_response) assert @provider.healthy? end test "healthy? returns false when API fails" do - # Mock failed response - @provider.stubs(:client).returns(mock_client = mock) - mock_client.stubs(:get).raises(Faraday::Error.new("Connection failed")) + @provider.stubs(:fetch_cookie_and_crumb).raises(Provider::YahooFinance::AuthenticationError.new("auth failed")) assert_not @provider.healthy? end + test "healthy? retries with fresh crumb on Unauthorized body response" do + unauthorized_body = '{"chart":{"error":{"code":"Unauthorized","description":"No crumb"}}}' + success_body = '{"chart":{"result":[{"meta":{"symbol":"AAPL"}}]}}' + + unauthorized_response = mock + unauthorized_response.stubs(:body).returns(unauthorized_body) + + success_response = mock + success_response.stubs(:body).returns(success_body) + + mock_client = mock + mock_client.stubs(:get).returns(unauthorized_response, success_response) + + @provider.stubs(:fetch_cookie_and_crumb).returns([ "cookie1", "crumb1" ], [ "cookie2", "crumb2" ]) + @provider.stubs(:authenticated_client).returns(mock_client) + @provider.expects(:clear_crumb_cache).once + + assert @provider.healthy? + end + # ================================ # Exchange Rate Tests # ================================