sursolver 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: e9167fab5ec1966f59855dabc0f071df537b946799aab078770d3a227fbdfe8a
4
+ data.tar.gz: b10715a8428c6a058dd69c8eae1a35f9e5a3d9d24e77511631126ef0424e4263
5
+ SHA512:
6
+ metadata.gz: 0c4c3d1f52248e8b32a1eaaaaafd45f5e53ed7f7ca12638ca4b882515601c78fddc9a591c6f0a11aa64973067cd84626413b4fb666425bf9f6c930912cbf6a7a
7
+ data.tar.gz: a844fbc99266c28f58733257353c3a99017367a6e709618ba5b12c5d1c7df992faee9622191d752b724803cb73d6ec5533cf168553e2b391596426e72dba2a52
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — unreleased
4
+
5
+ First release. Tested against the SDK specification revision in `SPEC_VERSION` (catalog of
6
+ 2026-11-04).
7
+
8
+ - `SurSolver::Client` with `create_task`, `get_task_result`, `get_balance`, `list_task_types`,
9
+ `list_services` and `solve` (3 s polling, 210 s deadline, `on_task_created:`).
10
+ - Typed errors: `AuthenticationError`, `InvalidTaskError` (with `supported_user_agents`),
11
+ `InsufficientBalanceError`, `NoSlotAvailableError`, `TaskNotFoundError`, `TaskFailedError`,
12
+ `ApiError`, `TransportError` (with `may_have_created_task?`), `ProtocolError`,
13
+ `SolveTimeoutError`, `ConfigurationError`, all under `SurSolver::Error`.
14
+ - `SurSolver::TaskTypes` and `SurSolver::ErrorCodes` constants generated from the public catalog.
15
+ - Standard library only (`net/http`, `json`).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SurSolver
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,186 @@
1
+ # SurSolver for Ruby — captcha solver API client
2
+
3
+ Solve **reCAPTCHA v3**, **Cloudflare Turnstile**, **Cloudflare Challenge**, **GeeTest**,
4
+ **DataDome**, **AWS WAF**, **Akamai**, **Kasada** and image captchas from Ruby with the official
5
+ [SurSolver](https://sursolver.com) gem. One call submits the captcha and returns the token or
6
+ cookie. Ruby 3.4+, standard library only.
7
+
8
+ ```powershell
9
+ gem install sursolver
10
+ ```
11
+
12
+ ## Quick start
13
+
14
+ ```ruby
15
+ require "sursolver"
16
+
17
+ API_KEY = "YOUR_API_KEY"
18
+ USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36"
19
+
20
+ client = SurSolver::Client.new(API_KEY)
21
+ result = client.solve(
22
+ "type" => SurSolver::TaskTypes::TURNSTILE_TOKEN_PROXY_LESS,
23
+ "websiteURL" => "https://example.com/login",
24
+ "websiteKey" => "0x4AAAAAAA...",
25
+ "userAgent" => USER_AGENT
26
+ )
27
+ puts result.solution["token"]
28
+ ```
29
+
30
+ Send the same `userAgent` with the request that uses the result. Tokens and cookies are
31
+ single-use, so use them right away. The examples below reuse `client` and `USER_AGENT`.
32
+
33
+ ## Examples
34
+
35
+ ### Solve reCAPTCHA v3 in Ruby
36
+
37
+ ```ruby
38
+ result = client.solve(
39
+ "type" => SurSolver::TaskTypes::RE_CAPTCHA_V3_TOKEN_PROXY_LESS,
40
+ "websiteURL" => "https://example.com/login",
41
+ "websiteKey" => "6Le-wvkS...",
42
+ "pageAction" => "login",
43
+ "userAgent" => USER_AGENT
44
+ )
45
+ puts result.solution["gRecaptchaResponse"]
46
+ ```
47
+
48
+ For reCAPTCHA v3 Enterprise use `SurSolver::TaskTypes::RE_CAPTCHA_V3_ENTERPRISE_TOKEN_PROXY_LESS`.
49
+
50
+ ### Solve Cloudflare Turnstile
51
+
52
+ ```ruby
53
+ result = client.solve(
54
+ "type" => SurSolver::TaskTypes::TURNSTILE_TOKEN_PROXY_LESS,
55
+ "websiteURL" => "https://example.com/signup",
56
+ "websiteKey" => "0x4AAAAAAA...",
57
+ "userAgent" => USER_AGENT
58
+ )
59
+ # Submit the token as the cf-turnstile-response form field.
60
+ puts result.solution["token"]
61
+ ```
62
+
63
+ ### Bypass the Cloudflare Challenge page (cf_clearance)
64
+
65
+ ```ruby
66
+ result = client.solve(
67
+ "type" => SurSolver::TaskTypes::CLOUDFLARE_CHALLENGE_COOKIE,
68
+ "websiteURL" => "https://example.com/protected",
69
+ "proxy" => "http://user:password@203.0.113.10:8000",
70
+ "userAgent" => USER_AGENT
71
+ )
72
+ puts result.solution["cf_clearance"]
73
+ ```
74
+
75
+ Replay the cookie through the same proxy and with the same `userAgent`.
76
+
77
+ ### Solve GeeTest v4
78
+
79
+ ```ruby
80
+ result = client.solve(
81
+ "type" => SurSolver::TaskTypes::GEE_TEST_TOKEN_PROXY_LESS,
82
+ "websiteURL" => "https://example.com/login",
83
+ "captchaId" => "e392e1d7fd421dc63325744d5a2b9c73",
84
+ "userAgent" => USER_AGENT
85
+ )
86
+ puts result.solution["lot_number"], result.solution["pass_token"]
87
+ ```
88
+
89
+ GeeTest v3: send `gt` and a fresh `challenge` instead of `captchaId`.
90
+
91
+ ### Solve the DataDome slider
92
+
93
+ ```ruby
94
+ result = client.solve(
95
+ "type" => SurSolver::TaskTypes::DATA_DOME_SLIDER_COOKIE,
96
+ "websiteURL" => "https://example.com/",
97
+ "captchaUrl" => "https://geo.captcha-delivery.com/captcha/?initialCid=...",
98
+ "proxy" => "http://user:password@203.0.113.10:8000",
99
+ "userAgent" => USER_AGENT
100
+ )
101
+ puts result.solution["datadome"]
102
+ ```
103
+
104
+ ### Get an AWS WAF token (silent challenge)
105
+
106
+ ```ruby
107
+ result = client.solve(
108
+ "type" => SurSolver::TaskTypes::AWS_CHALLENGE_COOKIE,
109
+ "websiteURL" => "https://example.com/",
110
+ "scriptUrl" => "https://example.token.awswaf.com/example/challenge.js",
111
+ "userAgent" => USER_AGENT
112
+ )
113
+ puts result.solution["aws-waf-token"]
114
+ ```
115
+
116
+ The visual AWS WAF CAPTCHA is `SurSolver::TaskTypes::AWS_CAPTCHA_COOKIE` and needs your proxy.
117
+
118
+ ### Solve an image captcha (image to text)
119
+
120
+ ```ruby
121
+ require "base64"
122
+
123
+ result = client.solve(
124
+ "type" => SurSolver::TaskTypes::IMAGE_TO_TEXT_TASK,
125
+ "module" => "1",
126
+ "body" => Base64.strict_encode64(File.binread("captcha.png"))
127
+ )
128
+ puts result.solution["answer"]
129
+ ```
130
+
131
+ ## Supported captchas
132
+
133
+ | Captcha | `task.type` | Guide |
134
+ |---|---|---|
135
+ | reCAPTCHA v3 | `ReCaptchaV3TokenProxyLess` | [recaptcha-v3](https://sursolver.com/solvers/recaptcha-v3) |
136
+ | reCAPTCHA v3 Enterprise | `ReCaptchaV3EnterpriseTokenProxyLess` | [recaptcha-v3-enterprise](https://sursolver.com/solvers/recaptcha-v3-enterprise) |
137
+ | Cloudflare Turnstile | `TurnstileTokenProxyLess` | [turnstile](https://sursolver.com/solvers/turnstile) |
138
+ | Cloudflare Challenge | `CloudflareChallengeCookie` | [cloudflare-challenge](https://sursolver.com/solvers/cloudflare-challenge) |
139
+ | GeeTest v3 / v4 | `GeeTestTokenProxyLess` | [geetest](https://sursolver.com/solvers/geetest) |
140
+ | DataDome | `DataDomeSliderCookie` | [datadome](https://sursolver.com/solvers/datadome) |
141
+ | AWS WAF Challenge | `AwsChallengeCookie` | [aws-challenge](https://sursolver.com/solvers/aws-challenge) |
142
+ | AWS WAF CAPTCHA | `AwsCaptchaCookie` | [aws-captcha](https://sursolver.com/solvers/aws-captcha) |
143
+ | Akamai (`_abck`) | `AkamaiCookieProxyLess` | [akamai](https://sursolver.com/solvers/akamai) |
144
+ | Akamai SBSD | `AkamaiSbsdCookie` | [akamai-sbsd](https://sursolver.com/solvers/akamai-sbsd) |
145
+ | Akamai BMP (Android) | `AkamaiBmpTask` | [akamai-bmp](https://sursolver.com/solvers/akamai-bmp) |
146
+ | Kasada | `KasadaTokenProxyLess` | [kasada](https://sursolver.com/solvers/kasada) |
147
+ | Shape | `ShapeCookieProxyLess` | [shape](https://sursolver.com/solvers/shape) |
148
+ | Imperva (reese84 / utmvc) | `ImpervaCookie`, `ImpervaUtmvcCookie` | [imperva](https://sursolver.com/solvers/imperva) |
149
+ | Vercel Security Checkpoint | `VercelCookie` | [vercel](https://sursolver.com/solvers/vercel) |
150
+ | FriendlyCaptcha | `FriendlyCaptchaTokenProxyLess` | [friendlycaptcha](https://sursolver.com/solvers/friendlycaptcha) |
151
+ | MTCaptcha | `MtCaptchaTokenProxyLess` | [mtcaptcha](https://sursolver.com/solvers/mtcaptcha) |
152
+ | Prosopo | `ProsopoTokenProxyLess` | [prosopo](https://sursolver.com/solvers/prosopo) |
153
+ | CaptchaFox | `CaptchaFoxToken` | [captchafox](https://sursolver.com/solvers/captchafox) |
154
+ | Altcha | `AltchaTokenProxyLess` | [altcha](https://sursolver.com/solvers/altcha) |
155
+ | Lemin | `LeminTokenProxyLess` | [lemin](https://sursolver.com/solvers/lemin) |
156
+ | Castle (Android) | `CastleAndroidToken` | [castle-android](https://sursolver.com/solvers/castle-android) |
157
+ | Image to Text | `ImageToTextTask` | [image-to-text](https://sursolver.com/solvers/image-to-text) |
158
+
159
+ Each guide lists the fields to send and where to find them on the page. The live list is
160
+ `client.list_task_types`; prices are `client.list_services`.
161
+
162
+ ## Errors
163
+
164
+ ```ruby
165
+ begin
166
+ result = client.solve(task)
167
+ rescue SurSolver::NoSlotAvailableError
168
+ # all your threads are busy: try again in a moment
169
+ rescue SurSolver::TaskFailedError
170
+ # this captcha could not be solved; its charge is refunded
171
+ end
172
+ ```
173
+
174
+ Other errors: `SurSolver::AuthenticationError` (wrong API key), `SurSolver::InvalidTaskError` (a
175
+ field is missing or wrong), `SurSolver::InsufficientBalanceError` (top up),
176
+ `SurSolver::SolveTimeoutError` (no result in time). All inherit from `SurSolver::Error`.
177
+
178
+ ## Links
179
+
180
+ - [Documentation](https://sursolver.com/docs) and [API reference](https://sursolver.com/docs/api)
181
+ - [Proxies guide](https://sursolver.com/docs/guides/proxies) and [error codes](https://sursolver.com/docs/guides/errors)
182
+ - [Pricing](https://sursolver.com/pricing)
183
+
184
+ ## License
185
+
186
+ MIT
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module SurSolver
9
+ # Client of the SurSolver captcha-solving API. Durations are in seconds.
10
+ #
11
+ # The API key travels only in request bodies and never appears in errors or #inspect.
12
+ class Client
13
+ DEFAULT_BASE_URL = "https://api.sursolver.com"
14
+ DEFAULT_REQUEST_TIMEOUT = 30
15
+ DEFAULT_POLL_INTERVAL = 3
16
+ DEFAULT_SOLVE_TIMEOUT = 210
17
+ DEFAULT_MAX_SUBMIT_RETRIES = 1
18
+ # Upper bound of the random delay added to a submit retry, in seconds.
19
+ SUBMIT_RETRY_JITTER = 1
20
+
21
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
22
+ NO_RESPONSE_ERRORS = [IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError, Net::ProtocolError].freeze
23
+ private_constant :LOOPBACK_HOSTS, :NO_RESPONSE_ERRORS
24
+
25
+ # Builds a client; raises ConfigurationError for an unusable option (nothing is sent).
26
+ #
27
+ # transport: object with #call(Request) -> Response (default NetHttpTransport).
28
+ # clock: object with #now and #sleep(seconds) (tests). random: callable returning [0, 1).
29
+ def initialize(api_key, base_url: DEFAULT_BASE_URL, request_timeout: DEFAULT_REQUEST_TIMEOUT,
30
+ poll_interval: DEFAULT_POLL_INTERVAL, solve_timeout: DEFAULT_SOLVE_TIMEOUT,
31
+ max_submit_retries: DEFAULT_MAX_SUBMIT_RETRIES, app_id: nil, user_agent_suffix: nil,
32
+ transport: nil, clock: nil, random: nil)
33
+ raise ConfigurationError, "api_key must be a non-empty string" if !api_key.is_a?(String) || api_key.strip.empty?
34
+ raise ConfigurationError, "api_key has leading or trailing whitespace" if api_key.strip != api_key
35
+
36
+ @base_url = normalize_base_url(base_url)
37
+ { request_timeout: request_timeout, poll_interval: poll_interval, solve_timeout: solve_timeout }.each do |name, value|
38
+ require_positive(name, value)
39
+ end
40
+ unless max_submit_retries.is_a?(Integer) && max_submit_retries >= 0
41
+ raise ConfigurationError, "max_submit_retries must be a non-negative integer"
42
+ end
43
+
44
+ @user_agent = "sursolver-ruby/#{VERSION}"
45
+ unless user_agent_suffix.nil?
46
+ unless user_agent_suffix.is_a?(String) && user_agent_suffix.match?(/\A[\x20-\x7e]+\z/)
47
+ raise ConfigurationError, "user_agent_suffix must be non-empty printable ASCII"
48
+ end
49
+
50
+ @user_agent += " #{user_agent_suffix}"
51
+ end
52
+ @api_key = api_key
53
+ @request_timeout = request_timeout
54
+ @poll_interval = poll_interval
55
+ @solve_timeout = solve_timeout
56
+ @max_submit_retries = max_submit_retries
57
+ @app_id = app_id&.strip
58
+ @app_id = nil if @app_id&.empty?
59
+ @transport = transport || NetHttpTransport.new
60
+ @clock = clock || SystemClock.new
61
+ @random = random || -> { Random.rand }
62
+ end
63
+
64
+ # Never reveals the API key.
65
+ def inspect
66
+ "#<SurSolver::Client base_url=#{@base_url}>"
67
+ end
68
+ alias to_s inspect
69
+
70
+ # Submits a task and returns its id. The task is charged from this point. It is never retried:
71
+ # after a TransportError the task may exist (check get_balance before submitting again).
72
+ #
73
+ # task: Hash with "type" plus that type's fields, sent verbatim. Browser types require "userAgent".
74
+ def create_task(task)
75
+ payload = { "clientKey" => @api_key, "task" => task }
76
+ payload["appId"] = @app_id if @app_id
77
+ body = solver_call("createTask", "/createTask", payload)
78
+ task_id = body["taskId"]
79
+ raise ProtocolError.new("createTask", "errorId 0 without a taskId") unless task_id.is_a?(String) && !task_id.empty?
80
+
81
+ task_id
82
+ end
83
+
84
+ # Reads a task's state once. A terminal failure raises TaskFailedError.
85
+ def get_task_result(task_id)
86
+ body = solver_call("getTaskResult", "/getTaskResult", { "clientKey" => @api_key, "taskId" => task_id }, task_id)
87
+ case body["status"]
88
+ when "processing" then TaskResult.new(status: "processing", solution: nil)
89
+ when "ready"
90
+ solution = body["solution"]
91
+ raise ProtocolError.new("getTaskResult", "status ready without a solution object") unless solution.is_a?(Hash)
92
+
93
+ TaskResult.new(status: "ready", solution: solution)
94
+ else
95
+ raise ProtocolError.new("getTaskResult", "unknown status #{JSON.generate(body["status"])}")
96
+ end
97
+ end
98
+
99
+ # The cash balance in USD as the JSON number the API sends. Display it; do not do money
100
+ # arithmetic on it (the dashboard's GET /v1/balance has the exact decimal).
101
+ def get_balance # rubocop:disable Naming/AccessorMethodName
102
+ balance = solver_call("getBalance", "/getBalance", { "clientKey" => @api_key })["balance"]
103
+ raise ProtocolError.new("getBalance", "errorId 0 without a numeric balance") unless balance.is_a?(Numeric) && balance.finite?
104
+
105
+ balance.to_f
106
+ end
107
+
108
+ # Creates a task and polls it until it is ready, failed, or the deadline passes.
109
+ #
110
+ # createTask is resubmitted only after ERROR_NO_SLOT_AVAILABLE (nothing was created), at most
111
+ # max_submit_retries times; any other submit failure is raised at once. Polls that fail with a
112
+ # TransportError or ERROR_INTERNAL_ERROR are retried until the deadline; any other error ends the
113
+ # call and carries the task id. On the deadline SolveTimeoutError is raised; the task may still
114
+ # finish. on_task_created is called once with the task id as soon as the task is accepted.
115
+ def solve(task, poll_interval: nil, solve_timeout: nil, on_task_created: nil)
116
+ interval = poll_interval || @poll_interval
117
+ timeout = solve_timeout || @solve_timeout
118
+ require_positive(:poll_interval, interval)
119
+ require_positive(:solve_timeout, timeout)
120
+ deadline = @clock.now + timeout
121
+
122
+ retries = 0
123
+ begin
124
+ task_id = create_task(task)
125
+ rescue NoSlotAvailableError
126
+ raise if retries >= @max_submit_retries
127
+
128
+ retries += 1
129
+ jitter_ms = (@random.call * SUBMIT_RETRY_JITTER * 1000).floor
130
+ @clock.sleep(interval + (jitter_ms / 1000.0))
131
+ retry
132
+ end
133
+ on_task_created&.call(task_id)
134
+
135
+ loop do
136
+ last_error = nil
137
+ begin
138
+ result = get_task_result(task_id)
139
+ return SolveResult.new(task_id: task_id, solution: result.solution) if result.ready?
140
+ rescue TransportError => e
141
+ last_error = e
142
+ rescue ApiError => e
143
+ raise unless e.code == ErrorCodes::ERROR_INTERNAL_ERROR
144
+
145
+ last_error = e
146
+ end
147
+ @clock.sleep(interval)
148
+ raise SolveTimeoutError.new(task_id, timeout, last_error) if @clock.now >= deadline
149
+ end
150
+ end
151
+
152
+ # The public task-type catalog: accepted names, fields, proxy modes and result keys.
153
+ def list_task_types
154
+ data = catalog_call("listTaskTypes", "/v1/task-types")
155
+ task_types = data["task_types"]
156
+ error_codes = data["error_codes"]
157
+ unless task_types.is_a?(Array) && error_codes.is_a?(Array)
158
+ raise ProtocolError.new("listTaskTypes", "data without task_types and error_codes arrays")
159
+ end
160
+
161
+ read = EntryReader.new("listTaskTypes")
162
+ TaskTypeCatalog.new(
163
+ task_types: task_types.map do |raw|
164
+ entry = read.object(raw)
165
+ TaskTypeInfo.new(
166
+ page_slug: read.optional_text(entry, "page_slug"),
167
+ display_name: read.text(entry, "display_name"),
168
+ task_type: read.text(entry, "task_type"),
169
+ task_type_proxyless: read.optional_text(entry, "task_type_proxyless"),
170
+ aliases: read.texts(entry["aliases"], "aliases"),
171
+ proxy: read.text(entry, "proxy"),
172
+ required: read.groups(entry, "required"),
173
+ any_of: read.groups(entry, "any_of"),
174
+ optional: read.texts(entry["optional"], "optional"),
175
+ solution_keys: read.texts(entry["solution_keys"], "solution_keys")
176
+ )
177
+ end,
178
+ error_codes: read.texts(error_codes, "error_codes")
179
+ )
180
+ end
181
+
182
+ # Active services with their current USD price per task (decimal strings).
183
+ def list_services
184
+ services = catalog_call("listServices", "/v1/services")["services"]
185
+ raise ProtocolError.new("listServices", "data without a services array") unless services.is_a?(Array)
186
+
187
+ read = EntryReader.new("listServices")
188
+ services.map do |raw|
189
+ entry = read.object(raw)
190
+ ServiceInfo.new(
191
+ id: read.integer(entry, "id"),
192
+ slug: read.text(entry, "slug"),
193
+ display_name: read.text(entry, "display_name"),
194
+ price_per_request: read.text(entry, "price_per_request"),
195
+ status: read.text(entry, "status"),
196
+ avg_latency_ms: entry.key?("avg_latency_ms") && entry["avg_latency_ms"].nil? ? nil : read.integer(entry, "avg_latency_ms")
197
+ )
198
+ end
199
+ end
200
+
201
+ private
202
+
203
+ def require_positive(name, value)
204
+ raise ConfigurationError, "#{name} must be a positive number of seconds" unless value.is_a?(Numeric) && value.positive?
205
+ end
206
+
207
+ def normalize_base_url(raw)
208
+ uri = begin
209
+ URI.parse(raw.to_s)
210
+ rescue URI::InvalidURIError
211
+ nil
212
+ end
213
+ raise ConfigurationError, "base_url is not a valid absolute URL" if uri.nil? || uri.host.nil? || uri.host.empty?
214
+
215
+ host = uri.host.downcase.delete_prefix("[").delete_suffix("]")
216
+ loopback_http = uri.scheme == "http" && LOOPBACK_HOSTS.include?(host)
217
+ unless uri.scheme == "https" || loopback_http
218
+ raise ConfigurationError, "base_url must use https:// (plain http:// only for localhost)"
219
+ end
220
+ if uri.userinfo || uri.query || uri.fragment || raw.include?("?") || raw.include?("#")
221
+ raise ConfigurationError, "base_url must not carry credentials, a query or a fragment"
222
+ end
223
+
224
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
225
+ "#{uri.scheme}://#{uri.host.downcase}#{port}#{uri.path.sub(%r{/+\z}, "")}"
226
+ end
227
+
228
+ def request(method, path, body)
229
+ headers = { "Accept" => "application/json", "User-Agent" => @user_agent }
230
+ headers["Content-Type"] = "application/json" if body
231
+ Request.new(method: method, url: @base_url + path, headers: headers, body: body, timeout: @request_timeout)
232
+ end
233
+
234
+ def send_request(operation, request)
235
+ response = begin
236
+ @transport.call(request)
237
+ rescue Timeout::Error
238
+ raise TransportError.new(operation, "request timed out after #{(request.timeout * 1000).round} ms")
239
+ rescue *NO_RESPONSE_ERRORS
240
+ raise TransportError.new(operation, "connection failed")
241
+ end
242
+ return response if response.status == 200
243
+
244
+ detail = parse(response.body)
245
+ detail = detail.is_a?(Hash) ? detail["error"] : nil
246
+ server_code = detail.is_a?(Hash) && detail["code"].is_a?(String) ? detail["code"] : nil
247
+ retry_after = detail.is_a?(Hash) && detail["retry_after_seconds"].is_a?(Numeric) ? detail["retry_after_seconds"].to_i : nil
248
+ reason = server_code ? "HTTP #{response.status} (#{server_code})" : "HTTP #{response.status}"
249
+ raise TransportError.new(operation, reason, status: response.status, server_code: server_code,
250
+ retry_after_seconds: retry_after)
251
+ end
252
+
253
+ NOT_JSON = Object.new.freeze
254
+ private_constant :NOT_JSON
255
+
256
+ def parse(body)
257
+ JSON.parse(body.to_s)
258
+ rescue JSON::ParserError
259
+ NOT_JSON
260
+ end
261
+
262
+ # POSTs to a solver endpoint; returns the errorId 0 body or raises the classified error.
263
+ def solver_call(operation, path, payload, task_id = nil)
264
+ response = send_request(operation, request("POST", path, JSON.generate(payload)))
265
+ body = parse(response.body)
266
+ error_id = body.is_a?(Hash) ? body["errorId"] : nil
267
+ unless error_id.is_a?(Integer) || (error_id.is_a?(Float) && error_id.finite? && error_id == error_id.floor)
268
+ what = body.equal?(NOT_JSON) ? "not JSON" : "not the solver envelope"
269
+ raise TransportError.new(operation, "HTTP 200 body is #{what}", status: 200)
270
+ end
271
+ return body if error_id.zero?
272
+ raise ProtocolError.new(operation, "errorId #{error_id}") unless error_id == 1
273
+
274
+ code = body["errorCode"]
275
+ raise ProtocolError.new(operation, "errorId 1 without a string errorCode") unless code.is_a?(String)
276
+
277
+ description = body["errorDescription"].is_a?(String) ? body["errorDescription"] : ""
278
+ supported = body["supportedUserAgents"].is_a?(Array) ? body["supportedUserAgents"].grep(String) : []
279
+ raise ApiError.for(operation, code, description, task_id, supported)
280
+ end
281
+
282
+ # GETs a public catalog endpoint and returns its data object.
283
+ def catalog_call(operation, path)
284
+ envelope = parse(send_request(operation, request("GET", path, nil)).body)
285
+ return envelope["data"] if envelope.is_a?(Hash) && envelope["success"] == true && envelope["data"].is_a?(Hash)
286
+
287
+ raise TransportError.new(operation, "HTTP 200 body is not the API envelope", status: 200)
288
+ end
289
+
290
+ # Typed reads of catalog entries; a missing or mistyped field is a ProtocolError.
291
+ class EntryReader
292
+ def initialize(operation)
293
+ @operation = operation
294
+ end
295
+
296
+ def object(raw)
297
+ raise ProtocolError.new(@operation, "a catalog entry is not an object") unless raw.is_a?(Hash)
298
+
299
+ raw
300
+ end
301
+
302
+ def text(entry, key)
303
+ value = entry[key]
304
+ raise fail(key, "a string") unless value.is_a?(String)
305
+
306
+ value
307
+ end
308
+
309
+ def optional_text(entry, key)
310
+ value = entry[key]
311
+ raise fail(key, "a string or null") unless value.nil? || value.is_a?(String)
312
+
313
+ value
314
+ end
315
+
316
+ def integer(entry, key)
317
+ value = entry[key]
318
+ raise fail(key, "an integer") unless value.is_a?(Integer)
319
+
320
+ value
321
+ end
322
+
323
+ def texts(raw, key)
324
+ raise fail(key, "a list of strings") unless raw.is_a?(Array) && raw.all?(String)
325
+
326
+ raw
327
+ end
328
+
329
+ def groups(entry, key)
330
+ value = entry[key]
331
+ raise fail(key, "a list of string lists") unless value.is_a?(Array) && value.all? { |g| g.is_a?(Array) && g.all?(String) }
332
+
333
+ value
334
+ end
335
+
336
+ private
337
+
338
+ def fail(key, expected)
339
+ ProtocolError.new(@operation, "catalog field #{key} is not #{expected}")
340
+ end
341
+ end
342
+ private_constant :EntryReader
343
+ end
344
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SurSolver
4
+ # Base of every error the client raises. Messages never contain the API key.
5
+ class Error < StandardError
6
+ # The call that failed ("createTask", "getTaskResult", "getBalance", "listTaskTypes",
7
+ # "listServices"); nil for ConfigurationError and SolveTimeoutError.
8
+ attr_reader :operation
9
+
10
+ def initialize(message, operation: nil)
11
+ super(message)
12
+ @operation = operation
13
+ end
14
+ end
15
+
16
+ # The client was built with an unusable option. Nothing was sent.
17
+ class ConfigurationError < Error; end
18
+
19
+ # No usable answer: a connection failure, a timeout, a non-200 status, or a body that is not the
20
+ # API's envelope. It says nothing about the task.
21
+ class TransportError < Error
22
+ # The HTTP status when a response arrived, otherwise nil.
23
+ attr_reader :status
24
+ # error.code of the API's internal envelope, or nil.
25
+ attr_reader :server_code
26
+ # error.retry_after_seconds of the internal envelope, or nil.
27
+ attr_reader :retry_after_seconds
28
+
29
+ def initialize(operation, reason, status: nil, server_code: nil, retry_after_seconds: nil)
30
+ note = operation == "createTask" ? " The task may have been created and charged; check get_balance before submitting again." : ""
31
+ super("#{operation} failed: #{reason}.#{note}", operation: operation)
32
+ @status = status
33
+ @server_code = server_code
34
+ @retry_after_seconds = retry_after_seconds
35
+ end
36
+
37
+ # True for createTask: the request may have been accepted and charged before the answer was
38
+ # lost. Check get_balance before submitting again.
39
+ def may_have_created_task?
40
+ operation == "createTask"
41
+ end
42
+ end
43
+
44
+ # HTTP 200 JSON whose shape the API contract does not allow.
45
+ class ProtocolError < Error
46
+ def initialize(operation, detail)
47
+ super("#{operation} returned an unexpected response: #{detail}", operation: operation)
48
+ end
49
+ end
50
+
51
+ # solve reached its deadline. The task may still finish and stays charged; poll it with
52
+ # get_task_result(task_id) if you still need the result.
53
+ class SolveTimeoutError < Error
54
+ attr_reader :task_id, :timeout, :last_error
55
+
56
+ def initialize(task_id, timeout, last_error)
57
+ super("task #{task_id} was not ready within #{timeout} s; it may still finish and stays charged")
58
+ @task_id = task_id
59
+ @timeout = timeout
60
+ @last_error = last_error
61
+ end
62
+ end
63
+
64
+ # The API answered errorId 1. Rescue the subclasses for the codes you handle; ApiError itself
65
+ # means ERROR_INTERNAL_ERROR, ERROR_FAILED on create, or a code newer than this gem.
66
+ class ApiError < Error
67
+ # The errorCode exactly as sent, including codes newer than this gem.
68
+ attr_reader :code
69
+ # The errorDescription as sent.
70
+ attr_reader :description
71
+ # The task for get_task_result and for solve after acceptance, otherwise nil.
72
+ attr_reader :task_id
73
+ # The list sent with ERROR_WRONG_USERAGENT; empty otherwise.
74
+ attr_reader :supported_user_agents
75
+
76
+ def initialize(operation, code, description, task_id: nil, supported_user_agents: [])
77
+ super("#{code}: #{description}#{task_id ? " (task #{task_id})" : ""}", operation: operation)
78
+ @code = code
79
+ @description = description
80
+ @task_id = task_id
81
+ @supported_user_agents = supported_user_agents.freeze
82
+ end
83
+
84
+ # True only for ERROR_NO_SLOT_AVAILABLE.
85
+ def retryable?
86
+ code == ErrorCodes::ERROR_NO_SLOT_AVAILABLE
87
+ end
88
+ end
89
+
90
+ # ERROR_WRONG_CLIENT_KEY or ERROR_KEY_DOES_NOT_EXIST: fix the API key.
91
+ class AuthenticationError < ApiError; end
92
+
93
+ # ERROR_INVALID_REQUEST, ERROR_TYPE_NOT_SUPPORTED, or ERROR_WRONG_USERAGENT on create (see
94
+ # supported_user_agents). Nothing was created or charged.
95
+ class InvalidTaskError < ApiError; end
96
+
97
+ # ERROR_ZERO_BALANCE: top up. Nothing was charged.
98
+ class InsufficientBalanceError < ApiError; end
99
+
100
+ # ERROR_NO_SLOT_AVAILABLE: all your threads are busy. Nothing was created; submitting later is safe.
101
+ class NoSlotAvailableError < ApiError; end
102
+
103
+ # ERROR_TASK_NOT_FOUND.
104
+ class TaskNotFoundError < ApiError; end
105
+
106
+ # The task ended without a result (ERROR_FAILED, ERROR_TASK_TIMEOUT, ERROR_TASK_EXPIRED, or
107
+ # ERROR_WRONG_USERAGENT after acceptance). Its charge is returned once.
108
+ class TaskFailedError < ApiError; end
109
+
110
+ class ApiError
111
+ TASK_FAILURE_CODES = [
112
+ ErrorCodes::ERROR_FAILED, ErrorCodes::ERROR_TASK_TIMEOUT, ErrorCodes::ERROR_TASK_EXPIRED,
113
+ ErrorCodes::ERROR_WRONG_USERAGENT
114
+ ].freeze
115
+
116
+ CLASS_BY_CODE = {
117
+ ErrorCodes::ERROR_WRONG_CLIENT_KEY => AuthenticationError,
118
+ ErrorCodes::ERROR_KEY_DOES_NOT_EXIST => AuthenticationError,
119
+ ErrorCodes::ERROR_INVALID_REQUEST => InvalidTaskError,
120
+ ErrorCodes::ERROR_TYPE_NOT_SUPPORTED => InvalidTaskError,
121
+ ErrorCodes::ERROR_WRONG_USERAGENT => InvalidTaskError,
122
+ ErrorCodes::ERROR_ZERO_BALANCE => InsufficientBalanceError,
123
+ ErrorCodes::ERROR_NO_SLOT_AVAILABLE => NoSlotAvailableError,
124
+ ErrorCodes::ERROR_TASK_NOT_FOUND => TaskNotFoundError
125
+ }.freeze
126
+
127
+ # The error for a code; the class depends on the operation and the code.
128
+ def self.for(operation, code, description, task_id, supported)
129
+ klass = if operation == "getTaskResult" && TASK_FAILURE_CODES.include?(code)
130
+ TaskFailedError
131
+ else
132
+ CLASS_BY_CODE.fetch(code, ApiError)
133
+ end
134
+ klass.new(operation, code, description, task_id: task_id, supported_user_agents: supported)
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated from the SurSolver catalog (GET /v1/task-types) by the spec repository
4
+ # tools/templates/rubygems.ts. Catalog fetched at 2026-11-03T21:16:22.334Z. DO NOT EDIT.
5
+
6
+ module SurSolver
7
+ # Every accepted task.type spelling: canonical names, ProxyLess twins and aliases.
8
+ module TaskTypes
9
+ AKAMAI_BMP_TASK = "AkamaiBmpTask"
10
+ AKAMAI_COOKIE = "AkamaiCookie"
11
+ AKAMAI_COOKIE_PROXY_LESS = "AkamaiCookieProxyLess"
12
+ AKAMAI_SBSD_COOKIE = "AkamaiSbsdCookie"
13
+ ALTCHA_TOKEN = "AltchaToken"
14
+ ALTCHA_TOKEN_PROXY_LESS = "AltchaTokenProxyLess"
15
+ AWS_CAPTCHA_COOKIE = "AwsCaptchaCookie"
16
+ AWS_CHALLENGE_COOKIE = "AwsChallengeCookie"
17
+ CAPTCHA_FOX_TOKEN = "CaptchaFoxToken"
18
+ CASTLE_ANDROID_TOKEN = "CastleAndroidToken"
19
+ CASTLE_TOKEN = "CastleToken"
20
+ CASTLE_WEB_TOKEN = "CastleWebToken"
21
+ CLOUDFLARE_CHALLENGE_COOKIE = "CloudflareChallengeCookie"
22
+ DATA_DOME_SLIDER_COOKIE = "DataDomeSliderCookie"
23
+ FRIENDLY_CAPTCHA_TOKEN = "FriendlyCaptchaToken"
24
+ FRIENDLY_CAPTCHA_TOKEN_PROXY_LESS = "FriendlyCaptchaTokenProxyLess"
25
+ GEE_TEST_TOKEN = "GeeTestToken"
26
+ GEE_TEST_TOKEN_PROXY_LESS = "GeeTestTokenProxyLess"
27
+ GEE_TEST_V3_TOKEN = "GeeTestV3Token"
28
+ GEE_TEST_V4_TOKEN = "GeeTestV4Token"
29
+ IMAGE_TO_TEXT = "ImageToText"
30
+ IMAGE_TO_TEXT_TASK = "ImageToTextTask"
31
+ IMPERVA_COOKIE = "ImpervaCookie"
32
+ IMPERVA_UTMVC_COOKIE = "ImpervaUtmvcCookie"
33
+ KASADA_TOKEN = "KasadaToken"
34
+ KASADA_TOKEN_PROXY_LESS = "KasadaTokenProxyLess"
35
+ LEMIN_TOKEN = "LeminToken"
36
+ LEMIN_TOKEN_PROXY_LESS = "LeminTokenProxyLess"
37
+ MT_CAPTCHA_TOKEN = "MtCaptchaToken"
38
+ MT_CAPTCHA_TOKEN_PROXY_LESS = "MtCaptchaTokenProxyLess"
39
+ PERIMETER_X_COOKIE = "PerimeterXCookie"
40
+ PROSOPO_TOKEN = "ProsopoToken"
41
+ PROSOPO_TOKEN_PROXY_LESS = "ProsopoTokenProxyLess"
42
+ RE_CAPTCHA_V2_ENTERPRISE_TOKEN = "ReCaptchaV2EnterpriseToken"
43
+ RE_CAPTCHA_V2_ENTERPRISE_TOKEN_PROXY_LESS = "ReCaptchaV2EnterpriseTokenProxyLess"
44
+ RE_CAPTCHA_V2_TOKEN = "ReCaptchaV2Token"
45
+ RE_CAPTCHA_V2_TOKEN_PROXY_LESS = "ReCaptchaV2TokenProxyLess"
46
+ RE_CAPTCHA_V3_ENTERPRISE_TOKEN = "ReCaptchaV3EnterpriseToken"
47
+ RE_CAPTCHA_V3_ENTERPRISE_TOKEN_PROXY_LESS = "ReCaptchaV3EnterpriseTokenProxyLess"
48
+ RE_CAPTCHA_V3_TOKEN = "ReCaptchaV3Token"
49
+ RE_CAPTCHA_V3_TOKEN_PROXY_LESS = "ReCaptchaV3TokenProxyLess"
50
+ SHAPE_COOKIE = "ShapeCookie"
51
+ SHAPE_COOKIE_PROXY_LESS = "ShapeCookieProxyLess"
52
+ TURNSTILE_TOKEN = "TurnstileToken"
53
+ TURNSTILE_TOKEN_PROXY_LESS = "TurnstileTokenProxyLess"
54
+ VERCEL_COOKIE = "VercelCookie"
55
+ end
56
+
57
+ # The complete errorCode vocabulary of the solver endpoints.
58
+ module ErrorCodes
59
+ ERROR_KEY_DOES_NOT_EXIST = "ERROR_KEY_DOES_NOT_EXIST"
60
+ ERROR_WRONG_CLIENT_KEY = "ERROR_WRONG_CLIENT_KEY"
61
+ ERROR_ZERO_BALANCE = "ERROR_ZERO_BALANCE"
62
+ ERROR_NO_SLOT_AVAILABLE = "ERROR_NO_SLOT_AVAILABLE"
63
+ ERROR_TYPE_NOT_SUPPORTED = "ERROR_TYPE_NOT_SUPPORTED"
64
+ ERROR_TASK_NOT_FOUND = "ERROR_TASK_NOT_FOUND"
65
+ ERROR_TASK_EXPIRED = "ERROR_TASK_EXPIRED"
66
+ ERROR_TASK_TIMEOUT = "ERROR_TASK_TIMEOUT"
67
+ ERROR_FAILED = "ERROR_FAILED"
68
+ ERROR_INVALID_REQUEST = "ERROR_INVALID_REQUEST"
69
+ ERROR_WRONG_USERAGENT = "ERROR_WRONG_USERAGENT"
70
+ ERROR_INTERNAL_ERROR = "ERROR_INTERNAL_ERROR"
71
+
72
+ # All codes in catalog order.
73
+ ALL = [ERROR_KEY_DOES_NOT_EXIST, ERROR_WRONG_CLIENT_KEY, ERROR_ZERO_BALANCE, ERROR_NO_SLOT_AVAILABLE, ERROR_TYPE_NOT_SUPPORTED, ERROR_TASK_NOT_FOUND, ERROR_TASK_EXPIRED, ERROR_TASK_TIMEOUT, ERROR_FAILED, ERROR_INVALID_REQUEST, ERROR_WRONG_USERAGENT, ERROR_INTERNAL_ERROR].freeze
74
+ end
75
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module SurSolver
8
+ # One HTTP request handed to a transport. timeout is in seconds.
9
+ Request = Struct.new(:method, :url, :headers, :body, :timeout, keyword_init: true)
10
+
11
+ # What a transport returns for any HTTP status.
12
+ Response = Struct.new(:status, :body, keyword_init: true)
13
+
14
+ # The default transport: Net::HTTP, which never follows redirects, so a request body carrying the
15
+ # API key is not replayed to another location.
16
+ #
17
+ # A transport is any object with #call(request) returning a Response for every HTTP status. Raise
18
+ # Timeout::Error when request.timeout passes, and an IOError, SystemCallError or SocketError when
19
+ # no response arrived.
20
+ class NetHttpTransport
21
+ # proxy: optional URI of an HTTP proxy for the connection to the API.
22
+ def initialize(proxy: nil)
23
+ @proxy = proxy && URI(proxy)
24
+ end
25
+
26
+ def call(request)
27
+ uri = URI(request.url)
28
+ http = Net::HTTP.new(uri.host, uri.port, @proxy&.host, @proxy&.port, @proxy&.user, @proxy&.password)
29
+ http.use_ssl = uri.scheme == "https"
30
+ # Net::HTTP bounds each phase (connect, write, every read) by the request timeout.
31
+ http.open_timeout = http.read_timeout = http.write_timeout = http.ssl_timeout = request.timeout
32
+ http.max_retries = 0
33
+ message = (request.method == "POST" ? Net::HTTP::Post : Net::HTTP::Get).new(uri.request_uri, request.headers)
34
+ message.body = request.body if request.body
35
+ response = http.start { |connection| connection.request(message) }
36
+ Response.new(status: response.code.to_i, body: response.body || "")
37
+ end
38
+ end
39
+
40
+ # The real clock: monotonic seconds and Kernel#sleep.
41
+ class SystemClock
42
+ def now
43
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
+ end
45
+
46
+ def sleep(seconds)
47
+ Kernel.sleep(seconds)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SurSolver
4
+ # One getTaskResult answer: status is "processing" or "ready" (with solution). Solution keys
5
+ # differ per type ("token", "gRecaptchaResponse", "cf_clearance", "cookies", ...) and are passed
6
+ # through untouched. Tokens are single-use.
7
+ TaskResult = Struct.new(:status, :solution, keyword_init: true) do
8
+ def ready?
9
+ status == "ready"
10
+ end
11
+
12
+ def processing?
13
+ status == "processing"
14
+ end
15
+ end
16
+
17
+ # What solve returns.
18
+ SolveResult = Struct.new(:task_id, :solution, keyword_init: true)
19
+
20
+ # One entry of GET /v1/task-types. proxy is "none", "optional" or "required"; required lists
21
+ # groups of accepted spellings (one name per group); any_of lists alternatives.
22
+ TaskTypeInfo = Struct.new(
23
+ :page_slug, :display_name, :task_type, :task_type_proxyless, :aliases, :proxy, :required,
24
+ :any_of, :optional, :solution_keys, keyword_init: true
25
+ )
26
+
27
+ # The public task-type catalog.
28
+ TaskTypeCatalog = Struct.new(:task_types, :error_codes, keyword_init: true)
29
+
30
+ # One entry of GET /v1/services. price_per_request is the USD price per task as a decimal string.
31
+ ServiceInfo = Struct.new(:id, :slug, :display_name, :price_per_request, :status, :avg_latency_ms, keyword_init: true)
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SurSolver
4
+ VERSION = "0.1.0"
5
+ end
data/lib/sursolver.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sursolver/version"
4
+ require_relative "sursolver/task_types"
5
+ require_relative "sursolver/errors"
6
+ require_relative "sursolver/types"
7
+ require_relative "sursolver/transport"
8
+ require_relative "sursolver/client"
9
+
10
+ # Official Ruby client for the SurSolver captcha-solving API (https://sursolver.com).
11
+ #
12
+ # client = SurSolver::Client.new("YOUR_API_KEY")
13
+ # result = client.solve(
14
+ # "type" => SurSolver::TaskTypes::TURNSTILE_TOKEN_PROXY_LESS,
15
+ # "websiteURL" => "https://example.com/login",
16
+ # "websiteKey" => "0x4AAAAAAA...",
17
+ # "userAgent" => USER_AGENT
18
+ # )
19
+ # result.solution["token"]
20
+ #
21
+ # The API key travels only in request bodies and never appears in errors or #inspect.
22
+ module SurSolver
23
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sursolver
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - SurSolver
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.7'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.7'
26
+ description: Solve reCAPTCHA v3, Cloudflare Turnstile, Cloudflare Challenge (cf_clearance),
27
+ GeeTest, DataDome, AWS WAF, Akamai, Kasada and image captchas from Ruby with one
28
+ call. Standard library only.
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - CHANGELOG.md
34
+ - LICENSE
35
+ - README.md
36
+ - lib/sursolver.rb
37
+ - lib/sursolver/client.rb
38
+ - lib/sursolver/errors.rb
39
+ - lib/sursolver/task_types.rb
40
+ - lib/sursolver/transport.rb
41
+ - lib/sursolver/types.rb
42
+ - lib/sursolver/version.rb
43
+ homepage: https://sursolver.com
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ homepage_uri: https://sursolver.com
48
+ documentation_uri: https://sursolver.com/docs
49
+ source_code_uri: https://github.com/sursolver/sursolver-ruby
50
+ changelog_uri: https://github.com/sursolver/sursolver-ruby/blob/main/CHANGELOG.md
51
+ rubygems_mfa_required: 'true'
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '3.4'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.6.9
67
+ specification_version: 4
68
+ summary: Official Ruby client for the SurSolver captcha solver API.
69
+ test_files: []