yf_as_dataframe 0.3.1 → 0.4.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.
@@ -1,3 +1,5 @@
1
+ require 'active_support'
2
+ require 'active_support/concern'
1
3
  # require 'requests'
2
4
  # require 'requests_cache'
3
5
  require 'thread'
@@ -5,10 +7,12 @@ require 'date'
5
7
  require 'nokogiri'
6
8
  require 'zache'
7
9
  require 'httparty'
10
+ require 'uri'
11
+ require 'json'
8
12
 
9
13
  class YfAsDataframe
10
14
  module YfConnection
11
- extend ActiveSupport::Concern
15
+ extend ::ActiveSupport::Concern
12
16
  # extend HTTParty
13
17
 
14
18
  # """
@@ -94,18 +98,50 @@ class YfAsDataframe
94
98
  @@cookie = nil
95
99
  @@cookie_strategy = 'basic'
96
100
  @@cookie_lock = ::Mutex.new()
101
+
102
+ # Add session tracking
103
+ @@session_created_at = Time.now
104
+ @@session_refresh_interval = 3600 # 1 hour
105
+ @@request_count = 0
106
+ @@last_request_time = nil
107
+
108
+ # Circuit breaker state
109
+ @@circuit_breaker_state = :closed # :closed, :open, :half_open
110
+ @@failure_count = 0
111
+ @@last_failure_time = nil
112
+ @@circuit_breaker_threshold = 3
113
+ @@circuit_breaker_timeout = 60 # seconds
114
+ @@circuit_breaker_base_timeout = 60 # seconds
97
115
  end
98
116
 
99
117
 
100
118
  def get(url, headers=nil, params=nil)
101
- # Important: treat input arguments as immutable.
102
- # Rails.logger.info { "#{__FILE__}:#{__LINE__} url = #{url}, headers = #{headers}, params=#{params.inspect}" }
119
+ # Check circuit breaker first
120
+ unless circuit_breaker_allow_request?
121
+ raise RuntimeError.new("Circuit breaker is open - too many recent failures. Please try again later.")
122
+ end
123
+
124
+ # Add request throttling to be respectful of rate limits
125
+ throttle_request
126
+
127
+ # Track session usage
128
+ track_session_usage
129
+
130
+ # Refresh session if needed
131
+ refresh_session_if_needed
132
+
133
+ # Only fetch crumb for /v7/finance/download endpoint
134
+ crumb_needed = url.include?('/v7/finance/download')
103
135
 
104
136
  headers ||= {}
105
137
  params ||= {}
106
- params.merge!(crumb: @@crumb) unless @@crumb.nil?
107
- cookie, crumb, strategy = _get_cookie_and_crumb()
108
- crumbs = !crumb.nil? ? {'crumb' => crumb} : {}
138
+ # params.merge!(crumb: @@crumb) unless @@crumb.nil? # Commented out: crumb not needed for most endpoints
139
+ if crumb_needed
140
+ crumb = get_crumb_scrape_quote_page(params[:symbol] || params['symbol'])
141
+ params.merge!(crumb: crumb) unless crumb.nil?
142
+ end
143
+ cookie, _, strategy = _get_cookie_and_crumb(crumb_needed)
144
+ crumbs = {} # crumb logic handled above if needed
109
145
 
