yahoo_finance_client 0.1.6 → 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: 88b5aae44ab5936bfba0fec79d7497662637c55bf54965dfe11a20eb1facc762
4
- data.tar.gz: 77e5908ab205b355680db2eb81d290284ef98b629bf01b4eff3a27204d657682
3
+ metadata.gz: f3fe95366fd98eec61742aa8884d677461fce3101d9d06705db3138c9eb45209
4
+ data.tar.gz: e06b233e9d188b66ccb2419b3fcfa915555d79956730cc8b4eadf0cd1648f6e4
5
5
  SHA512:
6
- metadata.gz: d43ddf2d142468824d65b967b992621d956fc2e3dde5cc109821b1068ab73ebc3201ec5f53973fdcb0a459d941c38df47e2cbab4b002a06a78f4af082bf36456
7
- data.tar.gz: 2628a14bc00d61a618d87a929d198749cb0661d69aa6e95ea18e1751b394003c306b68d855ea7ba3b4c6344db33af210eed74bbc4eabe6e673a480aca10874c3
6
+ metadata.gz: b34f6a47bc4288d7f90330ad0353b476ff948d3d00a237cd3a4ceb35cb44012b9b5b75dedef6b6f276632741d734f3d51dcbf0541bb6a3b34efd6d51cb01f3e0
7
+ data.tar.gz: acc8e07af8fbddd510181e8b78d761a6635022ef347a40909113abda79df62d40b34d0c051f45b73aea52c48c379c91e76291458a91d5d462f539daa7c941d4c
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
@@ -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,90 +6,77 @@ 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"
12
- USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64)"
13
- CACHE_TTL = 300 # Cache time-to-live in seconds (e.g., 5 minutes)
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
14
13
 
15
14
  @cache = {}
16
15
 
17
16
  class << self
18
17
  def get_quote(symbol)
19
18
  cache_key = "quote_#{symbol}"
20
- cached_data = fetch_from_cache(cache_key)
19
+ fetch_from_cache(cache_key) || fetch_and_cache(cache_key, symbol)
20
+ end
21
21
 
22
- return cached_data if cached_data
22
+ private
23
23
 
24
+ def fetch_and_cache(cache_key, symbol)
24
25
  data = fetch_quote_data(symbol)
25
26
  store_in_cache(cache_key, data) if data[:error].nil?
26
27
  data
27
28
  end
28
29
 
29
- private
30
-
31
30
  def fetch_quote_data(symbol)
32
- cookie = fetch_cookie
33
- crumb = fetch_crumb(cookie)
34
- url = build_url(symbol, crumb)
35
- pp url
36
- response = HTTParty.get(url, headers: { "User-Agent" => USER_AGENT })
37
-
38
- if response.success?
39
- parse_response(response.body, symbol)
40
- else
41
- { error: "Yahoo Finance connection failed" }
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" }
42
39
  end
43
40
  end
44
41
 
45
- def build_url(symbol, crumb)
46
- "#{BASE_URL}?symbols=#{symbol}&crumb=#{crumb}"
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
47
  end
48
48
 
49
- def fetch_cookie
50
- response = HTTParty.get(COOKIE_URL, headers: { "User-Agent" => USER_AGENT })
51
- response.headers["set-cookie"]
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" }
52
55
  end
53
56
 
54
- def fetch_crumb(cookie)
55
- response = HTTParty.get(CRUMB_URL, headers: { "User-Agent" => USER_AGENT, "Cookie" => cookie })
56
- response.body
57
+ def auth_error?(response)
58
+ response.code == 401 || AUTH_ERROR_PATTERNS.any? { |p| response.body.to_s.match?(p) }
57
59
  end
58
60
 
59
61
  def parse_response(body, symbol)
60
- data = JSON.parse(body)
61
- quote = data.dig("quoteResponse", "result", 0)
62
-
63
- if quote
64
- format_quote(quote)
65
- else
66
- { error: "No data was found for #{symbol}" }
67
- end
62
+ quote = JSON.parse(body).dig("quoteResponse", "result", 0)
63
+ quote ? format_quote(quote) : { error: "No data was found for #{symbol}" }
68
64
  end
69
65
 
70
66
  def format_quote(quote)
71
- {
72
- symbol: quote["symbol"],
73
- price: quote["regularMarketPrice"],
74
- change: quote["regularMarketChange"],
75
- percent_change: quote["regularMarketChangePercent"],
76
- volume: quote["regularMarketVolume"]
77
- }
67
+ { symbol: quote["symbol"], price: quote["regularMarketPrice"], change: quote["regularMarketChange"],
68
+ percent_change: quote["regularMarketChangePercent"], volume: quote["regularMarketVolume"] }
78
69
  end
79
70
 
80
71
  def fetch_from_cache(key)
81
72
  cached_entry = @cache[key]
82
- return unless cached_entry
73
+ return unless cached_entry && Time.now - cached_entry[:timestamp] < CACHE_TTL
83
74
 
84
- if Time.now - cached_entry[:timestamp] < CACHE_TTL
85
- cached_entry[:data]
86
- else
87
- @cache.delete(key)
88
- nil
89
- end
75
+ cached_entry[:data]
90
76
  end
91
77
 
92
78
  def store_in_cache(key, data)
79
+ @cache.delete_if { |_, v| Time.now - v[:timestamp] >= CACHE_TTL } if @cache.size > 100
93
80
  @cache[key] = { data: data, timestamp: Time.now }
94
81
  end
95
82
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module YahooFinanceClient
4
- VERSION = "0.1.6"
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.6
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: []