yahoo_finance_client 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2e9d16b1b8d40611c624f46e03ad7ca01e98ae8cbf58fe733c4ff72e37ee829
4
- data.tar.gz: 1d38747c9ff732268ad4d40121e4cfb058817ecc40df10f78d063949810f0fa4
3
+ metadata.gz: f3fe95366fd98eec61742aa8884d677461fce3101d9d06705db3138c9eb45209
4
+ data.tar.gz: e06b233e9d188b66ccb2419b3fcfa915555d79956730cc8b4eadf0cd1648f6e4
5
5
  SHA512:
6
- metadata.gz: eaf8f4c30384d727e32c4e3fa4e2e2877f16f157f20791cb7b2012e19fd9bbd77f466ea67a31b6887129b833901274ab756e65353fd677e80ee7f834c11a6fed
7
- data.tar.gz: 368918db13051ddfcc9297c956fb019bef8340ba2a3faedebf663a8c0667db26efdda55171cbbf31644297237c7782d5b5ec3707d0723527a934243ec5bf084a
6
+ metadata.gz: b34f6a47bc4288d7f90330ad0353b476ff948d3d00a237cd3a4ceb35cb44012b9b5b75dedef6b6f276632741d734f3d51dcbf0541bb6a3b34efd6d51cb01f3e0
7
+ data.tar.gz: acc8e07af8fbddd510181e8b78d761a6635022ef347a40909113abda79df62d40b34d0c051f45b73aea52c48c379c91e76291458a91d5d462f539daa7c941d4c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Change Log
2
2
 
3
+ ## [0.1.6] - 2025-02-18
4
+
5
+ - Adding a simple cache
6
+
7
+ ## [0.1.5] - 2025-02-18
8
+
9
+ - Change User Agent so Yahoo requests work
10
+
3
11
  ## [0.1.4] - 2025-02-18
4
12
 
5
13
  - Fix version & Gemfile.lock issues
data/CLAUDE.md ADDED
@@ -0,0 +1,49 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ YahooFinanceClient is a Ruby gem providing a client for the Yahoo! Finance API. It fetches stock quotes with built-in caching (5-minute TTL) and handles Yahoo's authentication flow (cookies + CSRF crumb tokens).
8
+
9
+ **Note**: Yahoo! may have disabled API access - this project is in a work-in-progress state.
10
+
11
+ ## Commands
12
+
13
+ | Command | Purpose |
14
+ |---------|---------|
15
+ | `rake` | Run tests and linting (default task) |
16
+ | `rake spec` | Run RSpec tests only |
17
+ | `rake rubocop` | Run RuboCop linter only |
18
+ | `bundle exec rspec spec/yahoo_finance_client/stock_spec.rb` | Run single test file |
19
+ | `bundle exec rake install` | Install gem locally |
20
+ | `bin/console` | Interactive Ruby console for experimentation |
21
+
22
+ ## Architecture
23
+
24
+ ```
25
+ lib/
26
+ ├── yahoo_finance_client.rb # Main module entry point
27
+ └── yahoo_finance_client/
28
+ ├── stock.rb # Core API client (class methods)
29
+ └── version.rb # Version constant
30
+ ```
31
+
32
+ **YahooFinanceClient::Stock** is the main class with a single public interface:
33
+ - `Stock.get_quote(symbol)` - Returns hash with `symbol`, `price`, `change`, `percent_change`, `volume`
34
+
35
+ The class handles Yahoo's auth flow internally: fetch cookie → get crumb token → make authenticated API request. Results are cached in a class-level hash with 300-second expiration.
36
+
37
+ ## Testing
38
+
39
+ Uses RSpec with WebMock for HTTP stubbing. Tests clear the cache before each example. Key test patterns:
40
+ - Stub HTTP requests, don't hit real API
41
+ - Test cache behavior via instance variable inspection (`@quotes_cache`)
42
+ - Contexts organize success/failure/cache scenarios
43
+
44
+ ## Code Style
45
+
46
+ - Ruby 3.4 target
47
+ - Max line length: 120
48
+ - Double quotes for strings
49
+ - Frozen string literals enabled
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A basic client to query Yahoo! Finance API.
4
4
 
