scrapeunblocker 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9c7871e5c3c3e31003c4a603ec9fda0784a79becb2b2617c922b89a53c39dc43
4
+ data.tar.gz: f784ea8c8411b97e6dd136aff9e39024d16a514fa059c516951d87e6d30e9874
5
+ SHA512:
6
+ metadata.gz: 8ce9fb00f43d3ab2f8433f880449581bf477d1bf90ee83036dc0384612cfbe385c6f112fc26407917dbc386629f3ed2e255cca68190dee832b637760e579c738
7
+ data.tar.gz: c56409262b4d73ff538bd39bb54f7cf2f417c80ba974a467fcafe91fc647b2bdce2ddf1d41b722ef09e949b73c3548f0834487ca09517832b9493946bfbf5a45
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - `ScrapeUnblocker::Client` with `get_page_source`, `get_parsed`, `get_page_with_cookies`, `serp`, `get_image`.
8
+ - Skyscanner plugins: flights, hotels, car hire (quotes + locations).
9
+ - Typed error hierarchy and automatic retry on transient failures.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ScrapeUnblocker
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 ADDED
@@ -0,0 +1,161 @@
1
+ # ScrapeUnblocker Ruby client
2
+
3
+ Official Ruby client for the [ScrapeUnblocker](https://scrapeunblocker.com) web scraping API.
4
+
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
+
7
+ - **Highest success rate on the market** (95%+ on live production traffic)
8
+ - **Rendered HTML or parsed JSON** - no per-site parsers to maintain
9
+ - Zero dependencies (uses the standard library), typed errors
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ gem install scrapeunblocker
15
+ ```
16
+
17
+ Or in a Gemfile:
18
+
19
+ ```ruby
20
+ gem "scrapeunblocker"
21
+ ```
22
+
23
+ Requires Ruby 2.7+.
24
+
25
+ ## Quickstart
26
+
27
+ ```ruby
28
+ require "scrapeunblocker"
29
+
30
+ su = ScrapeUnblocker::Client.new # reads SCRAPEUNBLOCKER_KEY, or Client.new(api_key: "YOUR_API_KEY")
31
+
32
+ # Rendered HTML for any URL
33
+ html = su.get_page_source("https://example.com")
34
+
35
+ # Structured JSON instead of HTML (products, listings, search results, ...)
36
+ product = su.get_parsed("https://www.amazon.com/dp/B08N5WRWNW")
37
+ puts product.page_type # "product"
38
+ p product.data
39
+ ```
40
+
41
+ Get your API key at [app.scrapeunblocker.com](https://app.scrapeunblocker.com). The free trial does not require a credit card.
42
+
43
+ ## Authentication
44
+
45
+ Set an environment variable and the client picks it up:
46
+
47
+ ```bash
48
+ export SCRAPEUNBLOCKER_KEY="YOUR_API_KEY"
49
+ ```
50
+
51
+ ```ruby
52
+ su = ScrapeUnblocker::Client.new # reads SCRAPEUNBLOCKER_KEY
53
+ ```
54
+
55
+ ## Fetch rendered HTML
56
+
57
+ ```ruby
58
+ html = su.get_page_source(
59
+ "https://www.nordstrom.com/browse/women/clothing/dresses",
60
+ proxy_country: "US", # route through a specific country
61
+ time_sleep: 3 # wait extra seconds after load
62
+ )
63
+ ```
64
+
65
+ ## Get parsed JSON
66
+
67
+ ```ruby
68
+ result = su.get_parsed("https://www.walmart.com/ip/12345")
69
+ puts result.page_type # e.g. "product"
70
+ puts result.source # how it was extracted
71
+ p result.data # the fields
72
+
73
+ # If a parse ever comes back wrong, force a fresh set of rules:
74
+ fresh = su.get_parsed(url, refresh_rules: true, rules_hint: "price is missing")
75
+ ```
76
+
77
+ ## Google search (SERP)
78
+
79
+ ```ruby
80
+ serp = su.serp("web scraping api", pages_to_check: 2, proxy_country: "US")
81
+ ```
82
+
83
+ ## Cookies and the serving proxy
84
+
85
+ ```ruby
86
+ page = su.get_page_with_cookies("https://example.com")
87
+ puts page.html
88
+ p page.cookies
89
+ puts page.proxy
90
+ ```
91
+
92
+ ## Images
93
+
94
+ ```ruby
95
+ bytes = su.get_image("https://example.com/photo.jpg")
96
+ File.binwrite("photo.jpg", bytes)
97
+ ```
98
+
99
+ ## Skyscanner plugins
100
+
101
+ Flights, hotels and car hire as JSON:
102
+
103
+ ```ruby
104
+ locations = su.skyscanner.flight_locations("London")
105
+
106
+ flights = su.skyscanner.flights(
107
+ origin: "London", dest: "New York",
108
+ depart_date: "2026-09-01", adults: 1, currency: "USD"
109
+ )
110
+
111
+ hotels = su.skyscanner.hotels(destination: "Madrid", checkin: "2026-09-01", checkout: "2026-09-03")
112
+ cars = su.skyscanner.carhire(pickup: "Madrid", pickup_datetime: "2026-09-01T10:00", dropoff_datetime: "2026-09-03T10:00")
113
+ ```
114
+
115
+ ## Error handling
116
+
117
+ 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.
118
+
119
+ ```ruby
120
+ begin
121
+ html = su.get_page_source("https://example.com")
122
+ rescue ScrapeUnblocker::BlockedError
123
+ # 403: the target blocked every bypass path (not billed)
124
+ rescue ScrapeUnblocker::RateLimitError
125
+ # 429: slow down
126
+ rescue ScrapeUnblocker::UpstreamOutageError
127
+ # 503: the target site itself is down - retry later
128
+ end
129
+ ```
130
+
131
+ | Error | Status | Meaning |
132
+ |---|---|---|
133
+ | `InvalidRequestError` | 400 | Bad URL or unsupported scheme |
134
+ | `AuthenticationError` | 401 | Missing or invalid API key |
135
+ | `BlockedError` | 403 | Blocked by bot protection on every path |
136
+ | `RateLimitError` | 429 | Too many requests |
137
+ | `UpstreamOutageError` | 503 | The target origin is down |
138
+ | `ServerError` | 5xx | Unexpected server error |
139
+ | `TimeoutError` | - | Request exceeded the timeout |
140
+ | `ConnectionError` | - | Could not reach the API |
141
+
142
+ ## Configuration
143
+
144
+ ```ruby
145
+ ScrapeUnblocker::Client.new(
146
+ api_key: nil, # or SCRAPEUNBLOCKER_KEY env var
147
+ base_url: "https://api.scrapeunblocker.com",
148
+ timeout: 180, # seconds; protected pages can be slow
149
+ max_retries: 2
150
+ )
151
+ ```
152
+
153
+ ## Links
154
+
155
+ - Documentation: https://developers.scrapeunblocker.com
156
+ - Website: https://scrapeunblocker.com
157
+ - Dashboard: https://app.scrapeunblocker.com
158
+
159
+ ## License
160
+
161
+ MIT
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ require_relative "errors"
8
+ require_relative "parsed_page"
9
+ require_relative "skyscanner"
10
+ require_relative "version"
11
+
12
+ module ScrapeUnblocker
13
+ # Client for the ScrapeUnblocker API.
14
+ #
15
+ # su = ScrapeUnblocker::Client.new(api_key: "YOUR_API_KEY")
16
+ # html = su.get_page_source("https://example.com")
17
+ class Client
18
+ DEFAULT_BASE_URL = "https://api.scrapeunblocker.com"
19
+ API_KEY_HEADER = "x-scrapeunblocker-key"
20
+ RETRYABLE = [429, 502, 503, 504].freeze
21
+
22
+ # @return [Skyscanner] the Skyscanner plugin endpoints
23
+ attr_reader :skyscanner
24
+
25
+ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: 180, max_retries: 2, transport: nil)
26
+ @api_key = api_key || ENV["SCRAPEUNBLOCKER_KEY"]
27
+ if @api_key.nil? || @api_key.empty?
28
+ raise Error, "No API key provided. Pass api_key: or set the " \
29
+ "SCRAPEUNBLOCKER_KEY environment variable. Get your key " \
30
+ "at https://app.scrapeunblocker.com"
31
+ end
32
+ @base_url = base_url.sub(%r{/+\z}, "")
33
+ @timeout = timeout
34
+ @max_retries = max_retries
35
+ @transport = transport || method(:net_http_transport)
36
+ @skyscanner = Skyscanner.new(self)
37
+ end
38
+
39
+ # Fetch a URL and return the fully rendered HTML.
40
+ def get_page_source(url, proxy_country: nil, time_sleep: nil, method: nil, value: nil, method_timeout: nil)
41
+ request("/getPageSource",
42
+ url: url, proxy_country: proxy_country, time_sleep: time_sleep,
43
+ method: method, value: value, method_timeout: method_timeout)[:body]
44
+ end
45
+
46
+ # Fetch a URL and return structured JSON instead of HTML.
47
+ def get_parsed(url, proxy_country: nil, time_sleep: nil, refresh_rules: false, rules_hint: nil)
48
+ body = request("/getPageSource",
49
+ url: url, parsed_data: true, proxy_country: proxy_country,
50
+ time_sleep: time_sleep,
51
+ refresh_rules: (refresh_rules ? true : nil),
52
+ rules_hint: rules_hint)[:body]
53
+ ParsedPage.from_hash(JSON.parse(body))
54
+ end
55
+
56
+ # Fetch a URL and also return the cookies and proxy that served it.
57
+ def get_page_with_cookies(url, proxy_country: nil, time_sleep: nil)
58
+ body = request("/getPageSource",
59
+ url: url, get_cookies: true, proxy_country: proxy_country,
60
+ time_sleep: time_sleep)[:body]
61
+ PageResult.from_hash(JSON.parse(body))
62
+ end
63
+
64
+ # Run a Google search and return the parsed SERP as a Hash.
65
+ def serp(keyword, proxy_country: nil, pages_to_check: 1, wait_after_load: 0, captcha_pause: 0)
66
+ post_json("/serpApi",
67
+ keyword: keyword, proxy_country: proxy_country,
68
+ pages_to_check: pages_to_check,
69
+ wait_after_load: (wait_after_load.zero? ? nil : wait_after_load),
70
+ captcha_pause: (captcha_pause.zero? ? nil : captcha_pause))
71
+ end
72
+
73
+ # Fetch an image URL through the bypass chain and return its raw bytes.
74
+ def get_image(url, proxy_country: nil)
75
+ request("/getImage", url: url, proxy_country: proxy_country)[:body]
76
+ end
77
+
78
+ # @api private
79
+ def post_json(path, params)
80
+ JSON.parse(request(path, params)[:body])
81
+ end
82
+
83
+ private
84
+
85
+ def request(path, params)
86
+ url = "#{@base_url}#{path}?#{build_query(params)}"
87
+ headers = {
88
+ API_KEY_HEADER => @api_key,
89
+ "User-Agent" => "scrapeunblocker-ruby/#{VERSION}",
90
+ "Accept" => "*/*"
91
+ }
92
+
93
+ attempt = 0
94
+ loop do
95
+ result = @transport.call(url, headers)
96
+ status = result[:status].to_i
97
+ body = result[:body].to_s
98
+
99
+ if RETRYABLE.include?(status) && attempt < @max_retries
100
+ sleep([0.5 * (2**attempt), 8.0].min)
101
+ attempt += 1
102
+ next
103
+ end
104
+
105
+ return { status: status, body: body } if status >= 200 && status < 300
106
+
107
+ raise ScrapeUnblocker.error_for_status(status, body)
108
+ end
109
+ end
110
+
111
+ def build_query(params)
112
+ params.each_with_object([]) do |(key, value), acc|
113
+ next if value.nil?
114
+
115
+ value = value ? "true" : "false" if value == true || value == false
116
+ acc << "#{key}=#{URI.encode_www_form_component(value.to_s)}"
117
+ end.join("&")
118
+ end
119
+
120
+ def net_http_transport(url, headers)
121
+ uri = URI.parse(url)
122
+ http = Net::HTTP.new(uri.host, uri.port)
123
+ http.use_ssl = uri.scheme == "https"
124
+ http.open_timeout = 30
125
+ http.read_timeout = @timeout
126
+
127
+ request = Net::HTTP::Post.new(uri.request_uri)
128
+ headers.each { |k, v| request[k] = v }
129
+
130
+ begin
131
+ response = http.request(request)
132
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
133
+ raise TimeoutError, "Request timed out after #{@timeout}s: #{e.message}"
134
+ rescue StandardError => e
135
+ raise ConnectionError, "Could not reach the API: #{e.message}"
136
+ end
137
+
138
+ { status: response.code.to_i, body: response.body }
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScrapeUnblocker
4
+ # Base class for every error raised by this library.
5
+ class Error < StandardError; end
6
+
7
+ # An error response returned by the ScrapeUnblocker API.
8
+ class APIError < Error
9
+ attr_reader :status_code, :body
10
+
11
+ def initialize(message, status_code:, body: nil)
12
+ super(message)
13
+ @status_code = status_code
14
+ @body = body
15
+ end
16
+ end
17
+
18
+ # The API key is missing, malformed, or not recognised (HTTP 401).
19
+ class AuthenticationError < APIError; end
20
+
21
+ # The request was rejected as invalid, e.g. a malformed URL (HTTP 400).
22
+ class InvalidRequestError < APIError; end
23
+
24
+ # The target site blocked every available bypass path (HTTP 403).
25
+ # Blocked calls are not billed.
26
+ class BlockedError < APIError; end
27
+
28
+ # Too many requests against your account in a short window (HTTP 429).
29
+ class RateLimitError < APIError; end
30
+
31
+ # The origin site returned a server-side outage page (HTTP 503).
32
+ class UpstreamOutageError < APIError; end
33
+
34
+ # ScrapeUnblocker returned an unexpected 5xx error.
35
+ class ServerError < APIError; end
36
+
37
+ # The request did not complete within the configured timeout.
38
+ class TimeoutError < Error; end
39
+
40
+ # The client could not reach the ScrapeUnblocker API.
41
+ class ConnectionError < Error; end
42
+
43
+ # Build a typed error from an HTTP status code and response body.
44
+ def self.error_for_status(status, body)
45
+ snippet = (body || "").strip.gsub(/\s+/, " ")
46
+ 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}")
54
+ message = snippet.empty? ? base : "#{base}: #{snippet}"
55
+
56
+ klass =
57
+ case status
58
+ when 400 then InvalidRequestError
59
+ when 401 then AuthenticationError
60
+ when 403 then BlockedError
61
+ when 429 then RateLimitError
62
+ when 503 then UpstreamOutageError
63
+ else status >= 500 ? ServerError : APIError
64
+ end
65
+
66
+ klass.new(message, status_code: status, body: body)
67
+ end
68
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScrapeUnblocker
4
+ # Structured data extracted from a page (#get_parsed).
5
+ class ParsedPage
6
+ # @return [String, nil] what the API classified the page as, e.g. "product"
7
+ attr_reader :page_type
8
+ # @return [String, nil] how the data was extracted
9
+ attr_reader :source
10
+ # @return the extracted fields
11
+ attr_reader :data
12
+ # @return [Hash] the full JSON payload as returned by the API
13
+ attr_reader :raw
14
+
15
+ def initialize(page_type:, source:, data:, raw:)
16
+ @page_type = page_type
17
+ @source = source
18
+ @data = data
19
+ @raw = raw
20
+ end
21
+
22
+ def self.from_hash(payload)
23
+ inner = payload["data"].is_a?(Hash) ? payload["data"] : payload
24
+ new(
25
+ page_type: inner["page_type"],
26
+ source: inner["source"],
27
+ data: inner["data"],
28
+ raw: payload
29
+ )
30
+ end
31
+ end
32
+
33
+ # HTML plus the cookies and proxy that served it (#get_page_with_cookies).
34
+ class PageResult
35
+ attr_reader :html, :cookies, :proxy, :raw
36
+
37
+ def initialize(html:, cookies:, proxy:, raw:)
38
+ @html = html
39
+ @cookies = cookies
40
+ @proxy = proxy
41
+ @raw = raw
42
+ end
43
+
44
+ def self.from_hash(payload)
45
+ new(
46
+ html: payload["html"] || payload["page_source"] || payload["content"],
47
+ cookies: payload["cookies"],
48
+ proxy: payload["proxy"] || payload["proxy_address"],
49
+ raw: payload
50
+ )
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScrapeUnblocker
4
+ # Skyscanner plugin endpoints (flights, hotels, car hire).
5
+ class Skyscanner
6
+ # @api private
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ def flight_locations(q, params = {})
12
+ @client.post_json("/flights/skyscanner-locations", { q: q }.merge(params))
13
+ end
14
+
15
+ def flights(params = {})
16
+ @client.post_json("/flights/skyscanner-quotes", params)
17
+ end
18
+
19
+ def hotel_locations(q, params = {})
20
+ @client.post_json("/hotels/skyscanner-locations", { q: q }.merge(params))
21
+ end
22
+
23
+ def hotels(params = {})
24
+ @client.post_json("/hotels/skyscanner-quotes", params)
25
+ end
26
+
27
+ def carhire_locations(q, params = {})
28
+ @client.post_json("/carhire/skyscanner-locations", { q: q }.merge(params))
29
+ end
30
+
31
+ def carhire(params = {})
32
+ @client.post_json("/carhire/skyscanner-quotes", params)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ScrapeUnblocker
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scrapeunblocker/version"
4
+ require_relative "scrapeunblocker/errors"
5
+ require_relative "scrapeunblocker/parsed_page"
6
+ require_relative "scrapeunblocker/skyscanner"
7
+ require_relative "scrapeunblocker/client"
8
+
9
+ # Official Ruby client for the ScrapeUnblocker web scraping API.
10
+ #
11
+ # require "scrapeunblocker"
12
+ #
13
+ # su = ScrapeUnblocker::Client.new # reads SCRAPEUNBLOCKER_KEY
14
+ # html = su.get_page_source("https://example.com")
15
+ # product = su.get_parsed("https://www.amazon.com/dp/B08N5WRWNW")
16
+ module ScrapeUnblocker
17
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: scrapeunblocker
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ScrapeUnblocker
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-16 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: JS-rendered pages that bypass Cloudflare, DataDome, PerimeterX and Akamai,
14
+ plus Google SERP and Skyscanner flights/hotels/car-hire scraping as JSON.
15
+ email:
16
+ - support@scrapeunblocker.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - lib/scrapeunblocker.rb
25
+ - lib/scrapeunblocker/client.rb
26
+ - lib/scrapeunblocker/errors.rb
27
+ - lib/scrapeunblocker/parsed_page.rb
28
+ - lib/scrapeunblocker/skyscanner.rb
29
+ - lib/scrapeunblocker/version.rb
30
+ homepage: https://scrapeunblocker.com
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ homepage_uri: https://scrapeunblocker.com
35
+ source_code_uri: https://github.com/ScrapeUnblocker/scrapeunblocker-ruby
36
+ documentation_uri: https://developers.scrapeunblocker.com
37
+ changelog_uri: https://github.com/ScrapeUnblocker/scrapeunblocker-ruby/blob/main/CHANGELOG.md
38
+ bug_tracker_uri: https://github.com/ScrapeUnblocker/scrapeunblocker-ruby/issues
39
+ rubygems_mfa_required: 'true'
40
+ post_install_message:
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 2.7.0
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 3.5.22
56
+ signing_key:
57
+ specification_version: 4
58
+ summary: Official Ruby client for the ScrapeUnblocker web scraping API.
59
+ test_files: []