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 +4 -4
- data/CHANGELOG.md +8 -0
- data/CLAUDE.md +49 -0
- data/README.md +1 -1
- data/lib/yahoo_finance_client/errors.rb +12 -0
- data/lib/yahoo_finance_client/session.rb +120 -0
- data/lib/yahoo_finance_client/stock.rb +62 -41
- data/lib/yahoo_finance_client/version.rb +1 -1
- data/lib/yahoo_finance_client.rb +3 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3fe95366fd98eec61742aa8884d677461fce3101d9d06705db3138c9eb45209
|
|
4
|
+
data.tar.gz: e06b233e9d188b66ccb2419b3fcfa915555d79956730cc8b4eadf0cd1648f6e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b34f6a47bc4288d7f90330ad0353b476ff948d3d00a237cd3a4ceb35cb44012b9b5b75dedef6b6f276632741d734f3d51dcbf0541bb6a3b34efd6d51cb01f3e0
|
|
7
|
+
data.tar.gz: acc8e07af8fbddd510181e8b78d761a6635022ef347a40909113abda79df62d40b34d0c051f45b73aea52c48c379c91e76291458a91d5d462f539daa7c941d4c
|
data/CHANGELOG.md
CHANGED
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
+
@cache = {}
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
data/lib/yahoo_finance_client.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
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.
|
|
84
|
+
rubygems_version: 3.6.7
|
|
82
85
|
specification_version: 4
|
|
83
86
|
summary: Basic Yahoo! Finance API client
|
|
84
87
|
test_files: []
|