mirror of
https://github.com/aykhans/sarin.git
synced 2026-04-15 20:39:35 +00:00
Compare commits
10 Commits
006029aad1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e62fd33f9c | |||
| e9b9b8890c | |||
| 8577c771e4 | |||
| c839b71c9e | |||
| cea692cf1b | |||
| 88f5171132 | |||
| 16b0081d3e | |||
| 1bd58a02b7 | |||
| 0e0ef72778 | |||
|
|
8d10198f02 |
@@ -20,16 +20,17 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
|
||||
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity and features like templating add zero overhead when unused.
|
||||
|
||||
| ✅ Supported | ❌ Not Supported |
|
||||
| ---------------------------------------------------------- | ------------------------------- |
|
||||
| High-performance with low memory footprint | Detailed response body analysis |
|
||||
| Long-running duration/count based tests | Extensive response statistics |
|
||||
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
|
||||
| Dynamic requests via 340+ template functions | Web UI or complex TUI |
|
||||
| Request scripting with Lua and JavaScript | Distributed load testing |
|
||||
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
||||
| Flexible config (CLI, ENV, YAML) | Plugins / extensions ecosystem |
|
||||
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem |
|
||||
| Flexible config (CLI, ENV, YAML) | |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -105,7 +106,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
|
||||
|
||||
## Templating
|
||||
|
||||
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request.
|
||||
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 340+ built-in functions to generate dynamic data for each request.
|
||||
|
||||
**Example:**
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Configuration
|
||||
|
||||
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent—YAML files have the most configuration options, followed by CLI flags, and then environment variables.
|
||||
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent: YAML files have the most configuration options, followed by CLI flags, and then environment variables.
|
||||
|
||||
When the same option is specified in multiple sources, the following priority order applies:
|
||||
|
||||
@@ -107,9 +107,9 @@ If all four files define `url`, the value from `config3.yaml` wins.
|
||||
|
||||
**Merge behavior by field:**
|
||||
|
||||
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) — higher priority overrides lower priority
|
||||
- **Method and Body** — higher priority overrides lower priority (no merging)
|
||||
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js** — accumulated across all config files
|
||||
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.): higher priority overrides lower priority
|
||||
- **Method and Body**: higher priority overrides lower priority (no merging)
|
||||
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js**: accumulated across all config files
|
||||
|
||||
## URL
|
||||
|
||||
@@ -408,7 +408,7 @@ SARIN_VALUES="key1=value1"
|
||||
|
||||
Lua script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
|
||||
|
||||
If multiple Lua scripts are provided, they are chained in order—the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
If multiple Lua scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
|
||||
**Script sources:**
|
||||
|
||||
@@ -473,7 +473,7 @@ SARIN_LUA='function transform(req) req.headers["X-Custom"] = "my-value" return r
|
||||
|
||||
JavaScript script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
|
||||
|
||||
If multiple JavaScript scripts are provided, they are chained in order—the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
If multiple JavaScript scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||
|
||||
**Script sources:**
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ This guide provides practical examples for common Sarin use cases.
|
||||
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
|
||||
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
||||
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
||||
- [Solving Captchas](#solving-captchas)
|
||||
- [Request Bodies](#request-bodies)
|
||||
- [File Uploads](#file-uploads)
|
||||
- [Using Proxies](#using-proxies)
|
||||
@@ -371,7 +372,29 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
|
||||
|
||||
</details>
|
||||
|
||||
> For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**.
|
||||
> For the complete list of 340+ template functions, see the **[Templating Guide](templating.md)**.
|
||||
|
||||
## Solving Captchas
|
||||
|
||||
Sarin can solve captchas through third-party services and embed the resulting token into the request. Three services are supported via dedicated template functions: **2Captcha**, **Anti-Captcha**, and **CapSolver**.
|
||||
|
||||
**Solve a reCAPTCHA v2 and submit the token in the request body:**
|
||||
|
||||
```sh
|
||||
sarin -U https://example.com/login -M POST -r 1 \
|
||||
-B '{"g-recaptcha-response": "{{ twocaptcha_RecaptchaV2 "YOUR_API_KEY" "SITE_KEY" "https://example.com/login" }}"}'
|
||||
```
|
||||
|
||||
**Reuse a single solved token across multiple requests via `values`:**
|
||||
|
||||
```sh
|
||||
sarin -U https://example.com/api -M POST -r 5 \
|
||||
-V 'TOKEN={{ anticaptcha_Turnstile "YOUR_API_KEY" "SITE_KEY" "https://example.com/api" }}' \
|
||||
-H "X-Turnstile-Token: {{ .Values.TOKEN }}" \
|
||||
-B '{"token": "{{ .Values.TOKEN }}"}'
|
||||
```
|
||||
|
||||
> See the **[Templating Guide](templating.md#captcha-functions)** for the full list of captcha functions and per-service support.
|
||||
|
||||
## Request Bodies
|
||||
|
||||
|
||||
@@ -4,16 +4,23 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
|
||||
|
||||
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
|
||||
|
||||
> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Using Values](#using-values)
|
||||
- [General Functions](#general-functions)
|
||||
- [String Functions](#string-functions)
|
||||
- [Collection Functions](#collection-functions)
|
||||
- [JSON Functions](#json-functions)
|
||||
- [Time Functions](#time-functions)
|
||||
- [Crypto Functions](#crypto-functions)
|
||||
- [Body Functions](#body-functions)
|
||||
- [File Functions](#file-functions)
|
||||
- [Captcha Functions](#captcha-functions)
|
||||
- [2Captcha](#2captcha)
|
||||
- [Anti-Captcha](#anti-captcha)
|
||||
- [CapSolver](#capsolver)
|
||||
- [Fake Data Functions](#fake-data-functions)
|
||||
- [File](#file)
|
||||
- [ID](#id)
|
||||
@@ -111,6 +118,33 @@ sarin -U http://example.com/users \
|
||||
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
|
||||
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` |
|
||||
|
||||
### JSON Functions
|
||||
|
||||
Build JSON payloads programmatically without manual quoting or escaping. `json_Object` is the ergonomic shortcut for flat objects; `json_Encode` marshals any value (slice, map, etc.) to a JSON string.
|
||||
|
||||
| Function | Description | Example |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
|
||||
| `json_Object(pairs ...any)` | Build an object from interleaved key-value pairs and return it as a JSON string. Keys must be strings. | `{{ json_Object "name" "Alice" "age" 30 }}` |
|
||||
| `json_Encode(v any)` | Marshal any value (slice, map, etc.) to a JSON string. | `{{ json_Encode (slice_Str "a" "b") }}` → `["a","b"]` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```yaml
|
||||
# Flat object with fake data
|
||||
body: '{{ json_Object "name" (fakeit_FirstName) "email" (fakeit_Email) }}'
|
||||
|
||||
# Embed a solved captcha token
|
||||
body: '{{ json_Object "g-recaptcha-response" (twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com") }}'
|
||||
|
||||
# Encode a slice as a JSON array
|
||||
body: '{{ json_Encode (slice_Str "a" "b" "c") }}'
|
||||
|
||||
# Encode a string dictionary (map[string]string)
|
||||
body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}'
|
||||
```
|
||||
|
||||
> **Note:** Object keys are serialized in alphabetical order (Go's `encoding/json` default), not insertion order. For API payloads this is almost always fine because JSON key order is semantically irrelevant.
|
||||
|
||||
### Time Functions
|
||||
|
||||
| Function | Description | Example |
|
||||
@@ -196,6 +230,95 @@ values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
|
||||
body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
||||
```
|
||||
|
||||
## Captcha Functions
|
||||
|
||||
Captcha functions solve a captcha challenge through a third-party solving service and return the resulting token, which can then be embedded directly into a request. They are intended for load testing endpoints protected by reCAPTCHA, hCaptcha, or Cloudflare Turnstile.
|
||||
|
||||
The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required — bring your own key and use any of the supported services per template.
|
||||
|
||||
> **Important: performance and cost:**
|
||||
>
|
||||
> - **Each call is slow.** Solving typically takes ~5–60 seconds because the function blocks the template render until the third-party service returns a token. Internally the solver polls every 1s and gives up after 120s.
|
||||
> - **Each call costs money.** Every successful solve is billed by the captcha service (typically $0.001–$0.003 per solve). For high-volume tests, your captcha bill grows linearly with request count.
|
||||
|
||||
**Common parameters across all captcha functions:**
|
||||
|
||||
- `apiKey` - Your API key for the chosen captcha solving service
|
||||
- `siteKey` - The captcha sitekey extracted from the target page (e.g. the `data-sitekey` attribute on a reCAPTCHA, hCaptcha, or Turnstile element)
|
||||
- `pageURL` - The URL of the page where the captcha is hosted
|
||||
|
||||
### 2Captcha
|
||||
|
||||
Functions for the [2Captcha](https://2captcha.com) service. Note: 2Captcha **does not currently support hCaptcha** through their API.
|
||||
|
||||
| Function | Description |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| `twocaptcha_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
|
||||
| `twocaptcha_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. Pass `""` for `pageAction` to omit |
|
||||
| `twocaptcha_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
|
||||
|
||||
### Anti-Captcha
|
||||
|
||||
Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is currently the only service that supports all four captcha types end-to-end.
|
||||
|
||||
| Function | Description |
|
||||
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `anticaptcha_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
|
||||
| `anticaptcha_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. `minScore` is hardcoded to `0.3` (Anti-Captcha rejects the request without it) |
|
||||
| `anticaptcha_HCaptcha(apiKey, siteKey, pageURL string)` | Solve an hCaptcha challenge |
|
||||
| `anticaptcha_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
|
||||
|
||||
### CapSolver
|
||||
|
||||
Functions for the [CapSolver](https://capsolver.com) service. Note: CapSolver no longer supports hCaptcha.
|
||||
|
||||
| Function | Description |
|
||||
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `capsolver_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
|
||||
| `capsolver_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. Pass `""` for `pageAction` to omit |
|
||||
| `capsolver_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```yaml
|
||||
# reCAPTCHA v2 in a JSON body via 2Captcha
|
||||
method: POST
|
||||
url: https://example.com/login
|
||||
body: |
|
||||
{
|
||||
"username": "test",
|
||||
"g-recaptcha-response": "{{ twocaptcha_RecaptchaV2 "YOUR_API_KEY" "6LfD3PIb..." "https://example.com/login" }}"
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Turnstile via Anti-Captcha with cData
|
||||
method: POST
|
||||
url: https://example.com/submit
|
||||
body: |
|
||||
{
|
||||
"cf-turnstile-response": "{{ anticaptcha_Turnstile "YOUR_API_KEY" "0x4AAAAAAA..." "https://example.com/submit" "session-cdata" }}"
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# hCaptcha via Anti-Captcha (the only service that still supports it)
|
||||
method: POST
|
||||
url: https://example.com/protected
|
||||
body: |
|
||||
{
|
||||
"h-captcha-response": "{{ anticaptcha_HCaptcha "YOUR_API_KEY" "338af34c-..." "https://example.com/protected" }}"
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Share a single solved token across body and headers via values
|
||||
values: 'TOKEN={{ capsolver_Turnstile "YOUR_API_KEY" "0x4AAAAAAA..." "https://example.com" }}'
|
||||
headers:
|
||||
X-Turnstile-Token: "{{ .Values.TOKEN }}"
|
||||
body: '{"token": "{{ .Values.TOKEN }}"}'
|
||||
```
|
||||
|
||||
## Fake Data Functions
|
||||
|
||||
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.
|
||||
|
||||
8
go.mod
8
go.mod
@@ -15,7 +15,7 @@ require (
|
||||
github.com/yuin/gopher-lua v1.1.2
|
||||
go.aykhans.me/utils v1.0.7
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/net v0.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -52,7 +52,7 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
)
|
||||
|
||||
16
go.sum
16
go.sum
@@ -109,16 +109,16 @@ go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,16 +13,28 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
captchaPollInterval = 5 * time.Second
|
||||
captchaTimeout = 120 * time.Second
|
||||
captchaPollInterval = 1 * time.Second
|
||||
captchaPollTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
var captchaHTTPClient = &http.Client{Timeout: captchaTimeout}
|
||||
var captchaHTTPClient = &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// solveCaptcha creates a task and polls for the result.
|
||||
// solveCaptcha creates a task on the given captcha service and polls until it is solved,
|
||||
// returning the extracted token from the solution object.
|
||||
//
|
||||
// baseURL is the service API base (e.g. "https://api.2captcha.com").
|
||||
// taskIDIsString controls whether taskId is sent back as a string or number.
|
||||
// task is the task payload the service expects (type + service-specific fields).
|
||||
// solutionKey is the field name in the solution object that holds the token.
|
||||
// taskIDIsString controls whether taskId is sent back as a string (CapSolver UUIDs)
|
||||
// or a JSON number (2Captcha, Anti-Captcha).
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey string, taskIDIsString bool) (string, error) {
|
||||
if apiKey == "" {
|
||||
return "", types.ErrCaptchaKeyEmpty
|
||||
@@ -34,6 +47,14 @@ func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey strin
|
||||
return captchaPollResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
|
||||
}
|
||||
|
||||
// captchaCreateTask submits a task to the captcha service and returns the assigned taskId.
|
||||
// The taskId is normalized to a string: numeric IDs are preserved via json.RawMessage,
|
||||
// and quoted string IDs (CapSolver UUIDs) have their surrounding quotes stripped.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, error) {
|
||||
body := map[string]any{
|
||||
"clientKey": apiKey,
|
||||
@@ -42,7 +63,7 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err
|
||||
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", types.NewCaptchaRequestError("createTask", err)
|
||||
return "", types.NewCaptchaDecodeError("createTask", err)
|
||||
}
|
||||
|
||||
resp, err := captchaHTTPClient.Post(
|
||||
@@ -62,7 +83,7 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err
|
||||
TaskID json.RawMessage `json:"taskId"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", types.NewCaptchaRequestError("createTask", err)
|
||||
return "", types.NewCaptchaDecodeError("createTask", err)
|
||||
}
|
||||
|
||||
if result.ErrorID != 0 {
|
||||
@@ -71,11 +92,23 @@ func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, err
|
||||
|
||||
// taskId may be a JSON number (2captcha, anti-captcha) or a quoted string (capsolver UUIDs).
|
||||
// Strip surrounding quotes if present so we always work with the underlying value.
|
||||
return strings.Trim(string(result.TaskID), `"`), nil
|
||||
taskID := strings.Trim(string(result.TaskID), `"`)
|
||||
if taskID == "" {
|
||||
return "", types.NewCaptchaAPIError("createTask", "EMPTY_TASK_ID", "service returned a successful response with no taskId")
|
||||
}
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// captchaPollResult polls the getTaskResult endpoint at captchaPollInterval until the task
|
||||
// is solved, an error is returned by the service, or the overall captchaPollTimeout is hit.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), captchaTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), captchaPollTimeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(captchaPollInterval)
|
||||
@@ -84,20 +117,35 @@ func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsStri
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", types.NewCaptchaTimeoutError(taskID)
|
||||
return "", types.NewCaptchaPollTimeoutError(taskID)
|
||||
case <-ticker.C:
|
||||
token, done, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
|
||||
token, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
|
||||
if errors.Is(err, types.ErrCaptchaProcessing) {
|
||||
continue
|
||||
}
|
||||
// Retry on transient HTTP errors (timeouts, connection resets, etc.)
|
||||
// instead of failing the entire solve. The poll loop timeout will
|
||||
// eventually catch permanently unreachable services.
|
||||
if _, ok := errors.AsType[types.CaptchaRequestError](err); ok {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if done {
|
||||
return token, nil
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, bool, error) {
|
||||
// captchaGetTaskResult fetches a single task result from the captcha service.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaProcessing
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) {
|
||||
var bodyMap map[string]any
|
||||
if taskIDIsString {
|
||||
bodyMap = map[string]any{"clientKey": apiKey, "taskId": taskID}
|
||||
@@ -107,7 +155,7 @@ func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsS
|
||||
|
||||
data, err := json.Marshal(bodyMap)
|
||||
if err != nil {
|
||||
return "", false, types.NewCaptchaRequestError("getTaskResult", err)
|
||||
return "", types.NewCaptchaDecodeError("getTaskResult", err)
|
||||
}
|
||||
|
||||
resp, err := captchaHTTPClient.Post(
|
||||
@@ -116,7 +164,7 @@ func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsS
|
||||
bytes.NewReader(data),
|
||||
)
|
||||
if err != nil {
|
||||
return "", false, types.NewCaptchaRequestError("getTaskResult", err)
|
||||
return "", types.NewCaptchaRequestError("getTaskResult", err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
@@ -128,33 +176,42 @@ func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsS
|
||||
Solution map[string]any `json:"solution"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", false, types.NewCaptchaRequestError("getTaskResult", err)
|
||||
return "", types.NewCaptchaDecodeError("getTaskResult", err)
|
||||
}
|
||||
|
||||
if result.ErrorID != 0 {
|
||||
return "", false, types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription)
|
||||
return "", types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription)
|
||||
}
|
||||
|
||||
if result.Status == "processing" || result.Status == "idle" {
|
||||
return "", false, nil
|
||||
return "", types.ErrCaptchaProcessing
|
||||
}
|
||||
|
||||
token, ok := result.Solution[solutionKey]
|
||||
if !ok {
|
||||
return "", false, types.NewCaptchaSolutionKeyError(solutionKey)
|
||||
return "", types.NewCaptchaSolutionKeyError(solutionKey)
|
||||
}
|
||||
tokenStr, ok := token.(string)
|
||||
if !ok {
|
||||
return "", false, types.NewCaptchaSolutionKeyError(solutionKey)
|
||||
return "", types.NewCaptchaSolutionKeyError(solutionKey)
|
||||
}
|
||||
|
||||
return tokenStr, true, nil
|
||||
return tokenStr, nil
|
||||
}
|
||||
|
||||
// ======================================== 2Captcha ========================================
|
||||
|
||||
const twoCaptchaBaseURL = "https://api.2captcha.com"
|
||||
|
||||
// twoCaptchaSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via 2Captcha.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(twoCaptchaBaseURL, apiKey, map[string]any{
|
||||
"type": "RecaptchaV2TaskProxyless",
|
||||
@@ -163,6 +220,16 @@ func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string,
|
||||
}, "gRecaptchaResponse", false)
|
||||
}
|
||||
|
||||
// twoCaptchaSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via 2Captcha.
|
||||
// pageAction may be empty.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "RecaptchaV3TaskProxyless",
|
||||
@@ -175,6 +242,16 @@ func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction strin
|
||||
return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false)
|
||||
}
|
||||
|
||||
// twoCaptchaSolveTurnstile solves a Cloudflare Turnstile challenge via 2Captcha.
|
||||
// cData may be empty.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "TurnstileTaskProxyless",
|
||||
@@ -191,6 +268,15 @@ func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (str
|
||||
|
||||
const antiCaptchaBaseURL = "https://api.anti-captcha.com"
|
||||
|
||||
// antiCaptchaSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via Anti-Captcha.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
|
||||
"type": "RecaptchaV2TaskProxyless",
|
||||
@@ -199,8 +285,18 @@ func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string,
|
||||
}, "gRecaptchaResponse", false)
|
||||
}
|
||||
|
||||
// antiCaptchaSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via Anti-Captcha.
|
||||
// pageAction may be empty. minScore is hardcoded to 0.3 (the loosest threshold) because
|
||||
// Anti-Captcha rejects the request without it.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
||||
// Anti-Captcha requires minScore for reCAPTCHA v3. 0.3 is the loosest threshold.
|
||||
task := map[string]any{
|
||||
"type": "RecaptchaV3TaskProxyless",
|
||||
"websiteURL": websiteURL,
|
||||
@@ -213,8 +309,17 @@ func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction stri
|
||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false)
|
||||
}
|
||||
|
||||
// antiCaptchaSolveHCaptcha solves an hCaptcha challenge via Anti-Captcha.
|
||||
// Anti-Captcha returns hCaptcha tokens under "gRecaptchaResponse" (not "token").
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
// Anti-Captcha returns hCaptcha tokens under "gRecaptchaResponse" (not "token").
|
||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
|
||||
"type": "HCaptchaTaskProxyless",
|
||||
"websiteURL": websiteURL,
|
||||
@@ -222,6 +327,16 @@ func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, er
|
||||
}, "gRecaptchaResponse", false)
|
||||
}
|
||||
|
||||
// antiCaptchaSolveTurnstile solves a Cloudflare Turnstile challenge via Anti-Captcha.
|
||||
// cData may be empty.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "TurnstileTaskProxyless",
|
||||
@@ -238,6 +353,15 @@ func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (st
|
||||
|
||||
const capSolverBaseURL = "https://api.capsolver.com"
|
||||
|
||||
// capSolverSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via CapSolver.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
||||
return solveCaptcha(capSolverBaseURL, apiKey, map[string]any{
|
||||
"type": "ReCaptchaV2TaskProxyLess",
|
||||
@@ -246,6 +370,16 @@ func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, e
|
||||
}, "gRecaptchaResponse", true)
|
||||
}
|
||||
|
||||
// capSolverSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via CapSolver.
|
||||
// pageAction may be empty.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "ReCaptchaV3TaskProxyLess",
|
||||
@@ -258,6 +392,16 @@ func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string
|
||||
return solveCaptcha(capSolverBaseURL, apiKey, task, "gRecaptchaResponse", true)
|
||||
}
|
||||
|
||||
// capSolverSolveTurnstile solves a Cloudflare Turnstile challenge via CapSolver.
|
||||
// cData may be empty. CapSolver nests cData under a "metadata" object.
|
||||
//
|
||||
// It can return the following errors:
|
||||
// - types.ErrCaptchaKeyEmpty
|
||||
// - types.CaptchaRequestError
|
||||
// - types.CaptchaDecodeError
|
||||
// - types.CaptchaAPIError
|
||||
// - types.CaptchaPollTimeoutError
|
||||
// - types.CaptchaSolutionKeyError
|
||||
func capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
||||
task := map[string]any{
|
||||
"type": "AntiTurnstileTaskProxyLess",
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
@@ -85,6 +86,38 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
||||
"slice_Uint": func(values ...uint) []uint { return values },
|
||||
"slice_Join": strings.Join,
|
||||
|
||||
// JSON
|
||||
// json_Encode marshals any value to a JSON string.
|
||||
// Usage: {{ json_Encode (dict_Str "key" "value") }}
|
||||
"json_Encode": func(v any) (string, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", types.NewJSONEncodeError(err)
|
||||
}
|
||||
return string(data), nil
|
||||
},
|
||||
// json_Object builds a JSON object from interleaved key-value pairs and returns it
|
||||
// as a JSON string. Keys must be strings; values may be any JSON-encodable type.
|
||||
// Usage: {{ json_Object "name" "Alice" "age" 30 }}
|
||||
"json_Object": func(pairs ...any) (string, error) {
|
||||
if len(pairs)%2 != 0 {
|
||||
return "", types.ErrJSONObjectOddArgs
|
||||
}
|
||||
obj := make(map[string]any, len(pairs)/2)
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
key, ok := pairs[i].(string)
|
||||
if !ok {
|
||||
return "", types.NewJSONObjectKeyError(i, pairs[i])
|
||||
}
|
||||
obj[key] = pairs[i+1]
|
||||
}
|
||||
data, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return "", types.NewJSONEncodeError(err)
|
||||
}
|
||||
return string(data), nil
|
||||
},
|
||||
|
||||
// Time
|
||||
"time_NowUnix": func() int64 { return time.Now().Unix() },
|
||||
"time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() },
|
||||
|
||||
@@ -208,8 +208,41 @@ func (e URLParseError) Unwrap() error {
|
||||
var (
|
||||
ErrFileCacheNotInitialized = errors.New("file cache is not initialized")
|
||||
ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)")
|
||||
ErrJSONObjectOddArgs = errors.New("json_Object requires an even number of arguments (key-value pairs)")
|
||||
)
|
||||
|
||||
type JSONObjectKeyError struct {
|
||||
Index int
|
||||
Value any
|
||||
}
|
||||
|
||||
func NewJSONObjectKeyError(index int, value any) JSONObjectKeyError {
|
||||
return JSONObjectKeyError{Index: index, Value: value}
|
||||
}
|
||||
|
||||
func (e JSONObjectKeyError) Error() string {
|
||||
return fmt.Sprintf("json_Object key at index %d must be a string, got %T", e.Index, e.Value)
|
||||
}
|
||||
|
||||
type JSONEncodeError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewJSONEncodeError(err error) JSONEncodeError {
|
||||
if err == nil {
|
||||
err = errNoError
|
||||
}
|
||||
return JSONEncodeError{Err: err}
|
||||
}
|
||||
|
||||
func (e JSONEncodeError) Error() string {
|
||||
return "json_Encode failed: " + e.Err.Error()
|
||||
}
|
||||
|
||||
func (e JSONEncodeError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type TemplateParseError struct {
|
||||
Err error
|
||||
}
|
||||
@@ -445,7 +478,13 @@ func (e ScriptUnknownEngineError) Error() string {
|
||||
|
||||
// ======================================== Captcha ========================================
|
||||
|
||||
var ErrCaptchaKeyEmpty = errors.New("captcha API key cannot be empty")
|
||||
var (
|
||||
ErrCaptchaKeyEmpty = errors.New("captcha API key cannot be empty")
|
||||
// ErrCaptchaProcessing is an internal sentinel returned by the captcha solver polling
|
||||
// code to signal that a task is not yet solved and polling should continue.
|
||||
// It should never be surfaced to callers outside of the captcha poll loop.
|
||||
ErrCaptchaProcessing = errors.New("captcha task still processing")
|
||||
)
|
||||
|
||||
type CaptchaAPIError struct {
|
||||
Endpoint string
|
||||
@@ -481,15 +520,35 @@ func (e CaptchaRequestError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type CaptchaTimeoutError struct {
|
||||
type CaptchaDecodeError struct {
|
||||
Endpoint string
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewCaptchaDecodeError(endpoint string, err error) CaptchaDecodeError {
|
||||
if err == nil {
|
||||
err = errNoError
|
||||
}
|
||||
return CaptchaDecodeError{Endpoint: endpoint, Err: err}
|
||||
}
|
||||
|
||||
func (e CaptchaDecodeError) Error() string {
|
||||
return fmt.Sprintf("captcha %s decode failed: %v", e.Endpoint, e.Err)
|
||||
}
|
||||
|
||||
func (e CaptchaDecodeError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type CaptchaPollTimeoutError struct {
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func NewCaptchaTimeoutError(taskID string) CaptchaTimeoutError {
|
||||
return CaptchaTimeoutError{TaskID: taskID}
|
||||
func NewCaptchaPollTimeoutError(taskID string) CaptchaPollTimeoutError {
|
||||
return CaptchaPollTimeoutError{TaskID: taskID}
|
||||
}
|
||||
|
||||
func (e CaptchaTimeoutError) Error() string {
|
||||
func (e CaptchaPollTimeoutError) Error() string {
|
||||
return fmt.Sprintf("captcha solving timed out (taskId: %s)", e.TaskID)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user