ruby-edgar 0.1.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.
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ # Enumerable collection of Filing objects with filter, sort, and summary.
5
+ class Filings
6
+ include Enumerable
7
+
8
+ attr_reader :entries, :original_state, :per_page
9
+
10
+ def initialize(entries = [], original_state: {}, page_index: 0, per_page: 100,
11
+ all_entries: nil)
12
+ @page_index = page_index
13
+ @per_page = per_page
14
+ @all_entries = all_entries
15
+ @entries = entries.map do |e|
16
+ if e.is_a?(Filing)
17
+ e
18
+ elsif e.is_a?(Hash) || e.is_a?(FilingIndex::IndexEntry)
19
+ Filing.new(
20
+ cik: e[:cik] || e["cik"],
21
+ company: e[:company] || e["company"],
22
+ form: e[:form] || e["form"],
23
+ filing_date: e[:filing_date] || e["filing_date"],
24
+ accession_number: e[:accession_number] || e["accession_number"]
25
+ )
26
+ end
27
+ end.compact
28
+ @all_entries ||= @entries
29
+ @original_state = original_state
30
+ end
31
+
32
+ def each(&block)
33
+ @entries.each(&block)
34
+ end
35
+
36
+ def [](index)
37
+ if index.is_a?(Integer)
38
+ @entries[index]
39
+ else
40
+ @entries.find { |f| f.accession_number == index }
41
+ end
42
+ end
43
+
44
+ def length
45
+ @entries.length
46
+ end
47
+
48
+ def empty?
49
+ @entries.empty?
50
+ end
51
+
52
+ def latest(count = 1)
53
+ sorted = @entries.sort_by(&:filing_date).reverse
54
+ selected = sorted.first(count)
55
+ return selected.first if count == 1 && selected.length == 1
56
+
57
+ Filings.new(selected, original_state: @original_state)
58
+ end
59
+
60
+ def head(count = 10)
61
+ Filings.new(@entries.first(count), original_state: @original_state)
62
+ end
63
+
64
+ def tail(count = 10)
65
+ Filings.new(@entries.last(count), original_state: @original_state)
66
+ end
67
+
68
+ def sample(count = 1)
69
+ Filings.new(@entries.sample(count), original_state: @original_state)
70
+ end
71
+
72
+ def filter(form: nil, filing_date: nil, date: nil, cik: nil, ticker: nil,
73
+ exchange: nil, company_name: nil,
74
+ amendments: nil, date_start: nil, date_end: nil)
75
+ filtered = @entries.select do |f|
76
+ matches_filters?(f,
77
+ form: form, cik: cik, ticker: ticker, company_name: company_name,
78
+ exchange: exchange, filing_date: filing_date || date,
79
+ date_start: date_start, date_end: date_end,
80
+ amendments: amendments)
81
+ end
82
+
83
+ Filings.new(filtered, original_state: @original_state)
84
+ end
85
+
86
+ def find_by_company(company_name)
87
+ filter(company_name: company_name)
88
+ end
89
+
90
+ def current_page
91
+ @page_index + 1
92
+ end
93
+
94
+ def page(num = nil, per_page: nil)
95
+ p = num || (@page_index + 1)
96
+ pp = per_page || @per_page
97
+ start_idx = (p - 1) * pp
98
+ page_entries = @all_entries[start_idx, pp] || []
99
+ Filings.new(page_entries, original_state: @original_state,
100
+ page_index: p - 1, per_page: pp,
101
+ all_entries: @all_entries)
102
+ end
103
+
104
+ def next_page
105
+ next_start = (@page_index + 1) * @per_page
106
+ return self if next_start >= @all_entries.length
107
+
108
+ page(@page_index + 2)
109
+ end
110
+
111
+ def previous_page
112
+ return self if @page_index.zero?
113
+
114
+ page(@page_index)
115
+ end
116
+
117
+ def total_pages(per_page: nil)
118
+ pp = per_page || @per_page
119
+ pp.positive? ? (@all_entries.length.to_f / pp).ceil : 0
120
+ end
121
+
122
+ def group_by_form
123
+ @entries.group_by(&:form)
124
+ end
125
+
126
+ def to_a
127
+ @entries.dup
128
+ end
129
+
130
+ def to_hashes
131
+ @entries.map do |f|
132
+ {
133
+ cik: f.cik,
134
+ company: f.company,
135
+ form: f.form,
136
+ filing_date: f.filing_date,
137
+ accession_number: f.accession_number
138
+ }
139
+ end
140
+ end
141
+
142
+ def to_dict
143
+ to_hashes
144
+ end
145
+
146
+ def date_range
147
+ return nil if @entries.empty?
148
+
149
+ dates = @entries.map(&:filing_date)
150
+ { min: dates.min, max: dates.max }
151
+ end
152
+
153
+ def self.from_quarter(year, quarter, type: "company")
154
+ entries = FilingIndex.for(year, quarter, type: type)
155
+ new(entries)
156
+ end
157
+
158
+ def self.from_years(years, quarters: [1, 2, 3, 4])
159
+ years = Array(years)
160
+ all = years.flat_map do |y|
161
+ quarters.flat_map { |q| FilingIndex.for(y, q) }
162
+ end
163
+ new(all)
164
+ end
165
+
166
+ def summary
167
+ form_counts = group_by_form.transform_values(&:length)
168
+ date_range_str = if @entries.any?
169
+ dates = @entries.map(&:filing_date)
170
+ "#{dates.min} - #{dates.max}"
171
+ else
172
+ "N/A"
173
+ end
174
+
175
+ {
176
+ count: @entries.length,
177
+ date_range: date_range_str,
178
+ form_counts: form_counts
179
+ }
180
+ end
181
+
182
+ private
183
+
184
+ def matches_filters?(filing, form: nil, cik: nil, ticker: nil, company_name: nil,
185
+ exchange: nil, filing_date: nil,
186
+ date_start: nil, date_end: nil, amendments: nil)
187
+ if form
188
+ form_list = Array(form)
189
+ # Match Python edgartools default behavior: include amendment (/A) variants
190
+ # unless amendments are explicitly excluded.
191
+ form_list = form_list.flat_map { |f| [f, "#{f}/A"] }.uniq unless amendments == false
192
+ return false unless form_list.include?(filing.form)
193
+ end
194
+ return false if cik && filing.cik != cik.to_i
195
+ return false if ticker && !filing.company&.downcase&.include?(ticker.downcase)
196
+ return false if company_name && !filing.company&.downcase&.include?(company_name.downcase)
197
+ return false if exchange && !exchange_match?(filing, exchange)
198
+ return false if filing_date && parse_date(filing_date) != filing.filing_date
199
+ return false if date_start && filing.filing_date < parse_date(date_start)
200
+ return false if date_end && filing.filing_date > parse_date(date_end)
201
+ return false if amendments == false && filing.form.to_s.end_with?("/A")
202
+
203
+ true
204
+ end
205
+
206
+ def exchange_match?(filing, exchange)
207
+ ex = Array(exchange).map(&:downcase)
208
+ entity_exchanges = EntityData.new(cik: filing.cik).exchanges.map(&:downcase)
209
+ (ex & entity_exchanges).any?
210
+ end
211
+
212
+ def parse_date(value)
213
+ value.is_a?(Date) ? value : Date.parse(value.to_s)
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ # Wraps EntityFacts/FilingFacts with statement rendering and convenience
5
+ # accessors matching Python edgartools' Financials API.
6
+ class Financials
7
+ # Map ISO 4217 currency codes to symbols (matches Python edgartools)
8
+ CURRENCY_SYMBOLS = {
9
+ "USD" => "$",
10
+ "EUR" => "\u20AC",
11
+ "GBP" => "\u00A3",
12
+ "JPY" => "\u00A5",
13
+ "CAD" => "C$",
14
+ "CHF" => "Fr",
15
+ "AUD" => "A$",
16
+ "CNY" => "\u00A5",
17
+ "HKD" => "HK$",
18
+ "NZD" => "NZ$",
19
+ "SEK" => "kr",
20
+ "NOK" => "kr",
21
+ "DKK" => "kr",
22
+ "INR" => "\u20B9",
23
+ "BRL" => "R$",
24
+ "ZAR" => "R",
25
+ "MXN" => "Mex$",
26
+ "SGD" => "S$",
27
+ "TWD" => "NT$",
28
+ "KRW" => "\u20A9",
29
+ "RUB" => "\u20BD",
30
+ "PLN" => "z\u0142",
31
+ "TRY" => "\u20BA",
32
+ "ILS" => "\u20AA"
33
+ }.freeze
34
+
35
+ attr_reader :facts
36
+
37
+ def initialize(cik: nil, facts: nil)
38
+ @facts = facts || EntityFacts.new(cik: cik)
39
+ end
40
+
41
+ def available?
42
+ @facts.available?
43
+ end
44
+
45
+ def income_statement(periods: 4)
46
+ concepts = %w[Revenues Revenue
47
+ RevenueFromContractWithCustomerExcludingAssessedTax
48
+ RevenueFromContractWithCustomer
49
+ CostOfGoodsAndServicesSold
50
+ GrossProfit OperatingExpenses
51
+ OperatingIncomeLoss
52
+ NonoperatingIncomeExpensePlusInterestAndDividendIncome
53
+ IncomeLossFromContinuingOperationsBeforeIncomeTaxExpenseBenefit
54
+ IncomeTaxExpenseBenefit
55
+ NetIncomeLoss
56
+ EarningsPerShareBasic
57
+ EarningsPerShareDiluted
58
+ WeightedAverageNumberOfSharesOutstandingBasic
59
+ WeightedAverageNumberOfDilutedSharesOutstanding]
60
+ extract_concepts(concepts, periods)
61
+ end
62
+
63
+ def balance_sheet(periods: 4)
64
+ concepts = %w[Assets CurrentAssets
65
+ CashAndCashEquivalentsAtCarryingValue
66
+ AccountsReceivableNetCurrent
67
+ InventoryNet
68
+ PropertyPlantAndEquipmentNet
69
+ AssetsCurrent LiabilitiesCurrent
70
+ LongTermDebtCurrent LongTermDebtNoncurrent
71
+ Liabilities StockholdersEquity
72
+ StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest
73
+ RetainedEarningsAccumulatedDeficit
74
+ CommonStocksIncludingAdditionalPaidInCapital
75
+ TreasuryStockValue]
76
+ extract_concepts(concepts, periods)
77
+ end
78
+
79
+ def cashflow_statement(periods: 4)
80
+ concepts = %w[NetCashProvidedByUsedInOperatingActivities
81
+ NetCashProvidedByUsedInInvestingActivities
82
+ NetCashProvidedByUsedInFinancingActivities
83
+ CashAndCashEquivalentsPeriodIncreaseDecrease
84
+ CashAndCashEquivalentsAtCarryingValue]
85
+ extract_concepts(concepts, periods)
86
+ end
87
+
88
+ def statement_of_equity(periods: 4)
89
+ concepts = %w[StockholdersEquity
90
+ StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest
91
+ RetainedEarningsAccumulatedDeficit
92
+ CommonStocksIncludingAdditionalPaidInCapital
93
+ TreasuryStockValue
94
+ AccumulatedOtherComprehensiveIncomeLossNetOfTax
95
+ ComprehensiveIncomeNetOfTax
96
+ Dividends]
97
+ extract_concepts(concepts, periods)
98
+ end
99
+
100
+ def comprehensive_income(periods: 4)
101
+ concepts = %w[ComprehensiveIncomeNetOfTax
102
+ OtherComprehensiveIncomeLossNetOfTax
103
+ ComprehensiveIncomeNetOfTaxAttributableToParent
104
+ ComprehensiveIncomeNetOfTaxAttributableToNoncontrollingInterest]
105
+ extract_concepts(concepts, periods)
106
+ end
107
+
108
+ # Delegates to EntityFacts/FilingFacts for the 7 core EntityFacts accessors
109
+ def get_revenue(limit: nil, annual: true, period: nil)
110
+ @facts.get_revenue(limit: limit, annual: annual, period: period)
111
+ end
112
+
113
+ def get_net_income(limit: nil, annual: true, period: nil)
114
+ @facts.get_net_income(limit: limit, annual: annual, period: period)
115
+ end
116
+
117
+ def get_operating_income(limit: nil, annual: true, period: nil)
118
+ @facts.get_operating_income(limit: limit, annual: annual, period: period)
119
+ end
120
+
121
+ def get_total_assets(limit: nil, annual: true, period: nil)
122
+ @facts.get_total_assets(limit: limit, annual: annual, period: period)
123
+ end
124
+
125
+ def get_total_liabilities(limit: nil, annual: true, period: nil)
126
+ @facts.get_total_liabilities(limit: limit, annual: annual, period: period)
127
+ end
128
+
129
+ def get_shareholders_equity(limit: nil, annual: true, period: nil)
130
+ @facts.get_shareholders_equity(limit: limit, annual: annual, period: period)
131
+ end
132
+ alias get_stockholders_equity get_shareholders_equity
133
+
134
+ def get_gross_profit(limit: nil, annual: true, period: nil)
135
+ @facts.get_gross_profit(limit: limit, annual: annual, period: period)
136
+ end
137
+
138
+ # Financials-specific convenience accessors matching Python edgartools
139
+ # Financials API. These use @facts._select_value and @facts.concept_values
140
+ # since Python Financials implements them via XBRL statement rendering
141
+ # (not available in ruby-edgar yet).
142
+
143
+ def get_operating_cash_flow(limit: nil, annual: true, period: nil)
144
+ if limit
145
+ v = @facts.concept_values("NetCashProvidedByUsedInOperatingActivities", limit: limit, annual: annual)
146
+ v.empty? ? nil : v
147
+ else
148
+ @facts._select_value("NetCashProvidedByUsedInOperatingActivities", annual: annual, period: period)
149
+ end
150
+ end
151
+
152
+ def get_free_cash_flow(limit: nil, annual: true)
153
+ op = @facts._select_value("NetCashProvidedByUsedInOperatingActivities", annual: annual)
154
+ capex = @facts._select_value("PaymentsToAcquirePropertyPlantAndEquipment", annual: annual)
155
+ return nil unless op && capex
156
+
157
+ if limit
158
+ end_date = @facts.concept_values(
159
+ "NetCashProvidedByUsedInOperatingActivities", limit: 1, annual: annual
160
+ )&.first&.dig("end")
161
+ [{ "end" => end_date, "val" => op - capex.abs }]
162
+ else
163
+ op - capex.abs
164
+ end
165
+ end
166
+
167
+ def get_earnings_per_share(limit: nil, annual: true, period: nil)
168
+ if limit
169
+ v = @facts.concept_values("EarningsPerShareDiluted", limit: limit, annual: annual)
170
+ v = @facts.concept_values("EarningsPerShareBasic", limit: limit, annual: annual) if v.empty?
171
+ v.empty? ? nil : v
172
+ else
173
+ val = @facts._select_value("EarningsPerShareDiluted", annual: annual, period: period)
174
+ val || @facts._select_value("EarningsPerShareBasic", annual: annual, period: period)
175
+ end
176
+ end
177
+
178
+ def get_current_assets(limit: nil, annual: true, period: nil)
179
+ if limit
180
+ v = @facts.concept_values("AssetsCurrent", limit: limit, annual: annual)
181
+ v.empty? ? nil : v
182
+ else
183
+ @facts._select_value("AssetsCurrent", annual: annual, period: period)
184
+ end
185
+ end
186
+
187
+ def get_current_liabilities(limit: nil, annual: true, period: nil)
188
+ if limit
189
+ v = @facts.concept_values("LiabilitiesCurrent", limit: limit, annual: annual)
190
+ v.empty? ? nil : v
191
+ else
192
+ @facts._select_value("LiabilitiesCurrent", annual: annual, period: period)
193
+ end
194
+ end
195
+
196
+ def get_capital_expenditures(limit: nil, annual: true, period: nil)
197
+ if limit
198
+ v = @facts.concept_values("PaymentsToAcquirePropertyPlantAndEquipment", limit: limit, annual: annual)
199
+ v.empty? ? nil : v
200
+ else
201
+ @facts._select_value("PaymentsToAcquirePropertyPlantAndEquipment", annual: annual, period: period)
202
+ end
203
+ end
204
+
205
+ def get_shares_outstanding_basic(limit: nil, annual: true, period: nil)
206
+ concepts = %w[WeightedAverageNumberOfSharesOutstandingBasic
207
+ EntityCommonStockSharesOutstanding
208
+ CommonStockSharesOutstanding]
209
+ if limit
210
+ concepts.each do |name|
211
+ v = @facts.concept_values(name, limit: limit, annual: annual)
212
+ return v unless v.empty?
213
+ end
214
+ else
215
+ concepts.each do |name|
216
+ val = @facts._select_value(name, annual: annual, period: period)
217
+ return val if val
218
+ end
219
+ end
220
+ nil
221
+ end
222
+
223
+ def get_shares_outstanding_diluted(limit: nil, annual: true, period: nil)
224
+ if limit
225
+ v = @facts.concept_values("WeightedAverageNumberOfDilutedSharesOutstanding", limit: limit, annual: annual)
226
+ v = @facts.concept_values("DilutedAverageShares", limit: limit, annual: annual) if v.empty?
227
+ v.empty? ? nil : v
228
+ else
229
+ val = @facts._select_value("WeightedAverageNumberOfDilutedSharesOutstanding", annual: annual, period: period)
230
+ val || @facts._select_value("DilutedAverageShares", annual: annual, period: period)
231
+ end
232
+ end
233
+
234
+ def currency_symbol
235
+ # Try to find the currency from us-gaap units.
236
+ # EntityFacts returns Hash-format concepts with a "units" sub-hash.
237
+ # FilingFacts returns Array-format concepts with a "unit" string key.
238
+ @facts.us_gaap.each_value do |concept|
239
+ if concept.is_a?(Array)
240
+ # FilingFacts format: each element is a fact hash with "unit" key
241
+ concept.each do |v|
242
+ result = currency_from_unit(v["unit"])
243
+ return result if result
244
+ end
245
+ else
246
+ # EntityFacts format: concept hash with "units" sub-hash
247
+ units = concept["units"]
248
+ next unless units
249
+
250
+ units.each_key do |unit|
251
+ result = currency_from_unit(unit)
252
+ return result if result
253
+ end
254
+ end
255
+ end
256
+
257
+ # Fallback: try facts_map when us_gaap is empty (e.g. custom facts objects)
258
+ if @facts.respond_to?(:facts_map)
259
+ @facts.facts_map.each_value do |values|
260
+ next unless values.any?
261
+
262
+ values.each do |v|
263
+ result = currency_from_unit(v["unit"])
264
+ return result if result
265
+ end
266
+ end
267
+ end
268
+
269
+ # Default fallback (Python edgartools defaults to "$")
270
+ "$"
271
+ end
272
+
273
+ def financial_metrics
274
+ revenue = get_revenue
275
+ net_income = get_net_income
276
+ operating_income = get_operating_income
277
+ total_assets = get_total_assets
278
+ total_liabilities = get_total_liabilities
279
+ equity = get_stockholders_equity
280
+ ocf = get_operating_cash_flow
281
+ fcf = get_free_cash_flow
282
+ gross_profit = get_gross_profit
283
+ eps = get_earnings_per_share
284
+ current_assets = get_current_assets
285
+ current_liabilities = get_current_liabilities
286
+ capex = get_capital_expenditures
287
+ shares_basic = get_shares_outstanding_basic
288
+ shares_diluted = get_shares_outstanding_diluted
289
+
290
+ metrics = {
291
+ revenue: revenue,
292
+ operating_income: operating_income,
293
+ net_income: net_income,
294
+ total_assets: total_assets,
295
+ total_liabilities: total_liabilities,
296
+ stockholders_equity: equity,
297
+ current_assets: current_assets,
298
+ current_liabilities: current_liabilities,
299
+ operating_cash_flow: ocf,
300
+ capital_expenditures: capex,
301
+ free_cash_flow: fcf,
302
+ shares_outstanding_basic: shares_basic,
303
+ shares_outstanding_diluted: shares_diluted,
304
+ gross_profit: gross_profit,
305
+ eps: eps
306
+ }
307
+
308
+ # Calculate ratios (matching Python edgartools)
309
+ if current_assets && current_liabilities&.positive?
310
+ metrics[:current_ratio] = current_assets / current_liabilities.to_f
311
+ end
312
+
313
+ metrics[:debt_to_assets] = total_liabilities / total_assets.to_f if total_liabilities && total_assets&.positive?
314
+
315
+ metrics
316
+ end
317
+
318
+ class << self
319
+ def extract(filing)
320
+ xbrl = filing.xbrl
321
+ facts = if xbrl
322
+ FilingFacts.new(xbrl, form_type: filing.form)
323
+ else
324
+ EntityFacts.new(cik: filing.cik)
325
+ end
326
+ new(facts: facts)
327
+ rescue StandardError => e
328
+ warn "Failed to extract financials from filing: #{e.message}"
329
+ nil
330
+ end
331
+ end
332
+
333
+ private
334
+
335
+ # Look up a currency unit string in CURRENCY_SYMBOLS, returning either
336
+ # the mapped symbol or the raw unit if it looks like a currency code.
337
+ def currency_from_unit(unit)
338
+ return nil unless unit.is_a?(String)
339
+
340
+ sym = CURRENCY_SYMBOLS[unit]
341
+ return sym if sym
342
+ return unit if unit.length <= 3 && unit == unit.upcase
343
+
344
+ nil
345
+ end
346
+
347
+ def extract_concepts(concept_names, periods)
348
+ result = {}
349
+ concept_names.each do |name|
350
+ values = @facts.concept_values(name, limit: periods)
351
+ result[name] = { label: concept_label(name), values: values } if values.any?
352
+ end
353
+ result
354
+ end
355
+
356
+ def concept_label(concept_name)
357
+ concept = @facts.concept(concept_name)
358
+ concept ? concept["label"] : concept_name
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+ require "zlib"
7
+ require "stringio"
8
+
9
+ module Edgar
10
+ # Faraday-based HTTP client with rate limiting, retries, and gzip handling.
11
+ class HTTPClient
12
+ class RateLimitError < StandardError; end
13
+ class SslVerificationError < StandardError; end
14
+
15
+ attr_reader :last_request_time
16
+
17
+ def initialize(
18
+ rate_limit: Config::EDGAR_RATE_LIMIT_PER_SEC,
19
+ timeout: Config::DEFAULT_TIMEOUT,
20
+ connect_timeout: Config::DEFAULT_CONNECT_TIMEOUT,
21
+ retries: 5
22
+ )
23
+ @rate_limit = rate_limit
24
+ @min_interval = 1.0 / rate_limit
25
+ @last_request_time = 0.0
26
+ @mutex = Mutex.new
27
+ @retries = retries
28
+
29
+ @connection = build_connection(timeout, connect_timeout)
30
+ end
31
+
32
+ def get(url, **options)
33
+ request(:get, url, **options)
34
+ end
35
+
36
+ def post(url, body: nil, **options)
37
+ request(:post, url, body: body, **options)
38
+ end
39
+
40
+ def download_text(url)
41
+ response = get(url)
42
+ decompress_body(url, response.body, response.headers)
43
+ end
44
+
45
+ def download_json(url)
46
+ response = get(url)
47
+ body = decompress_body(url, response.body, response.headers)
48
+ JSON.parse(body)
49
+ end
50
+
51
+ GZIP_MAGIC = [0x1f, 0x8b].freeze
52
+
53
+ private
54
+
55
+ def build_connection(timeout, connect_timeout)
56
+ Faraday.new do |f|
57
+ f.options[:timeout] = timeout
58
+ f.options[:open_timeout] = connect_timeout
59
+
60
+ f.request :retry, {
61
+ max: @retries,
62
+ interval: 1.0,
63
+ interval_randomness: 0.5,
64
+ backoff_factor: 2.0,
65
+ retry_statuses: [429, 500, 502, 503, 504],
66
+ retry_if: lambda { |env, _ex|
67
+ env.response&.status == 429 || env.response&.status&.between?(500, 599)
68
+ }
69
+ }
70
+
71
+ f.adapter Faraday.default_adapter
72
+ end
73
+ end
74
+
75
+ def request(method, url, body: nil, **options)
76
+ rate_limit!
77
+
78
+ headers = {
79
+ "User-Agent" => Config.user_agent,
80
+ "Accept-Encoding" => "gzip, deflate"
81
+ }
82
+ headers.merge!(options[:headers]) if options[:headers]
83
+
84
+ response = @connection.run_request(method, url, body, headers)
85
+
86
+ raise RateLimitError, "429 Too Many Requests" if response.status == 429
87
+ raise SslVerificationError, "SSL verification failed for #{url}" if response.status == 525
88
+
89
+ raise "HTTP #{response.status} for #{url}" unless response.success?
90
+
91
+ response
92
+ end
93
+
94
+ def decompress_body(url, body, headers)
95
+ content_encoding = headers["content-encoding"].to_s.downcase
96
+ is_gz_url = url.to_s.end_with?(".gz")
97
+ is_gzip = content_encoding.include?("gzip") || is_gz_url
98
+
99
+ if is_gzip || body.bytes.first(2) == GZIP_MAGIC
100
+ Zlib::GzipReader.new(StringIO.new(body)).read
101
+ else
102
+ body
103
+ end
104
+ rescue Zlib::Error
105
+ body
106
+ end
107
+
108
+ def rate_limit!
109
+ @mutex.synchronize do
110
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_request_time
111
+ sleep(@min_interval - elapsed) if elapsed < @min_interval
112
+ @last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ end
114
+ end
115
+ end
116
+ end