scrapeunblocker 0.1.2 → 0.1.7

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: 4d9a2366dce1f1f9586ccb5d9d0f5b9c667a1a9573f811093f017984118d138a
4
- data.tar.gz: 527960e09c0159162825fe7ef7a75dcb071bafaf0872f847bcd25d6fd65df813
3
+ metadata.gz: ab6bd524121d205b90ef9cabe0315d1185772ee313a6e40d1bf9057dad3b535b
4
+ data.tar.gz: 82bc7674e38f9bfa125320956e440a66ad535d3aac8ac923e086d0b0ec3fcba2
5
5
  SHA512:
6
- metadata.gz: e71d1368c7be85c86736955c25f39043457ae20e733d7ebee33416717981588eb8f35f82a16c10c9dd18b2d961f920121b984417e3dfb42aa450ecab5b2ace77
7
- data.tar.gz: 58cb22b93d148eb3cdc342cf184268c4826f2567380909bb964cb8ebb37a26644995df0dcbcb183e8d1266d5f50fd1f0bf21ff075ddc69c3ef6b20ed5f7da39c
6
+ metadata.gz: 7d8ecc218ec1814f6cdca5681e156488b43302a935a7e8c8fe3d2d73cd98f45558c979c555c33c03db0a43e5a8cf440d2edc8d3b259854844ee7cf9083399fa3
7
+ data.tar.gz: ad8a15c080c559e9d6d5927351e8336788266a65f07971c0c49c41e69ffbee98b8a3d2a661c62d325365d9abdd716b08241a3afbe1a8a2f47234da48d162894e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.7 (2026-07-27)
4
+
5
+ - Registry and README links to scrapeunblocker.com now carry UTM parameters so traffic from package registries is attributable. No functional changes.
6
+
7
+ ## 0.1.6 (2026-07-23)
8
+
9
+ Version jumps from 0.1.2 to 0.1.6 so all four official SDKs (Python, Node.js, Ruby, PHP) share one version number from here on. Nothing was skipped - 0.1.3 to 0.1.5 were never released for Ruby.
10
+
11
+ - Added `PaymentRequiredError` for HTTP 402, which previously surfaced as a bare `APIError` with no explanation. The three billing blocks now each get their own subclass, picked from the response body: `QuotaExceededError` (`Quota exceeded`), `CreditLimitExceededError` (`Credit limit exceeded`) and `PaymentFailedError` (`Payment failed - update payment method`). Rescue `PaymentRequiredError` to handle all three.
12
+ - Added `NoSubscriptionError`, a subclass of `AuthenticationError`, for the 401 that means "the key is fine, the account has no active plan" (`No valid subscription`) as opposed to an unrecognised key.
13
+ - Added typed errors for the remaining documented status codes: `NotFoundError` (404), `BrowserTimeoutError` (408), `UnsupportedContentError` (415) and `ValidationError` (422). All previously raised a bare `APIError`.
14
+ - Error messages now describe every documented status code accurately - notably 400, which also covers a missing `x-scrapeunblocker-key` header, not just a bad URL.
15
+ - Documented the full error hierarchy in the README, including which errors are retried, which are billed, and how each 402 clears.
16
+ - Fixed the README claim that Oopbuy brand keywords return HTTP 422. They return a successful `200` with `keywordRejected: true` and an empty `results` array.
17
+
18
+ No breaking changes: every new class inherits from `APIError`, so existing `rescue ScrapeUnblocker::APIError` / `rescue ScrapeUnblocker::Error` handlers keep working unchanged.
19
+
3
20
  ## 0.1.2 (2026-07-22)
4
21
 
5
22
  - Added `oopbuy_search(keyword, ...)` for the new Oopbuy goods search plugin (`POST /goods/oopbuy-search`) - searches 1688, Taobao or the official channel and returns matched products (spu, title, price, monthSold, image, url) as a Hash.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ScrapeUnblocker Ruby client
2
2
 
