webscraping_ai 3.2.1 → 4.0.1

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: cf1c3fe294cde88ca7896056723ed69042d8c6b4217fa33667edbe055067ba56
4
- data.tar.gz: cd5d05403e51177b1ac7a4ed34e4c6fd87d2e70893ce02d13b4c16c46b6e1a6b
3
+ metadata.gz: e312f29ac8666f33be3757599d08f062908be2a1a815973cc591d2cce44a4f4e
4
+ data.tar.gz: 6cb641d229bdf754fba1ffb2f717bc40db8bcad50158d6fc960d3d6de7448480
5
5
  SHA512:
6
- metadata.gz: 001a5c2dd678f2c090ec73cbae64cdecdd6eaeaea355ca3d47893033bff367ed6946ab93f5ef7229489c53616f5e9a1130e5d76986b74e4046fa61ca061dabd6
7
- data.tar.gz: f3ff936c84bd6f56e3153d09701ea82daa2006ec3bae8db6782335241da7701960f8c4e47eb9c406bf060df071377aeb7436fef7a5b9e6ac07221b0f1fa89a6f
6
+ metadata.gz: 8d6838f0b67bf4426e2b1c6f48d5047fe4413c3d1e342e576aedc4a8df755f2fe4b591ccf747e8df955690d66ebe3ebf5b95747cc93794fa8fe0d90572050d19
7
+ data.tar.gz: 4d3966a1b3e7d4bd2b4a51cb1011f991ba43fc21f760fcce3496427af68256a5d2e45587089fc95e726f400577078935f83a178abd18c864f06da9312d29908e
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file. This project follows [Semantic Versioning](https://semver.org/).
4
+
5
+ ## 4.0.1 — 2026-07-17
6
+
7
+ ### Changed
8
+
9
+ - Documentation: expanded README — API docs, signup/dashboard links, badges, and links to the other official clients.
10
+
11
+ ## [4.0.0] - Unreleased
12
+
13
+ ### Changed
14
+
15
+ - **Complete rewrite**: the gem is now a hand-written, idiomatic Ruby client rather than OpenAPI-generated code.
16
+ - New unified entry point: `WebScrapingAI::Client.new(api_key: ...)` with one method per endpoint (`#html`, `#text`, `#selected`, `#selected_multiple`, `#question`, `#fields`, `#account`).
17
+ - Switched HTTP layer from `typhoeus` to `faraday ~> 2.0`.
18
+ - Minimum Ruby version is now `3.1`.
19
+
20
+ ### Removed
21
+
22
+ - `WebScrapingAI::HTMLApi`, `WebScrapingAI::TextApi`, `WebScrapingAI::SelectedHTMLApi`, `WebScrapingAI::AIApi`, `WebScrapingAI::AccountApi` classes and all generated model classes. This is a hard break — see the README for the new API surface.
23
+ - `typhoeus` runtime dependency.
24
+
25
+ ### Added
26
+
27
+ - Typed error hierarchy: `BadRequestError`, `PaymentRequiredError`, `AuthenticationError`, `RateLimitError`, `ServerError`, `GatewayTimeoutError` (all `< WebScrapingAI::ApiError`), plus `TimeoutError` and `ConnectionError` for transport failures.
28
+ - Module-level configuration: `WebScrapingAI.configure { |c| c.api_key = "..." }`.
29
+ - `WEBSCRAPING_AI_API_KEY` environment variable picked up by default.
30
+ - RSpec test suite with WebMock-based stubs.
31
+ - GitHub Actions workflows for CI and RubyGems trusted publishing on release.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) WebScraping.AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -1,128 +1,170 @@
1
- # webscraping_ai
1
+ # WebScraping.AI Ruby Client
2
2
 