110
146
  request_args = {
111
147
  url: url,
@@ -118,17 +154,24 @@ class YfAsDataframe
118
154
 
119
155
  cookie_hash = ::HTTParty::CookieHash.new
120
156
  cookie_hash.add_cookies(@@cookie)
121
- options = { headers: headers.dup.merge(@@user_agent_headers).merge({ 'cookie' => cookie_hash.to_cookie_string, 'crumb' => crumb })} #, debug_output: STDOUT }
157
+ options = { headers: headers.dup.merge(@@user_agent_headers).merge({ 'cookie' => cookie_hash.to_cookie_string })} #, debug_output: STDOUT }
122
158
 
123
159
  u = (request_args[:url]).dup.to_s
124
- joiner = ('?'.in?(request_args[:url]) ? '&' : '?')
125
- u += (joiner + CGI.unescape(request_args[:params].to_query)) unless request_args[:params].empty?
126
-
127
- # Rails.logger.info { "#{__FILE__}:#{__LINE__} u=#{u}, options = #{options.inspect}" }
128
- response = ::HTTParty.get(u, options)
129
- # Rails.logger.info { "#{__FILE__}:#{__LINE__} response=#{response.inspect}" }
160
+ joiner = (request_args[:url].include?('?') ? '&' : '?')
161
+ u += (joiner + URI.encode_www_form(request_args[:params])) unless request_args[:params].empty?
130
162
 
131
- return response
163
+ begin
164
+ response = ::HTTParty.get(u, options)
165
+ if response_failure?(response)
166
+ circuit_breaker_record_failure
167
+ raise RuntimeError.new("Yahoo Finance request failed: #{response.code} - #{response.body}")
168
+ end
169
+ circuit_breaker_record_success
170
+ return response
171
+ rescue => e
172
+ circuit_breaker_record_failure
173
+ raise e
174
+ end
132
175
  end
133
176
 
134
177
  alias_method :cache_get, :get
@@ -173,33 +216,17 @@ class YfAsDataframe
173
216
  end
174
217
  end
175
218
 
176
- def _get_cookie_and_crumb()
219
+ def _get_cookie_and_crumb(crumb_needed=false)
177
220
  cookie, crumb, strategy = nil, nil, nil
178
- # puts "cookie_mode = '#{@@cookie_strategy}'"
179
-
180
221
  @@cookie_lock.synchronize do
181
- if @@cookie_strategy == 'csrf'
182
- crumb = _get_crumb_csrf()
183
- if crumb.nil?
184
- # Fail
185
- _set_cookie_strategy('basic', have_lock=true)
186
- cookie, crumb = __get_cookie_and_crumb_basic()
187
- # Rails.logger.info { "#{__FILE__}:#{__LINE__} cookie = #{cookie}, crumb = #{crumb}" }
188
- end
189
- else
190
- # Fallback strategy
222
+ if crumb_needed
191
223
  cookie, crumb = __get_cookie_and_crumb_basic()
192
- # Rails.logger.info { "#{__FILE__}:#{__LINE__} cookie = #{cookie}, crumb = #{crumb}" }
193
- if cookie.nil? || crumb.nil?
194
- # Fail
195
- _set_cookie_strategy('csrf', have_lock=true)
196
- crumb = _get_crumb_csrf()
197
- end
224
+ else
225
+ cookie = _get_cookie_basic()
226
+ crumb = nil
198
227
  end
199
228
  strategy = @@cookie_strategy
200
229
  end
201
-
202
- # Rails.logger.info { "#{__FILE__}:#{__LINE__} cookie = #{cookie}, crumb = #{crumb}, strategy=#{strategy}" }
203
230
  return cookie, crumb, strategy
204
231
  end
205
232
 
@@ -229,18 +256,58 @@ class YfAsDataframe
229
256
 
230
257
  def _get_crumb_basic()
231
258
  return @@crumb unless @@crumb.nil?
232
- return nil if (cookie = _get_cookie_basic()).nil?
233
-
234
- cookie_hash = ::HTTParty::CookieHash.new
235
- cookie_hash.add_cookies(cookie)
236
- options = {headers: @@user_agent_headers.dup.merge(
237
- { 'cookie' => cookie_hash.to_cookie_string }
238
- )} #, debug_output: STDOUT }
239
-
240
- crumb_response = ::HTTParty.get('https://query1.finance.yahoo.com/v1/test/getcrumb', options)
241
- @@crumb = crumb_response.parsed_response
259
+
260
+ # Retry logic similar to yfinance: try up to 3 times
261
+ 3.times do |attempt|
262
+ begin
263
+ # Clear cookie on retry (except first attempt) to get fresh session
264
+ if attempt > 0
265
+ @@cookie = nil
266
+ # Clear curl-impersonate executables cache to force re-selection
267
+ CurlImpersonateIntegration.instance_variable_set(:@available_executables, nil)
268
+ warn "[yf_as_dataframe] Retrying crumb fetch (attempt #{attempt + 1}/3)"
269
+ # Add delay between retries to be respectful of rate limits
270
+ sleep(2 ** attempt) # Exponential backoff: 2s, 4s, 8s
271
+ end
272
+
273
+ return nil if (cookie = _get_cookie_basic()).nil?
274
+
275
+ cookie_hash = ::HTTParty::CookieHash.new
276
+ cookie_hash.add_cookies(cookie)
277
+ options = {headers: @@user_agent_headers.dup.merge(
278
+ { 'cookie' => cookie_hash.to_cookie_string }
279
+ )}
280
+
281
+ crumb_response = ::HTTParty.get('https://query1.finance.yahoo.com/v1/test/getcrumb', options)
282
+ @@crumb = crumb_response.parsed_response
283
+
284
+ # Validate crumb: must be short, alphanumeric, no spaces, not an error message
285
+ if crumb_valid?(@@crumb)
286
+ warn "[yf_as_dataframe] Successfully fetched valid crumb on attempt #{attempt + 1}"
287
+ return @@crumb
288
+ else
289
+ warn "[yf_as_dataframe] Invalid crumb received on attempt #{attempt + 1}: '#{@@crumb.inspect}'"
290
+ @@crumb = nil
291
+ end
292
+ rescue => e
293
+ warn "[yf_as_dataframe] Error fetching crumb on attempt #{attempt + 1}: #{e.message}"
294
+ @@crumb = nil
295
+ end
296
+ end
297
+
298
+ # All attempts failed
299
+ warn "[yf_as_dataframe] Failed to fetch valid crumb after 3 attempts"
300
+ raise "Could not fetch a valid Yahoo Finance crumb after 3 attempts"
301
+ end
242
302
 
243
- return (@@crumb.nil? || '<html>'.in?(@@crumb)) ? nil : @@crumb
303
+ def crumb_valid?(crumb)
304
+ return false if crumb.nil?
305
+ return false if crumb.include?('<html>')
306
+ return false if crumb.include?('Too Many Requests')
307
+ return false if crumb.strip.empty?
308
+ return false if crumb.length < 8 || crumb.length > 20
309
+ return false if crumb =~ /\s/
310
+ true
244
311
  end
245
312
 
246
313
  def _get_cookie_csrf()
@@ -310,7 +377,8 @@ class YfAsDataframe
310
377
  # puts 'reusing crumb'
311
378
  return @@crumb unless @@crumb.nil?
312
379
  # This cookie stored in session
313
- return nil unless _get_cookie_csrf().present?
380
+ cookie_csrf = _get_cookie_csrf()
381
+ return nil if cookie_csrf.nil? || (cookie_csrf.respond_to?(:empty?) && cookie_csrf.empty?)
314
382
 
315
383
  get_args = {
316
384
  url: 'https://query2.finance.yahoo.com/v1/test/getcrumb',
@@ -323,7 +391,7 @@ class YfAsDataframe
323
391
  @@crumb = r.text
324
392
 
325
393
  # puts "Didn't receive crumb"
326
- return nil if @@crumb.nil? || '<html>'.in?(@@crumb) || @@crumb.length.zero?
394
+ return nil if @@crumb.nil? || @@crumb.include?('<html>') || @@crumb.length.zero?
327
395
  return @@crumb
328
396
  end
329
397
 
@@ -358,5 +426,124 @@ class YfAsDataframe
358
426
  @@zache.put(:basic, nil, lifetime: 1) unless @@zache.exists?(:basic, dirty: false)
359
427
  return @@zache.expired?(:basic) ? nil : @@zache.get(:basic)
360
428
  end
429
+
430
+ def throttle_request
431
+ # Random delay between 0.1 and 0.5 seconds to be respectful of rate limits
432
+ # Similar to yfinance's approach
433
+ sleep(rand(0.1..0.5))
434
+ end
435
+
436
+ def track_session_usage
437
+ @@request_count += 1
438
+ @@last_request_time = Time.now
439
+ end
440
+
441
+ def refresh_session_if_needed
442
+ return unless session_needs_refresh?
443
+
444
+ warn "[yf_as_dataframe] Refreshing session (age: #{session_age} seconds, requests: #{@@request_count})"
445
+ refresh_session
446
+ end
447
+
448
+ def session_needs_refresh?
449
+ return true if session_age > @@session_refresh_interval
450
+ return true if @@request_count > 100 # Refresh after 100 requests
451
+ return true if @@cookie.nil? || @@crumb.nil?
452
+ false
453
+ end
454
+
455
+ def session_age
456
+ Time.now - @@session_created_at
457
+ end
458
+
459
+ def refresh_session
460
+ @@cookie = nil
461
+ @@crumb = nil
462
+ @@session_created_at = Time.now
463
+ @@request_count = 0
464
+ warn "[yf_as_dataframe] Session refreshed"
465
+ end
466
+
467
+ # Circuit breaker methods
468
+ def circuit_breaker_allow_request?
469
+ case @@circuit_breaker_state
470
+ when :closed
471
+ true
472
+ when :open
473
+ if Time.now - @@last_failure_time > @@circuit_breaker_timeout
474
+ @@circuit_breaker_state = :half_open
475
+ warn "[yf_as_dataframe] Circuit breaker transitioning to half-open"
476
+ true
477
+ else
478
+ false
479
+ end
480
+ when :half_open
481
+ true
482
+ end
483
+ end
484
+
485
+ def circuit_breaker_record_failure
486
+ @@failure_count += 1
487
+ @@last_failure_time = Time.now
488
+
489
+ if @@failure_count >= @@circuit_breaker_threshold && @@circuit_breaker_state != :open
490
+ @@circuit_breaker_state = :open
491
+ # Exponential backoff: 60s, 120s, 240s, 480s, etc.
492
+ @@circuit_breaker_timeout = @@circuit_breaker_base_timeout * (2 ** (@@failure_count - @@circuit_breaker_threshold))
493
+ warn "[yf_as_dataframe] Circuit breaker opened after #{@@failure_count} failures (timeout: #{@@circuit_breaker_timeout}s)"
494
+ end
495
+ end
496
+
497
+ def circuit_breaker_record_success
498
+ if @@circuit_breaker_state == :half_open
499
+ @@circuit_breaker_state = :closed
500
+ @@failure_count = 0
501
+ @@circuit_breaker_timeout = @@circuit_breaker_base_timeout
502
+ warn "[yf_as_dataframe] Circuit breaker closed after successful request"
503
+ elsif @@circuit_breaker_state == :closed
504
+ # Reset failure count on success
505
+ @@failure_count = 0
506
+ @@circuit_breaker_timeout = @@circuit_breaker_base_timeout
507
+ end
508
+ end
509
+
510
+ def response_failure?(response)
511
+ return true if response.nil?
512
+ return true if response.code >= 400
513
+ return true if response.body.to_s.include?("Too Many Requests")
514
+ return true if response.body.to_s.include?("Will be right back")
515
+ return true if response.body.to_s.include?("<html>")
516
+ false
517
+ end
518
+
519
+ def circuit_breaker_status
520
+ {
521
+ state: @@circuit_breaker_state,
522
+ failure_count: @@failure_count,
523
+ last_failure_time: @@last_failure_time,
524
+ timeout: @@circuit_breaker_timeout,
525
+ threshold: @@circuit_breaker_threshold
526
+ }
527
+ end
528
+
529
+ # For /v7/finance/download, scrape crumb from quote page
530
+ def get_crumb_scrape_quote_page(symbol)
531
+ return nil if symbol.nil?
532
+ url = "https://finance.yahoo.com/quote/#{symbol}"
533
+ response = ::HTTParty.get(url, headers: @@user_agent_headers)
534
+ # Look for root.App.main = { ... };
535
+ m = response.body.match(/root\.App\.main\s*=\s*(\{.*?\});/m)
536
+ return nil unless m
537
+ json_blob = m[1]
538
+ begin
539
+ data = JSON.parse(json_blob)
540
+ crumb = data.dig('context', 'dispatcher', 'stores', 'CrumbStore', 'crumb')
541
+ warn "[yf_as_dataframe] Scraped crumb from quote page: #{crumb.inspect}"
542
+ return crumb
543
+ rescue => e
544
+ warn "[yf_as_dataframe] Failed to parse crumb from quote page: #{e.message}"
545
+ return nil
546
+ end
547
+ end
361
548
  end
362
549
  end
@@ -0,0 +1,97 @@
1
+ # Minimal patch to make curl-impersonate the default behavior
2
+ # This file should be required after the main YfConnection class
3
+
4
+ require_relative 'curl_impersonate_integration'
5
+
6
+ class YfAsDataframe
7
+ module YfConnection
8
+ # Store original methods
9
+ alias_method :get_original, :get
10
+ alias_method :get_raw_json_original, :get_raw_json
11
+
12
+ # Override get method to use curl-impersonate by default
13
+ def get(url, headers=nil, params=nil)
14
+ # Debug output
15
+ puts "DEBUG: curl_impersonate_enabled = #{CurlImpersonateIntegration.curl_impersonate_enabled}"
16
+ puts "DEBUG: curl_impersonate_fallback = #{CurlImpersonateIntegration.curl_impersonate_fallback}"
17
+
18
+ # Try curl-impersonate first if enabled
19
+ if CurlImpersonateIntegration.curl_impersonate_enabled
20
+ puts "DEBUG: Trying curl-impersonate..."
21
+ begin
22
+ # Prepare headers and params as in original method
23
+ headers ||= {}
24
+ params ||= {}
25
+ params.merge!(crumb: @@crumb) unless @@crumb.nil?
26
+ cookie, crumb, strategy = _get_cookie_and_crumb()
27
+ crumbs = !crumb.nil? ? {'crumb' => crumb} : {}
28
+
29
+ # Prepare headers for curl-impersonate
30
+ curl_headers = headers.dup.merge(@@user_agent_headers)
31
+
32
+ # Add cookie if available
33
+ if cookie
34
+ cookie_hash = ::HTTParty::CookieHash.new
35
+ cookie_hash.add_cookies(cookie)
36
+ curl_headers['Cookie'] = cookie_hash.to_cookie_string
37
+ end
38
+
39
+ # Add crumb if available
40
+ curl_headers['crumb'] = crumb if crumb
41
+
42
+ # Make curl-impersonate request
43
+ response = CurlImpersonateIntegration.make_request(
44
+ url,
45
+ headers: curl_headers,
46
+ params: params.merge(crumbs),
47
+ timeout: CurlImpersonateIntegration.curl_impersonate_timeout
48
+ )
49
+
50
+ if response && response.success?
51
+ puts "DEBUG: curl-impersonate succeeded"
52
+ return response
53
+ else
54
+ puts "DEBUG: curl-impersonate returned nil or failed"
55
+ end
56
+ rescue => e
57
+ # Log error but continue to fallback
58
+ puts "DEBUG: curl-impersonate exception: #{e.message}"
59
+ warn "curl-impersonate request failed: #{e.message}" if $VERBOSE
60
+ end
61
+ else
62
+ puts "DEBUG: curl-impersonate is disabled, skipping to fallback"
63
+ end
64
+
65
+ # Fallback to original HTTParty method
66
+ if CurlImpersonateIntegration.curl_impersonate_fallback
67
+ puts "DEBUG: Using HTTParty fallback"
68
+ get_original(url, headers, params)
69
+ else
70
+ puts "DEBUG: Fallback is disabled, but forcing fallback anyway"
71
+ get_original(url, headers, params)
72
+ end
73
+ end
74
+
75
+ # get_raw_json uses get, so it automatically gets curl-impersonate behavior
76
+ # No need to override it separately
77
+
78
+ # Class-level configuration methods
79
+ class << self
80
+ def enable_curl_impersonate(enabled: true)
81
+ CurlImpersonateIntegration.curl_impersonate_enabled = enabled
82
+ end
83
+
84
+ def enable_curl_impersonate_fallback(enabled: true)
85
+ CurlImpersonateIntegration.curl_impersonate_fallback = enabled
86
+ end
87
+
88
+ def set_curl_impersonate_timeout(timeout)
89
+ CurlImpersonateIntegration.curl_impersonate_timeout = timeout
90
+ end
91
+
92
+ def get_available_curl_impersonate_executables
93
+ CurlImpersonateIntegration.available_executables
94
+ end
95
+ end
96
+ end
97
+ end
@@ -1,3 +1,5 @@
1
+ require 'logger'
2
+
1
3
  class YfAsDataframe
2
4
  class YfinanceException < StandardError
3
5
  attr_reader :msg
@@ -9,7 +11,7 @@ class YfAsDataframe
9
11
  class YFNotImplementedError < NotImplementedError
10
12
  def initialize(str)
11
13
  @msg = "Have not implemented fetching \"#{str}\" from Yahoo API"
12
- Rails.logger.warn { @msg }
14
+ Logger.new(STDOUT).warn { @msg }
13
15
  end
14
16
  end
15
17
  end
@@ -7,6 +7,8 @@ require_relative 'yf_as_dataframe/version'
7
7
  require_relative 'yf_as_dataframe/utils'
8
8
  require_relative 'yf_as_dataframe/yfinance_exception'
9
9
  require_relative 'yf_as_dataframe/yf_connection'
10
+ require_relative 'yf_as_dataframe/curl_impersonate_integration'
11
+ require_relative 'yf_as_dataframe/yf_connection_minimal_patch'
10
12
  require_relative 'yf_as_dataframe/price_technical'
11
13
  require_relative 'yf_as_dataframe/price_history'
12
14
  require_relative 'yf_as_dataframe/quote'
data/quick_test.rb ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Quick test for minimal curl-impersonate integration
4
+ # This test verifies the integration without making actual HTTP requests
5
+
6
+ puts "=== Quick Curl-Impersonate Integration Test ==="
7
+ puts
8
+
9
+ # Test 1: Check curl-impersonate integration module
10
+ puts "1. Testing curl-impersonate integration module..."
11
+ begin
12
+ require_relative 'lib/yf_as_dataframe/curl_impersonate_integration'
13
+
14
+ executables = YfAsDataframe::CurlImpersonateIntegration.available_executables
15
+ if executables.empty?
16
+ puts " ❌ No curl-impersonate executables found!"
17
+ exit 1
18
+ else
19
+ puts " ✅ Found #{executables.length} curl-impersonate executables"
20
+ puts " Sample: #{executables.first[:executable]} (#{executables.first[:browser]})"
21
+ end
22
+ rescue => e
23
+ puts " ❌ Error loading integration module: #{e.message}"
24
+ exit 1
25
+ end
26
+
27
+ puts
28
+
29
+ # Test 2: Test executable selection
30
+ puts "2. Testing executable selection..."
31
+ begin
32
+ executable = YfAsDataframe::CurlImpersonateIntegration.get_random_executable
33
+ if executable
34
+ puts " ✅ Random executable selected: #{executable[:executable]} (#{executable[:browser]})"
35
+ else
36
+ puts " ❌ No executable selected"
37
+ end
38
+ rescue => e
39
+ puts " ❌ Error selecting executable: #{e.message}"
40
+ end
41
+
42
+ puts
43
+
44
+ # Test 3: Test environment variable functionality
45
+ puts "3. Testing environment variable functionality..."
46
+ begin
47
+ default_dir = YfAsDataframe::CurlImpersonateIntegration.executable_directory
48
+ puts " ✅ Default directory: #{default_dir}"
49
+
50
+ # Test with a custom directory (should still use default if not set)
51
+ old_env = ENV['CURL_IMPERSONATE_DIR']
52
+ ENV['CURL_IMPERSONATE_DIR'] = '/nonexistent/path'
53
+
54
+ # Clear the cached executables to force re-discovery
55
+ YfAsDataframe::CurlImpersonateIntegration.instance_variable_set(:@available_executables, nil)
56
+
57
+ custom_dir = YfAsDataframe::CurlImpersonateIntegration.executable_directory
58
+ puts " ✅ Custom directory (set): #{custom_dir}"
59
+
60
+ # Restore original environment
61
+ if old_env
62
+ ENV['CURL_IMPERSONATE_DIR'] = old_env
63
+ else
64
+ ENV.delete('CURL_IMPERSONATE_DIR')
65
+ end
66
+
67
+ # Clear cache again
68
+ YfAsDataframe::CurlImpersonateIntegration.instance_variable_set(:@available_executables, nil)
69
+
70
+ restored_dir = YfAsDataframe::CurlImpersonateIntegration.executable_directory
71
+ puts " ✅ Restored directory: #{restored_dir}"
72
+
73
+ rescue => e
74
+ puts " ❌ Error testing environment variable: #{e.message}"
75
+ end
76
+
77
+ puts
78
+
79
+ # Test 4: Test minimal patch loading
80
+ puts "4. Testing minimal patch structure..."
81
+ begin
82
+ # This would normally require the full YfConnection class
83
+ # For this test, we'll just verify the patch file loads
84
+ require_relative 'lib/yf_as_dataframe/curl_impersonate_integration'
85
+ require_relative 'lib/yf_as_dataframe/yf_connection_minimal_patch'
86
+
87
+ puts " ✅ Minimal patch files load successfully"
88
+ puts " ✅ Integration module is available"
89
+ rescue => e
90
+ puts " ❌ Error loading minimal patch: #{e.message}"
91
+ end
92
+
93
+ puts
94
+
95
+ # Test 5: Test configuration
96
+ puts "5. Testing configuration..."
97
+ begin
98
+ puts " ✅ Configuration methods available:"
99
+ puts " - enable_curl_impersonate"
100
+ puts " - enable_curl_impersonate_fallback"
101
+ puts " - set_curl_impersonate_timeout"
102
+ puts " - get_available_curl_impersonate_executables"
103
+
104
+ # Test setting configuration
105
+ YfAsDataframe::CurlImpersonateIntegration.curl_impersonate_timeout = 20
106
+ puts " ✅ Configuration can be modified"
107
+ rescue => e
108
+ puts " ❌ Error with configuration: #{e.message}"
109
+ end
110
+
111
+ puts
112
+
113
+ # Test 6: Test command building (without execution)
114
+ puts "6. Testing command building..."
115
+ begin
116
+ executable = YfAsDataframe::CurlImpersonateIntegration.get_random_executable
117
+ if executable
118
+ # Build a command without executing it
119
+ cmd = [executable[:path], "--max-time", "5", "https://httpbin.org/get"]
120
+ puts " ✅ Command built: #{cmd.join(' ')}"
121
+ else
122
+ puts " ❌ Could not build command"
123
+ end
124
+ rescue => e
125
+ puts " ❌ Error building command: #{e.message}"
126
+ end
127
+
128
+ puts
129
+ puts "=== Quick Test Summary ==="
130
+ puts "✅ Integration module loads successfully"
131
+ puts "✅ Executables are detected"
132
+ puts "✅ Environment variable functionality works"
133
+ puts "✅ Configuration works"
134
+ puts "✅ Patch files load without errors"
135
+ puts
136
+ puts "The minimal curl-impersonate integration is ready for use!"
137
+ puts
138
+ puts "To integrate with your code:"
139
+ puts "require 'yf_as_dataframe/curl_impersonate_integration'"
140
+ puts "require 'yf_as_dataframe/yf_connection_minimal_patch'"
141
+ puts
142
+ puts "Environment variable support:"
143
+ puts "export CURL_IMPERSONATE_DIR='/custom/path' # Optional"