3
- Official Ruby client for the [ScrapeUnblocker](https://scrapeunblocker.com) web scraping API.
3
+ Official Ruby client for the [ScrapeUnblocker](https://scrapeunblocker.com?utm_source=rubygems&utm_medium=integration&utm_campaign=ruby-sdk) web scraping API.
4
4
 
5
5
  Every request is fully JavaScript-rendered in a real browser and routed through premium proxies, so it bypasses Cloudflare, DataDome, PerimeterX, Akamai, Kasada and similar anti-bot systems - from one simple call. You are only billed for successful requests.
6
6
 
@@ -38,7 +38,7 @@ puts product.page_type # "product"
38
38
  p product.data
39
39
  ```
40
40
 
41
- Get your API key at [app.scrapeunblocker.com](https://app.scrapeunblocker.com). The free trial does not require a credit card.
41
+ Get your API key at [app.scrapeunblocker.com](https://app.scrapeunblocker.com?utm_source=rubygems&utm_medium=integration&utm_campaign=ruby-sdk). The free trial does not require a credit card.
42
42
 
43
43
  ## Authentication
44
44
 
@@ -94,7 +94,7 @@ goods = su.oopbuy_search("running shoes", channel: "1688", page: 1, page_size: 2
94
94
  goods["results"].each { |item| puts "#{item['title']} #{item['price']} #{item['url']}" }
95
95
  ```
96
96
 
97
- `channel` is one of `"1688"` (default), `"taobao"` or `"official"`. `sort` is one of `"default"`, `"price_asc"`, `"price_desc"` or `"best_selling"`. `page_size` max is 60. Brand keywords return HTTP 422.
97
+ `channel` is one of `"1688"` (default), `"taobao"` or `"official"`. `sort` is one of `"default"`, `"price_asc"`, `"price_desc"` or `"best_selling"`. `page_size` max is 60. Oopbuy trademark-blocks brand keywords at its own backend: those come back as a successful `200` with `keywordRejected: true` and an empty `results` array, not an error.
98
98
 
99
99
  ## Cookies and the serving proxy
100
100
 
@@ -130,13 +130,15 @@ cars = su.skyscanner.carhire(pickup: "Madrid", pickup_datetime: "2026-09-01T10:0
130
130
 
131
131
  ## Error handling
132
132
 
133
- Non-2xx responses raise typed errors, all subclasses of `ScrapeUnblocker::Error`. Transient failures (429, 502, 503, 504 and network errors) are retried automatically with exponential backoff.
133
+ Non-2xx responses raise typed errors, all subclasses of `ScrapeUnblocker::Error`.
134
134
 
135
135
  ```ruby
136
136
  begin
137
137
  html = su.get_page_source("https://example.com")
138
138
  rescue ScrapeUnblocker::BlockedError
139
139
  # 403: the target blocked every bypass path (not billed)
140
+ rescue ScrapeUnblocker::PaymentRequiredError
141
+ # 402: quota, credit limit, or a failed payment - fix billing
140
142
  rescue ScrapeUnblocker::RateLimitError
141
143
  # 429: slow down
142
144
  rescue ScrapeUnblocker::UpstreamOutageError
@@ -146,15 +148,46 @@ end
146
148
 
147
149
  | Error | Status | Meaning |
148
150
  |---|---|---|
149
- | `InvalidRequestError` | 400 | Bad URL or unsupported scheme |
150
- | `AuthenticationError` | 401 | Missing or invalid API key |
151
+ | `InvalidRequestError` | 400 | Bad URL, unsupported scheme, or the API key header was not sent |
152
+ | `AuthenticationError` | 401 | Key not recognised - typo, stray whitespace, or a rotated key |
153
+ | `NoSubscriptionError` | 401 | Key is fine, but the account has no active plan |
154
+ | `PaymentRequiredError` | 402 | Billing block - base class for the three below |
155
+ | `QuotaExceededError` | 402 | The plan's requests for this period are used up |
156
+ | `CreditLimitExceededError` | 402 | Unpaid balance is past the account's credit limit |
157
+ | `PaymentFailedError` | 402 | A card payment was declined three times |
151
158
  | `BlockedError` | 403 | Blocked by bot protection on every path |
159
+ | `NotFoundError` | 404 | Page loaded but held no image (`get_image` only) |
160
+ | `BrowserTimeoutError` | 408 | Our browser run timed out before the page was ready |
161
+ | `UnsupportedContentError` | 415 | The URL serves something other than HTML |
162
+ | `ValidationError` | 422 | Missing or wrong-typed parameter; `body` holds the `detail` array |
152
163
  | `RateLimitError` | 429 | Too many requests |
153
164
  | `UpstreamOutageError` | 503 | The target origin is down |
154
- | `ServerError` | 5xx | Unexpected server error |
155
- | `TimeoutError` | - | Request exceeded the timeout |
165
+ | `ServerError` | 5xx | Unexpected server error, including a 504 upstream timeout |
166
+ | `TimeoutError` | - | This client gave up locally before the API answered |
156
167
  | `ConnectionError` | - | Could not reach the API |
157
168
 
169
+ Transient failures (429, 502, 503, 504 and network errors) are retried automatically with exponential backoff. A 401 or 402 is never retried - it clears when the key or the billing state changes, not on another attempt. Neither is billed or counted against your quota, because the request is refused before anything is scraped.
170
+
171
+ ### Billing errors (402)
172
+
173
+ The three billing blocks share a status code and differ only in their message, so the client raises a dedicated error for each:
174
+
175
+ ```ruby
176
+ begin
177
+ html = su.get_page_source("https://example.com")
178
+ rescue ScrapeUnblocker::QuotaExceededError
179
+ # plan quota (plus any overage allowance) is used up for this period
180
+ rescue ScrapeUnblocker::CreditLimitExceededError
181
+ # unpaid balance passed the account credit limit
182
+ rescue ScrapeUnblocker::PaymentFailedError
183
+ # card declined three times - update the payment method
184
+ end
185
+ ```
186
+
187
+ When more than one applies, the most serious wins: failed payment outranks credit limit, which outranks quota. All three lift by themselves once the billing state changes - access returns within about a minute, and the API key stays the same. One catch worth knowing: subscribing to a new plan does **not** clear `PaymentFailedError`, because the old unpaid invoice stays open until it is paid.
188
+
189
+ Full details for every status code: [developers.scrapeunblocker.com/errors](https://developers.scrapeunblocker.com/errors).
190
+
158
191
  ## Configuration
159
192
 
160
193
  ```ruby
@@ -168,9 +201,9 @@ ScrapeUnblocker::Client.new(
168
201
 
169
202
  ## Links
170
203
 
171
- - Documentation: https://developers.scrapeunblocker.com
172
- - Website: https://scrapeunblocker.com
173
- - Dashboard: https://app.scrapeunblocker.com
204
+ - Documentation: [developers.scrapeunblocker.com](https://developers.scrapeunblocker.com?utm_source=rubygems&utm_medium=integration&utm_campaign=ruby-sdk)
205
+ - Website: [scrapeunblocker.com](https://scrapeunblocker.com?utm_source=rubygems&utm_medium=integration&utm_campaign=ruby-sdk)
206
+ - Dashboard: [app.scrapeunblocker.com](https://app.scrapeunblocker.com?utm_source=rubygems&utm_medium=integration&utm_campaign=ruby-sdk)
174
207
 
175
208
  ## License
176
209
 
@@ -15,12 +15,89 @@ module ScrapeUnblocker
15
15
  end
16
16
  end
17
17
 
18
- # The API key is missing, malformed, or not recognised (HTTP 401).
18
+ # The API key was rejected (HTTP 401).
19
+ #
20
+ # Two cases produce a 401: an unrecognised key ("Unauthorized" - a typo,
21
+ # trailing whitespace, an empty value, or a key rotated in the dashboard),
22
+ # and a valid key on an account with no plan, which raises the
23
+ # NoSubscriptionError subclass. Omitting the key header entirely is a 400,
24
+ # not a 401. Nothing is scraped for a 401, so it is not billed.
19
25
  class AuthenticationError < APIError; end
20
26
 
21
- # The request was rejected as invalid, e.g. a malformed URL (HTTP 400).
27
+ # The key is valid but the account has no active plan (HTTP 401).
28
+ #
29
+ # Raised when the API answers a 401 with "No valid subscription". Pick a plan
30
+ # at https://app.scrapeunblocker.com - access resumes within about a minute,
31
+ # and the key does not change.
32
+ class NoSubscriptionError < AuthenticationError; end
33
+
34
+ # The account has a billing problem (HTTP 402).
35
+ #
36
+ # Credentials are fine - the request was stopped for a billing reason. There
37
+ # are three, each raised as a dedicated subclass: QuotaExceededError,
38
+ # CreditLimitExceededError and PaymentFailedError. Rescue this base class to
39
+ # handle all three.
40
+ #
41
+ # When more than one applies, the most serious wins: failed payment outranks
42
+ # credit limit, which outranks quota. All three lift by themselves once the
43
+ # billing state changes - access returns within roughly a minute, with no key
44
+ # change needed. Like a 401, a 402 is refused before anything is scraped, so
45
+ # it is never billed. Retrying is pointless; fix the billing state first.
46
+ class PaymentRequiredError < APIError; end
47
+
48
+ # Every request the plan allows this period has been used (HTTP 402).
49
+ #
50
+ # On plans that permit overages this only fires past the quota *plus* the
51
+ # overage allowance; inside that band requests still succeed and the extra
52
+ # usage is invoiced. Active coupon credit is spent before plan quota. The
53
+ # counter resets on the subscription's anniversary day, not the first of the
54
+ # month.
55
+ class QuotaExceededError < PaymentRequiredError; end
56
+
57
+ # The unpaid balance has passed the account's credit limit (HTTP 402).
58
+ #
59
+ # The balance counted here is the amount remaining on open invoices plus
60
+ # metered usage already consumed but not yet invoiced. Outstanding invoices
61
+ # are charged automatically when this triggers, so with a working card it
62
+ # usually clears itself within about a minute.
63
+ class CreditLimitExceededError < PaymentRequiredError; end
64
+
65
+ # A card payment has been declined three times (HTTP 402).
66
+ #
67
+ # Those attempts are the payment provider's automatic retries spread over
68
+ # several days, so a card has been failing for a while. Subscribing to a new
69
+ # plan does NOT clear this: the old unpaid invoice stays open, and the block
70
+ # stays until that specific invoice is paid.
71
+ class PaymentFailedError < PaymentRequiredError; end
72
+
73
+ # The request was rejected as invalid (HTTP 400).
74
+ #
75
+ # Raised for a malformed URL or unsupported scheme, for a missing
76
+ # x-scrapeunblocker-key header ("Missing x-scrapeunblocker-key"), and for a
77
+ # URL that belongs to a dedicated plugin - the response names the endpoint to
78
+ # use instead.
22
79
  class InvalidRequestError < APIError; end
23
80
 
81
+ # The page loaded but the requested element was absent (HTTP 404).
82
+ # Only #get_image raises this: the page rendered and held no <img> tag.
83
+ class NotFoundError < APIError; end
84
+
85
+ # The browser run did not finish in time on our side (HTTP 408).
86
+ #
87
+ # Distinct from TimeoutError, which is this client giving up locally. Here
88
+ # the API answered - it just could not render the page in time.
89
+ class BrowserTimeoutError < APIError; end
90
+
91
+ # The URL serves something other than HTML (HTTP 415).
92
+ # The message names the content type found. For images, use #get_image.
93
+ class UnsupportedContentError < APIError; end
94
+
95
+ # A request parameter is missing or has the wrong type (HTTP 422).
96
+ #
97
+ # Unlike the other errors the body is JSON, with a "detail" array pinpointing
98
+ # each problem field. Read it from #body.
99
+ class ValidationError < APIError; end
100
+
24
101
  # The target site blocked every available bypass path (HTTP 403).
25
102
  # Blocked calls are not billed.
26
103
  class BlockedError < APIError; end
@@ -32,6 +109,7 @@ module ScrapeUnblocker
32
109
  class UpstreamOutageError < APIError; end
33
110
 
34
111
  # ScrapeUnblocker returned an unexpected 5xx error.
112
+ # Also covers the 504 returned when a SERP fetch times out upstream.
35
113
  class ServerError < APIError; end
36
114
 
37
115
  # The request did not complete within the configured timeout.
@@ -40,24 +118,58 @@ module ScrapeUnblocker
40
118
  # The client could not reach the ScrapeUnblocker API.
41
119
  class ConnectionError < Error; end
42
120
 
121
+ BASE_MESSAGES = {
122
+ 400 => "Invalid request (bad URL, unsupported scheme, or missing API key header)",
123
+ 401 => "Authentication failed - key not recognised, or account has no active plan",
124
+ 402 => "Billing block - quota exceeded, credit limit exceeded, or a failed payment",
125
+ 403 => "Target blocked by bot protection on every bypass path",
126
+ 404 => "Requested element not found on the page",
127
+ 408 => "Browser run timed out before the page was ready",
128
+ 415 => "URL does not serve HTML",
129
+ 422 => "Validation error - see the detail array in the response body",
130
+ 429 => "Rate limited - too many requests",
131
+ 503 => "Upstream origin returned a server-side outage page",
132
+ 504 => "Fetch timed out upstream"
133
+ }.freeze
134
+ private_constant :BASE_MESSAGES
135
+
136
+ # A 401 is either an unknown key or a recognised key on an account without a
137
+ # plan, and only the body tells them apart. Anything unrecognised stays on
138
+ # the general AuthenticationError rather than guessing.
139
+ def self.auth_error_class(body)
140
+ (body || "").downcase.include?("no valid subscription") ? NoSubscriptionError : AuthenticationError
141
+ end
142
+ private_class_method :auth_error_class
143
+
144
+ # The three billing blocks share a status code and differ only in their
145
+ # plain-text body. An unrecognised body falls back to PaymentRequiredError.
146
+ def self.billing_error_class(body)
147
+ text = (body || "").downcase
148
+ return QuotaExceededError if text.include?("quota exceeded")
149
+ return CreditLimitExceededError if text.include?("credit limit exceeded")
150
+ return PaymentFailedError if text.include?("payment failed")
151
+
152
+ PaymentRequiredError
153
+ end
154
+ private_class_method :billing_error_class
155
+
43
156
  # Build a typed error from an HTTP status code and response body.
44
157
  def self.error_for_status(status, body)
45
158
  snippet = (body || "").strip.gsub(/\s+/, " ")
46
159
  snippet = "#{snippet[0, 200]}..." if snippet.length > 200
47
- base = {
48
- 400 => "Invalid request (bad URL or unsupported scheme)",
49
- 401 => "Authentication failed - check your API key",
50
- 403 => "Target blocked by bot protection on every bypass path",
51
- 429 => "Rate limited - too many requests",
52
- 503 => "Upstream origin returned a server-side outage page"
53
- }.fetch(status, "API returned HTTP #{status}")
160
+ base = BASE_MESSAGES.fetch(status, "API returned HTTP #{status}")
54
161
  message = snippet.empty? ? base : "#{base}: #{snippet}"
55
162
 
56
163
  klass =
57
164
  case status
58
165
  when 400 then InvalidRequestError
59
- when 401 then AuthenticationError
166
+ when 401 then auth_error_class(body)
167
+ when 402 then billing_error_class(body)
60
168
  when 403 then BlockedError
169
+ when 404 then NotFoundError
170
+ when 408 then BrowserTimeoutError
171
+ when 415 then UnsupportedContentError
172
+ when 422 then ValidationError
61
173
  when 429 then RateLimitError
62
174
  when 503 then UpstreamOutageError
63
175
  else status >= 500 ? ServerError : APIError
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ScrapeUnblocker
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.7"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scrapeunblocker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - ScrapeUnblocker
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-22 00:00:00.000000000 Z
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: JS-rendered pages that bypass Cloudflare, DataDome, PerimeterX and Akamai,
14
14
  plus Google SERP and Skyscanner flights/hotels/car-hire scraping as JSON.
@@ -27,13 +27,13 @@ files:
27
27
  - lib/scrapeunblocker/parsed_page.rb
28
28
  - lib/scrapeunblocker/skyscanner.rb
29
29
  - lib/scrapeunblocker/version.rb
30
- homepage: https://scrapeunblocker.com
30
+ homepage: https://scrapeunblocker.com?utm_source=rubygems&utm_medium=integration&utm_campaign=ruby-sdk
31
31
  licenses:
32
32
  - MIT
33
33
  metadata:
34
- homepage_uri: https://scrapeunblocker.com
34
+ homepage_uri: https://scrapeunblocker.com?utm_source=rubygems&utm_medium=integration&utm_campaign=ruby-sdk
35
35
  source_code_uri: https://github.com/ScrapeUnblocker/scrapeunblocker-ruby
36
- documentation_uri: https://developers.scrapeunblocker.com
36
+ documentation_uri: https://developers.scrapeunblocker.com?utm_source=rubygems&utm_medium=integration&utm_campaign=ruby-sdk
37
37
  changelog_uri: https://github.com/ScrapeUnblocker/scrapeunblocker-ruby/blob/main/CHANGELOG.md
38
38
  bug_tracker_uri: https://github.com/ScrapeUnblocker/scrapeunblocker-ruby/issues
39
39
  rubygems_mfa_required: 'true'