5
- Work in process, it might work, or not :)
5
+ Work in process, it might work, or not. It seems that Yahoo! disabled that kind of API access lately.
6
6
 
7
7
  It was created to support https://github.com/fleveque/dividend-portfolio pet project.
8
8
 
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YahooFinanceClient
4
+ # Base error class for Yahoo Finance Client
5
+ class Error < StandardError; end
6
+
7
+ # Raised when authentication fails (invalid cookie/crumb)
8
+ class AuthenticationError < Error; end
9
+
10
+ # Raised when Yahoo Finance rate limits requests
11
+ class RateLimitError < Error; end
12
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require "singleton"
5
+
6
+ module YahooFinanceClient
7
+ # Handles Yahoo Finance authentication with multiple fallback strategies
8
+ class Session
9
+ include Singleton
10
+
11
+ SESSION_TTL = 60
12
+ USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0"
13
+ COOKIE_URL = "https://fc.yahoo.com"
14
+ CRUMB_URL_QUERY1 = "https://query1.finance.yahoo.com/v1/test/getcrumb"
15
+ CRUMB_URL_QUERY2 = "https://query2.finance.yahoo.com/v1/test/getcrumb"
16
+ HOMEPAGE_URL = "https://finance.yahoo.com"
17
+ CRUMB_PATTERNS = [/"crumb"\s*:\s*"([^"]+)"/, /"CrsrfToken"\s*:\s*"([^"]+)"/, /crumb=([a-zA-Z0-9_.~-]+)/].freeze
18
+
19
+ attr_reader :cookie, :crumb, :base_url
20
+
21
+ def initialize
22
+ reset!
23
+ end
24
+
25
+ def ensure_authenticated
26
+ return if valid_session?
27
+
28
+ authenticate!
29
+ end
30
+
31
+ def invalidate!
32
+ reset!
33
+ end
34
+
35
+ def valid_session?
36
+ return false unless @cookie && @crumb && @authenticated_at
37
+
38
+ Time.now - @authenticated_at < SESSION_TTL
39
+ end
40
+
41
+ private
42
+
43
+ def reset!
44
+ @cookie = nil
45
+ @crumb = nil
46
+ @authenticated_at = nil
47
+ @base_url = "https://query1.finance.yahoo.com"
48
+ end
49
+
50
+ def authenticate!
51
+ strategies = %i[strategy_fc_cookie_query1 strategy_homepage_scrape strategy_fc_cookie_query2]
52
+ strategies.each { |s| return @authenticated_at = Time.now if send(s) rescue nil } # rubocop:disable Style/RescueModifier
53
+ raise AuthenticationError, "All authentication strategies failed"
54
+ end
55
+
56
+ def strategy_fc_cookie_query1
57
+ apply_crumb_strategy(fetch_cookie_from_fc, CRUMB_URL_QUERY1, "https://query1.finance.yahoo.com")
58
+ end
59
+
60
+ def strategy_fc_cookie_query2
61
+ apply_crumb_strategy(fetch_cookie_from_fc, CRUMB_URL_QUERY2, "https://query2.finance.yahoo.com")
62
+ end
63
+
64
+ def apply_crumb_strategy(cookie, crumb_url, base)
65
+ return false unless cookie
66
+
67
+ crumb = fetch_crumb(cookie, crumb_url)
68
+ return false unless valid_crumb?(crumb)
69
+
70
+ @cookie = cookie
71
+ @crumb = crumb
72
+ @base_url = base
73
+ true
74
+ end
75
+
76
+ def strategy_homepage_scrape
77
+ response = HTTParty.get(HOMEPAGE_URL, headers: request_headers, follow_redirects: true)
78
+ return false unless response.success?
79
+
80
+ cookie = extract_cookie(response)
81
+ crumb = extract_crumb_from_html(response.body)
82
+ return false unless cookie && crumb
83
+
84
+ @cookie = cookie
85
+ @crumb = crumb
86
+ @base_url = "https://query1.finance.yahoo.com"
87
+ true
88
+ end
89
+
90
+ def fetch_cookie_from_fc
91
+ extract_cookie(HTTParty.get(COOKIE_URL, headers: request_headers))
92
+ end
93
+
94
+ def fetch_crumb(cookie, crumb_url)
95
+ response = HTTParty.get(crumb_url, headers: request_headers.merge("Cookie" => cookie))
96
+ response.success? ? response.body.strip : nil
97
+ end
98
+
99
+ def extract_cookie(response)
100
+ response.headers["set-cookie"]&.to_s
101
+ end
102
+
103
+ def extract_crumb_from_html(html)
104
+ CRUMB_PATTERNS.each { |p| (m = html.match(p)) && (return unescape_crumb(m[1])) }
105
+ nil
106
+ end
107
+
108
+ def unescape_crumb(crumb)
109
+ crumb.gsub(/\\u([0-9a-fA-F]{4})/) { [::Regexp.last_match(1).to_i(16)].pack("U") }
110
+ end
111
+
112
+ def valid_crumb?(crumb)
113
+ crumb && !crumb.empty? && !crumb.include?("<") && !crumb.include?("Unauthorized")
114
+ end
115
+
116
+ def request_headers
117
+ { "User-Agent" => USER_AGENT, "Accept" => "text/html,*/*;q=0.8", "Accept-Language" => "en-US,en;q=0.5" }
118
+ end
119
+ end
120
+ end
@@ -6,58 +6,79 @@ require "json"
6
6
  module YahooFinanceClient
7
7
  # This class provides methods to interact with Yahoo Finance API for stock data.
8
8
  class Stock
9
- BASE_URL = "https://query1.finance.yahoo.com/v7/finance/quote"
10
- COOKIE_URL = "https://fc.yahoo.com"
11
- CRUMB_URL = "https://query1.finance.yahoo.com/v1/test/getcrumb"
9
+ QUOTE_PATH = "/v7/finance/quote"
10
+ CACHE_TTL = 300
11
+ MAX_RETRIES = 2
12
+ AUTH_ERROR_PATTERNS = [/invalid cookie/i, /invalid crumb/i, /unauthorized/i].freeze
12
13
 
13
- USER_AGENT = "YahooFinanceClient/#{YahooFinanceClient::VERSION}".freeze
14
+ @cache = {}
14
15
 
15
- def self.get_quote(symbol)
16
- cookie = fetch_cookie
17
- crumb = fetch_crumb(cookie)
18
- url = build_url(symbol, crumb)
19
- response = HTTParty.get(url, headers: { "User-Agent" => USER_AGENT })
16
+ class << self
17
+ def get_quote(symbol)
18
+ cache_key = "quote_#{symbol}"
19
+ fetch_from_cache(cache_key) || fetch_and_cache(cache_key, symbol)
20
+ end
21
+
22
+ private
20
23
 
21
- if response.success?
22
- parse_response(response.body, symbol)
23
- else
24
- { error: "Yahoo Finance connection failed" }
24
+ def fetch_and_cache(cache_key, symbol)
25
+ data = fetch_quote_data(symbol)
26
+ store_in_cache(cache_key, data) if data[:error].nil?
27
+ data
25
28
  end
26
- end
27
29
 
28
- def self.build_url(symbol, crumb)
29
- "#{BASE_URL}?symbols=#{symbol}&crumb=#{crumb}"
30
- end
30
+ def fetch_quote_data(symbol)
31
+ retries = 0
32
+ begin
33
+ response = make_authenticated_request(symbol)
34
+ handle_response(response, symbol)
35
+ rescue AuthenticationError
36
+ retries += 1
37
+ retry if retries <= MAX_RETRIES
38
+ { error: "Authentication failed after #{MAX_RETRIES} retries" }
39
+ end
40
+ end
31
41
 
32
- def self.fetch_cookie
33
- response = HTTParty.get(COOKIE_URL, headers: { "User-Agent" => USER_AGENT })
34
- response.headers["set-cookie"]
35
- end
42
+ def make_authenticated_request(symbol)
43
+ session = Session.instance
44
+ session.ensure_authenticated
45
+ url = "#{session.base_url}#{QUOTE_PATH}?symbols=#{symbol}&crumb=#{session.crumb}"
46
+ HTTParty.get(url, headers: { "User-Agent" => Session::USER_AGENT, "Cookie" => session.cookie })
47
+ end
36
48
 
37
- def self.fetch_crumb(cookie)
38
- response = HTTParty.get(CRUMB_URL, headers: { "User-Agent" => USER_AGENT, "Cookie" => cookie })
39
- response.body
40
- end
49
+ def handle_response(response, symbol)
50
+ if auth_error?(response)
51
+ Session.instance.invalidate!
52
+ raise AuthenticationError, "Authentication failed"
53
+ end
54
+ response.success? ? parse_response(response.body, symbol) : { error: "Yahoo Finance connection failed" }
55
+ end
41
56
 
42
- def self.parse_response(body, symbol)
43
- data = JSON.parse(body)
44
- quote = data.dig("quoteResponse", "result", 0)
57
+ def auth_error?(response)
58
+ response.code == 401 || AUTH_ERROR_PATTERNS.any? { |p| response.body.to_s.match?(p) }
59
+ end
45
60
 
46
- if quote
47
- format_quote(quote)
48
- else
49
- { error: "No data was found for #{symbol}" }
61
+ def parse_response(body, symbol)
62
+ quote = JSON.parse(body).dig("quoteResponse", "result", 0)
63
+ quote ? format_quote(quote) : { error: "No data was found for #{symbol}" }
64
+ end
65
+
66
+ def format_quote(quote)
67
+ { symbol: quote["symbol"], price: quote["regularMarketPrice"], change: quote["regularMarketChange"],
68
+ percent_change: quote["regularMarketChangePercent"], volume: quote["regularMarketVolume"] }
50
69
  end
51
- end
52
70
 
53
- def self.format_quote(quote)
54
- {
55
- symbol: quote["symbol"],
56
- price: quote["regularMarketPrice"],
57
- change: quote["regularMarketChange"],
58
- percent_change: quote["regularMarketChangePercent"],
59
- volume: quote["regularMarketVolume"]
60
- }
71
+ def fetch_from_cache(key)
72
+ cached_entry = @cache[key]
73
+ return unless cached_entry && Time.now - cached_entry[:timestamp] < CACHE_TTL
74
+
75
+ cached_entry[:data]
76
+ end
77
+
78
+ def store_in_cache(key, data)
79
+ @cache.delete_if { |_, v| Time.now - v[:timestamp] >= CACHE_TTL } if @cache.size > 100
80
+ @cache[key] = { data: data, timestamp: Time.now }
81
+ end
61
82
  end
62
83
  end
63
84
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YahooFinanceClient
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "httparty"
3
4
  require "yahoo_finance_client/version"
5
+ require "yahoo_finance_client/errors"
6
+ require "yahoo_finance_client/session"
4
7
  require "yahoo_finance_client/stock"
5
- require "httparty"
6
8
 
7
9
  # Basic Yahoo! Finance client
8
10
  module YahooFinanceClient
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yahoo_finance_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Francesc Leveque
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-02-18 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: csv
@@ -48,10 +48,13 @@ files:
48
48
  - ".rspec"
49
49
  - ".rubocop.yml"
50
50
  - CHANGELOG.md
51
+ - CLAUDE.md
51
52
  - LICENSE.txt
52
53
  - README.md
53
54
  - Rakefile
54
55
  - lib/yahoo_finance_client.rb
56
+ - lib/yahoo_finance_client/errors.rb
57
+ - lib/yahoo_finance_client/session.rb
55
58
  - lib/yahoo_finance_client/stock.rb
56
59
  - lib/yahoo_finance_client/version.rb
57
60
  - sig/yahoo_finance_client.rbs
@@ -78,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
78
81
  - !ruby/object:Gem::Version
79
82
  version: '0'
80
83
  requirements: []
81
- rubygems_version: 3.6.2
84
+ rubygems_version: 3.6.7
82
85
  specification_version: 4
83
86
  summary: Basic Yahoo! Finance API client
84
87
  test_files: []