3
- WebScrapingAI - the Ruby gem for the WebScraping.AI
3
+ [![Gem Version](https://badge.fury.io/rb/webscraping_ai.svg)](https://rubygems.org/gems/webscraping_ai)
4
+ [![CI](https://github.com/webscraping-ai/webscraping-ai-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/webscraping-ai/webscraping-ai-ruby/actions/workflows/ci.yml)
4
5
 
5
- WebScraping.AI scraping API provides LLM-powered tools with Chromium JavaScript rendering, rotating proxies, and built-in HTML parsing.
6
-
7
- This SDK is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
8
-
9
- - API version: 3.2.1
10
- - Package version: 3.2.1
11
- - Generator version: 7.22.0
12
- - Build package: org.openapitools.codegen.languages.RubyClientCodegen
13
- For more information, please visit [https://webscraping.ai](https://webscraping.ai)
6
+ Official Ruby client for the [WebScraping.AI](https://webscraping.ai) API
7
+ web scraping with Chromium JavaScript rendering, rotating
8
+ datacenter/residential/stealth proxies, and AI-powered question answering and
9
+ structured field extraction on any page. See the
10
+ [API documentation](https://webscraping.ai/docs) for the full parameter reference.
14
11
 
15
12
  ## Installation
16
13
 
17
- ### Build a gem
14
+ ```ruby
15
+ # Gemfile
16
+ gem "webscraping_ai", "~> 4.0"
17
+ ```
18
18
 
19
- To build the Ruby code into a gem:
19
+ Or:
20
20
 
21
- ```shell
22
- gem build webscraping_ai.gemspec
21
+ ```bash
22
+ gem install webscraping_ai
23
23
  ```
24
24
 
25
- Then either install the gem locally:
25
+ Requires Ruby 3.1+.
26
26
 
27
- ```shell
28
- gem install ./webscraping_ai-3.2.1.gem
29
- ```
27
+ ## Quick start
30
28
 
31
- (for development, run `gem install --dev ./webscraping_ai-3.2.1.gem` to install the development dependencies)
29
+ [Sign up](https://webscraping.ai/auth/sign_up) to get an API key the free
30
+ trial includes 2,000 credits, no credit card required. Your key lives in the
31
+ [dashboard](https://webscraping.ai/dashboard).
32
32
 
33
- or publish the gem to a gem hosting service, e.g. [RubyGems](https://rubygems.org/).
33
+ ```ruby
34
+ require "webscraping_ai"
34
35
 
35
- Finally add this to the Gemfile:
36
+ client = WebScrapingAI::Client.new(api_key: ENV.fetch("WEBSCRAPING_AI_API_KEY"))
36
37
 
37
- gem 'webscraping_ai', '~> 3.2.1'
38
+ # Page HTML
39
+ html = client.html("https://example.com", js: true)
38
40
 
39
- ### Install from Git
41
+ # Visible text
42
+ text = client.text("https://example.com")
40
43
 
41
- If the Ruby gem is hosted at a git repository: https://github.com/webscraping-ai/webscraping-ai-ruby, then add the following in the Gemfile:
44
+ # CSS-selected fragment
45
+ title = client.selected("https://example.com", selector: "h1")
42
46
 
43
- gem 'webscraping_ai', :git => 'https://github.com/webscraping-ai/webscraping-ai-ruby.git'
47
+ # Multiple selectors at once
48
+ fragments = client.selected_multiple("https://example.com", selectors: ["h1", ".price"])
44
49
 
45
- ### Include the Ruby code directly
50
+ # Ask the LLM a question about the page
51
+ answer = client.question("https://example.com", question: "What is the main product?")
46
52
 
47
- Include the Ruby code directly using `-I` as follows:
53
+ # Extract structured fields with the LLM
54
+ data = client.fields(
55
+ "https://example.com",
56
+ fields: {
57
+ title: "Main product title",
58
+ price: "Current product price",
59
+ description: "Full product description"
60
+ }
61
+ )
48
62
 
49
- ```shell
50
- ruby -Ilib script.rb
63
+ # Check your account quota
64
+ info = client.account
65
+ # => { "remaining_api_calls" => 200_000, "resets_at" => 1_617_073_667, "remaining_concurrency" => 100 }
51
66
  ```
52
67
 
53
- ## Getting Started
68
+ ## Configuration
54
69
 
55
- Please follow the [installation](#installation) procedure and then run the following code:
70
+ Configure globally once, then create clients without arguments:
56
71
 
57
72
  ```ruby
58
- # Load the gem
59
- require 'webscraping_ai'
60
-
61
- # Setup authorization
62
73
  WebScrapingAI.configure do |config|
63
- # Configure API key authorization: api_key
64
- config.api_key['api_key'] = 'YOUR API KEY'
65
- # Uncomment the following line to set a prefix for the API key, e.g. 'Bearer' (defaults to nil)
66
- # config.api_key_prefix['api_key'] = 'Bearer'
74
+ config.api_key = ENV.fetch("WEBSCRAPING_AI_API_KEY")
75
+ config.timeout = 60 # seconds, total request timeout
76
+ config.open_timeout = 10 # seconds, connection timeout
67
77
  end
68
78
 
69
- api_instance = WebScrapingAI::AIApi.new
70
- url = 'https://example.com' # String | URL of the target page.
71
- fields = { key: { key: 'inner_example'}} # Hash<String, String> | Object describing fields to extract from the page and their descriptions
72
- opts = {
73
- headers: { key: { key: 'inner_example'}}, # Hash<String, String> | HTTP headers to pass to the target page. Can be specified either via a nested query parameter (...&headers[One]=value1&headers=[Another]=value2) or as a JSON encoded object (...&headers={\"One\": \"value1\", \"Another\": \"value2\"}).
74
- timeout: 10000, # Integer | Maximum web page retrieval time in ms. Increase it in case of timeout errors (10000 by default, maximum is 30000).
75
- js: true, # Boolean | Execute on-page JavaScript using a headless browser (true by default).
76
- js_timeout: 2000, # Integer | Maximum JavaScript rendering time in ms. Increase it in case if you see a loading indicator instead of data on the target page.
77
- wait_for: 'wait_for_example', # String | CSS selector to wait for before returning the page content. Useful for pages with dynamic content loading. Overrides js_timeout.
78
- proxy: 'datacenter', # String | Type of proxy. Use `residential` if your site restricts traffic from datacenters, or `stealth` for the most heavily protected sites with advanced anti-bot detection (`datacenter` by default). Residential and stealth proxy requests are more expensive than datacenter, see the pricing page for details.
79
- country: 'us', # String | Country of the proxy to use (US by default).
80
- custom_proxy: 'custom_proxy_example', # String | Your own proxy URL to use instead of our built-in proxy pool in \"http://user:password@host:port\" format (<a target=\"_blank\" href=\"https://webscraping.ai/proxies/smartproxy\">Smartproxy</a> for example).
81
- device: 'desktop', # String | Type of device emulation.
82
- error_on_404: false, # Boolean | Return error on 404 HTTP status on the target page (false by default).
83
- error_on_redirect: false, # Boolean | Return error on redirect on the target page (false by default).
84
- js_script: 'document.querySelector('button').click();' # String | Custom JavaScript code to execute on the target page.
85
- }
79
+ client = WebScrapingAI::Client.new
80
+ ```
86
81
 
87
- begin
88
- #Extract structured data fields from a web page
89
- result = api_instance.get_fields(url, fields, opts)
90
- p result
91
- rescue WebScrapingAI::ApiError => e
92
- puts "Exception when calling AIApi->get_fields: #{e}"
93
- end
82
+ The gem also reads `WEBSCRAPING_AI_API_KEY` from the environment automatically.
94
83
 
84
+ Per-instance overrides:
85
+
86
+ ```ruby
87
+ client = WebScrapingAI::Client.new(
88
+ api_key: "...",
89
+ timeout: 90,
90
+ base_url: "https://api.webscraping.ai"
91
+ )
95
92
  ```
96
93
 
97
- ## Documentation for API Endpoints
94
+ ## Endpoints and options
98
95
 
99
- All URIs are relative to *https://api.webscraping.ai*
96
+ All page-fetching endpoints accept these common options (passed as keyword arguments):
100
97
 
101
- Class | Method | HTTP request | Description
102
- ------------ | ------------- | ------------- | -------------
103
- *WebScrapingAI::AIApi* | [**get_fields**](docs/AIApi.md#get_fields) | **GET** /ai/fields | Extract structured data fields from a web page
104
- *WebScrapingAI::AIApi* | [**get_question**](docs/AIApi.md#get_question) | **GET** /ai/question | Get an answer to a question about a given web page
105
- *WebScrapingAI::AccountApi* | [**account**](docs/AccountApi.md#account) | **GET** /account | Information about your account calls quota
106
- *WebScrapingAI::HTMLApi* | [**get_html**](docs/HTMLApi.md#get_html) | **GET** /html | Page HTML by URL
107
- *WebScrapingAI::SelectedHTMLApi* | [**get_selected**](docs/SelectedHTMLApi.md#get_selected) | **GET** /selected | HTML of a selected page area by URL and CSS selector
108
- *WebScrapingAI::SelectedHTMLApi* | [**get_selected_multiple**](docs/SelectedHTMLApi.md#get_selected_multiple) | **GET** /selected-multiple | HTML of multiple page areas by URL and CSS selectors
109
- *WebScrapingAI::TextApi* | [**get_text**](docs/TextApi.md#get_text) | **GET** /text | Page text by URL
98
+ | Option | Type | Default | Description |
99
+ | --- | --- | --- | --- |
100
+ | `headers` | `Hash` | | HTTP headers to send to the target page (e.g. `{ "Cookie" => "session=..." }`) |
101
+ | `timeout` | `Integer` | `10000` | Page retrieval timeout in ms (1–30000) |
102
+ | `js` | `Boolean` | `true` | Execute on-page JavaScript via headless Chromium |
103
+ | `js_timeout` | `Integer` | `2000` | JS rendering timeout in ms (1–20000) |
104
+ | `wait_for` | `String` | | CSS selector to wait for before returning (overrides `js_timeout`) |
105
+ | `proxy` | `String` | `"datacenter"` | One of `datacenter`, `residential`, `stealth` |
106
+ | `country` | `String` | `"us"` | Proxy country: `us`, `gb`, `de`, `it`, `fr`, `ca`, `es`, `ru`, `jp`, `kr`, `in`, `hk`, `tr` |
107
+ | `custom_proxy` | `String` | — | Your own proxy in `http://user:pass@host:port` form |
108
+ | `device` | `String` | `"desktop"` | One of `desktop`, `mobile`, `tablet` |
109
+ | `error_on_404` | `Boolean` | `false` | Raise an error if the target page returns 404 |
110
+ | `error_on_redirect` | `Boolean` | `false` | Raise an error if the target page redirects |
111
+ | `js_script` | `String` | — | Custom JS to execute on the page |
110
112
 
113
+ Endpoint-specific options:
111
114
 
112
- ## Documentation for Models
115
+ - `#html` `return_script_result` (`Boolean`), `format` (`"json"`/`"text"`)
116
+ - `#text` — `text_format` (`"plain"`/`"xml"`/`"json"`), `return_links` (`Boolean`, only with `text_format: "json"`)
117
+ - `#selected` — `selector` (`String`), `format` (`"json"`/`"text"`)
118
+ - `#selected_multiple` — `selectors` (`Array<String>` or single `String`)
119
+ - `#question` — `question` (`String`, required), `format` (`"json"`/`"text"`)
120
+ - `#fields` — `fields` (`Hash<String, String>`, required) — keys are field names, values are descriptions
113
121
 
114
- - [WebScrapingAI::Account](docs/Account.md)
115
- - [WebScrapingAI::Error](docs/Error.md)
122
+ Returns: `String` for HTML/text responses, `Hash`/`Array` for JSON responses.
116
123
 
124
+ ## Error handling
117
125
 
118
- ## Documentation for Authorization
126
+ All API errors inherit from `WebScrapingAI::ApiError` and expose `#status`, `#message`, `#status_code`, `#status_message`, `#body`, and `#response_body`.
119
127
 
128
+ ```ruby
129
+ begin
130
+ client.html("https://example.com")
131
+ rescue WebScrapingAI::RateLimitError => e
132
+ # 429 — too many concurrent requests
133
+ sleep 1 and retry
134
+ rescue WebScrapingAI::PaymentRequiredError => e
135
+ # 402 — out of API credits
136
+ rescue WebScrapingAI::AuthenticationError => e
137
+ # 403 — wrong API key
138
+ rescue WebScrapingAI::BadRequestError => e
139
+ # 400 — invalid parameters
140
+ rescue WebScrapingAI::ServerError => e
141
+ # 500 — target page returned a non-2xx code, or unexpected error.
142
+ # e.status_code / e.status_message expose the target page's response.
143
+ rescue WebScrapingAI::GatewayTimeoutError => e
144
+ # 504 — page took longer than `timeout` ms to load. Try a higher `timeout:`.
145
+ rescue WebScrapingAI::TimeoutError => e
146
+ # Client-side: the HTTP request exceeded `Client#timeout`.
147
+ rescue WebScrapingAI::ConnectionError => e
148
+ # Network failure before a response was received.
149
+ end
150
+ ```
151
+
152
+ ## Development
153
+
154
+ ```bash
155
+ bin/setup # bundle install
156
+ bundle exec rspec
157
+ bundle exec rubocop
158
+ ```
120
159
 
121
- Authentication schemes defined for the API:
122
- ### api_key
160
+ ## Links
123
161
 
162
+ - [WebScraping.AI](https://webscraping.ai) — features, pricing, signup
163
+ - [API documentation](https://webscraping.ai/docs)
164
+ - [Dashboard](https://webscraping.ai/dashboard) — API key, usage, request builder
165
+ - Other official clients: [Python](https://github.com/webscraping-ai/webscraping-ai-python) · [JavaScript](https://github.com/webscraping-ai/webscraping-ai-js) · [PHP](https://github.com/webscraping-ai/webscraping-ai-php) · [Go](https://github.com/webscraping-ai/webscraping-ai-go) · [Java](https://github.com/webscraping-ai/webscraping-ai-java) · [.NET](https://github.com/webscraping-ai/webscraping-ai-dotnet) · [CLI](https://github.com/webscraping-ai/webscraping-ai-cli) · [MCP server](https://github.com/webscraping-ai/webscraping-ai-mcp-server) · [n8n node](https://github.com/webscraping-ai/webscraping-ai-n8n)
166
+ - Support: [support@webscraping.ai](mailto:support@webscraping.ai)
124
167
 
125
- - **Type**: API key
126
- - **API key parameter name**: api_key
127
- - **Location**: URL query string
168
+ ## License
128
169
 
170
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,130 @@
1
+ require "faraday"
2
+ require "json"
3
+
4
+ module WebScrapingAI
5
+ class Client
6
+ PROXY_TYPES = %w[datacenter residential stealth].freeze
7
+ COUNTRIES = %w[us gb de it fr ca es ru jp kr in hk tr].freeze
8
+ DEVICES = %w[desktop mobile tablet].freeze
9
+ TEXT_FORMATS = %w[plain xml json].freeze
10
+ FORMATS = %w[json text].freeze
11
+
12
+ PAGE_FETCH_OPTIONS = %i[
13
+ headers timeout js js_timeout wait_for proxy country
14
+ custom_proxy device error_on_404 error_on_redirect js_script
15
+ ].freeze
16
+
17
+ attr_reader :configuration
18
+
19
+ def initialize(api_key: nil, base_url: nil, timeout: nil, open_timeout: nil, adapter: nil, user_agent: nil)
20
+ global = WebScrapingAI.configuration
21
+ @configuration = Configuration.new.tap do |c|
22
+ c.api_key = api_key || global.api_key
23
+ c.base_url = base_url || global.base_url
24
+ c.timeout = timeout || global.timeout
25
+ c.open_timeout = open_timeout || global.open_timeout
26
+ c.adapter = adapter || global.adapter
27
+ c.user_agent = user_agent || global.user_agent
28
+ end
29
+
30
+ return unless @configuration.api_key.nil? || @configuration.api_key.to_s.empty?
31
+
32
+ raise ConfigurationError,
33
+ "api_key is required (pass api_key: or set WebScrapingAI.configure { |c| c.api_key = ... })"
34
+ end
35
+
36
+ # GET /ai/question — returns the LLM's answer about the page.
37
+ # Returns a String by default, or a Hash when format: "json".
38
+ def question(url, question:, **opts)
39
+ get("/ai/question", url: url, question: question, **opts.slice(*PAGE_FETCH_OPTIONS, :format))
40
+ end
41
+
42
+ # GET /ai/fields — extracts the named fields from the page.
43
+ # `fields` is a Hash of { field_name => description }. Returns a Hash.
44
+ def fields(url, fields:, **opts)
45
+ get("/ai/fields", url: url, fields: fields, **opts.slice(*PAGE_FETCH_OPTIONS))
46
+ end
47
+
48
+ # GET /html — returns the full page HTML as a String.
49
+ def html(url, **opts)
50
+ get("/html", url: url, **opts.slice(*PAGE_FETCH_OPTIONS, :return_script_result, :format))
51
+ end
52
+
53
+ # GET /text — returns the visible text content of the page.
54
+ # Returns a String when text_format is "plain"/"xml" (default), or a Hash when text_format: "json".
55
+ def text(url, **opts)
56
+ get("/text", url: url, **opts.slice(*PAGE_FETCH_OPTIONS, :text_format, :return_links))
57
+ end
58
+
59
+ # GET /selected — returns HTML of the element matching `selector` as a String.
60
+ def selected(url, selector: nil, **opts)
61
+ get("/selected", url: url, selector: selector, **opts.slice(*PAGE_FETCH_OPTIONS, :format))
62
+ end
63
+
64
+ # GET /selected-multiple — returns an Array of HTML strings, one per selector.
65
+ def selected_multiple(url, selectors:, **opts)
66
+ get("/selected-multiple", url: url, selectors: Array(selectors), **opts.slice(*PAGE_FETCH_OPTIONS))
67
+ end
68
+
69
+ # GET /account — returns Hash with remaining_api_calls, resets_at, remaining_concurrency, email.
70
+ def account
71
+ get("/account")
72
+ end
73
+
74
+ private
75
+
76
+ def connection
77
+ @connection ||= Faraday.new(url: configuration.base_url) do |conn|
78
+ conn.options.timeout = configuration.timeout
79
+ conn.options.open_timeout = configuration.open_timeout
80
+ conn.options.params_encoder = QueryEncoder
81
+ conn.headers["User-Agent"] = configuration.user_agent
82
+ conn.headers["Accept"] = "application/json, text/html, text/xml, text/plain"
83
+ conn.adapter(configuration.adapter || Faraday.default_adapter)
84
+ end
85
+ end
86
+
87
+ def get(path, **params)
88
+ response = connection.get(path) do |req|
89
+ req.params = params.merge(api_key: configuration.api_key)
90
+ end
91
+ handle_response(response)
92
+ rescue Faraday::TimeoutError => e
93
+ raise TimeoutError, e.message
94
+ rescue Faraday::ConnectionFailed => e
95
+ raise ConnectionError, e.message
96
+ end
97
+
98
+ def handle_response(response)
99
+ return parse_body(response) if response.status.between?(200, 299)
100
+
101
+ error_class = STATUS_TO_ERROR.fetch(response.status, ApiError)
102
+ data = safe_parse_json(response.body) || {}
103
+ raise error_class.new(
104
+ message: data["message"] || "HTTP #{response.status}",
105
+ status: response.status,
106
+ status_code: data["status_code"],
107
+ status_message: data["status_message"],
108
+ body: data["body"],
109
+ response_body: response.body
110
+ )
111
+ end
112
+
113
+ def parse_body(response)
114
+ content_type = response.headers["content-type"].to_s
115
+ if content_type.include?("application/json")
116
+ JSON.parse(response.body)
117
+ else
118
+ response.body
119
+ end
120
+ end
121
+
122
+ def safe_parse_json(body)
123
+ return nil if body.nil? || body.empty?
124
+
125
+ JSON.parse(body)
126
+ rescue JSON::ParserError
127
+ nil
128
+ end
129
+ end
130
+ end