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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +186 -0
- data/lib/sursolver/client.rb +344 -0
- data/lib/sursolver/errors.rb +137 -0
- data/lib/sursolver/task_types.rb +75 -0
- data/lib/sursolver/transport.rb +50 -0
- data/lib/sursolver/types.rb +32 -0
- data/lib/sursolver/version.rb +5 -0
- data/lib/sursolver.rb +23 -0
- metadata +69 -0
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
|
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: []
|