mirror of
https://github.com/aykhans/sarin.git
synced 2026-04-15 20:39:35 +00:00
Compare commits
13 Commits
14280f4e43
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e62fd33f9c | |||
| e9b9b8890c | |||
| 8577c771e4 | |||
| c839b71c9e | |||
| cea692cf1b | |||
| 88f5171132 | |||
| 16b0081d3e | |||
| 1bd58a02b7 | |||
| 006029aad1 | |||
| 0e0ef72778 | |||
|
|
8d10198f02 | ||
| cf3c8f4cde | |||
|
|
65ef05f960 |
@@ -20,16 +20,17 @@
|
|||||||
|
|
||||||
## Overview
|
## 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 |
|
| ✅ Supported | ❌ Not Supported |
|
||||||
| ---------------------------------------------------------- | ------------------------------- |
|
| ---------------------------------------------------------- | ------------------------------- |
|
||||||
| High-performance with low memory footprint | Detailed response body analysis |
|
| High-performance with low memory footprint | Detailed response body analysis |
|
||||||
| Long-running duration/count based tests | Extensive response statistics |
|
| 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 |
|
| Request scripting with Lua and JavaScript | Distributed load testing |
|
||||||
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
| 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
|
## Installation
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
|
|||||||
|
|
||||||
## Templating
|
## 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:**
|
**Example:**
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Configuration
|
# 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:
|
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:**
|
**Merge behavior by field:**
|
||||||
|
|
||||||
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) — higher priority overrides lower priority
|
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.): higher priority overrides lower priority
|
||||||
- **Method and Body** — higher priority overrides lower priority (no merging)
|
- **Method and Body**: higher priority overrides lower priority (no merging)
|
||||||
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js** — accumulated across all config files
|
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js**: accumulated across all config files
|
||||||
|
|
||||||
## URL
|
## 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.
|
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:**
|
**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.
|
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:**
|
**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)
|
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
|
||||||
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
||||||
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
||||||
|
- [Solving Captchas](#solving-captchas)
|
||||||
- [Request Bodies](#request-bodies)
|
- [Request Bodies](#request-bodies)
|
||||||
- [File Uploads](#file-uploads)
|
- [File Uploads](#file-uploads)
|
||||||
- [Using Proxies](#using-proxies)
|
- [Using Proxies](#using-proxies)
|
||||||
@@ -371,7 +372,29 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
|
|||||||
|
|
||||||
</details>
|
</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
|
## 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:** 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
|
## Table of Contents
|
||||||
|
|
||||||
- [Using Values](#using-values)
|
- [Using Values](#using-values)
|
||||||
- [General Functions](#general-functions)
|
- [General Functions](#general-functions)
|
||||||
- [String Functions](#string-functions)
|
- [String Functions](#string-functions)
|
||||||
- [Collection Functions](#collection-functions)
|
- [Collection Functions](#collection-functions)
|
||||||
|
- [JSON Functions](#json-functions)
|
||||||
- [Time Functions](#time-functions)
|
- [Time Functions](#time-functions)
|
||||||
- [Crypto Functions](#crypto-functions)
|
- [Crypto Functions](#crypto-functions)
|
||||||
- [Body Functions](#body-functions)
|
- [Body Functions](#body-functions)
|
||||||
- [File Functions](#file-functions)
|
- [File Functions](#file-functions)
|
||||||
|
- [Captcha Functions](#captcha-functions)
|
||||||
|
- [2Captcha](#2captcha)
|
||||||
|
- [Anti-Captcha](#anti-captcha)
|
||||||
|
- [CapSolver](#capsolver)
|
||||||
- [Fake Data Functions](#fake-data-functions)
|
- [Fake Data Functions](#fake-data-functions)
|
||||||
- [File](#file)
|
- [File](#file)
|
||||||
- [ID](#id)
|
- [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_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
|
||||||
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 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
|
### Time Functions
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
@@ -196,6 +230,95 @@ values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
|
|||||||
body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
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
|
## Fake Data Functions
|
||||||
|
|
||||||
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.
|
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.
|
||||||
|
|||||||
12
go.mod
12
go.mod
@@ -11,16 +11,16 @@ require (
|
|||||||
github.com/charmbracelet/x/term v0.2.2
|
github.com/charmbracelet/x/term v0.2.2
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/valyala/fasthttp v1.69.0
|
github.com/valyala/fasthttp v1.70.0
|
||||||
github.com/yuin/gopher-lua v1.1.2
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
go.aykhans.me/utils v1.0.7
|
go.aykhans.me/utils v1.0.7
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4
|
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 (
|
require (
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
@@ -52,7 +52,7 @@ require (
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/term v0.41.0 // indirect
|
golang.org/x/term v0.42.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
24
go.sum
24
go.sum
@@ -6,8 +6,8 @@ github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ
|
|||||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
@@ -91,8 +91,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
||||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
@@ -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=
|
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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
415
internal/sarin/captcha.go
Normal file
415
internal/sarin/captcha.go
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
package sarin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
captchaPollInterval = 1 * time.Second
|
||||||
|
captchaPollTimeout = 120 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var captchaHTTPClient = &http.Client{Timeout: 5 * time.Second}
|
||||||
|
|
||||||
|
// 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").
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, err := captchaCreateTask(baseURL, apiKey, task)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
"task": task,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewCaptchaDecodeError("createTask", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := captchaHTTPClient.Post(
|
||||||
|
baseURL+"/createTask",
|
||||||
|
"application/json",
|
||||||
|
bytes.NewReader(data),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewCaptchaRequestError("createTask", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ErrorID int `json:"errorId"`
|
||||||
|
ErrorCode string `json:"errorCode"`
|
||||||
|
ErrorDescription string `json:"errorDescription"`
|
||||||
|
TaskID json.RawMessage `json:"taskId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", types.NewCaptchaDecodeError("createTask", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ErrorID != 0 {
|
||||||
|
return "", types.NewCaptchaAPIError("createTask", result.ErrorCode, result.ErrorDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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(), captchaPollTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(captchaPollInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", types.NewCaptchaPollTimeoutError(taskID)
|
||||||
|
case <-ticker.C:
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
} else {
|
||||||
|
bodyMap = map[string]any{"clientKey": apiKey, "taskId": json.Number(taskID)}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(bodyMap)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewCaptchaDecodeError("getTaskResult", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := captchaHTTPClient.Post(
|
||||||
|
baseURL+"/getTaskResult",
|
||||||
|
"application/json",
|
||||||
|
bytes.NewReader(data),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewCaptchaRequestError("getTaskResult", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ErrorID int `json:"errorId"`
|
||||||
|
ErrorCode string `json:"errorCode"`
|
||||||
|
ErrorDescription string `json:"errorDescription"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Solution map[string]any `json:"solution"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", types.NewCaptchaDecodeError("getTaskResult", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ErrorID != 0 {
|
||||||
|
return "", types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Status == "processing" || result.Status == "idle" {
|
||||||
|
return "", types.ErrCaptchaProcessing
|
||||||
|
}
|
||||||
|
|
||||||
|
token, ok := result.Solution[solutionKey]
|
||||||
|
if !ok {
|
||||||
|
return "", types.NewCaptchaSolutionKeyError(solutionKey)
|
||||||
|
}
|
||||||
|
tokenStr, ok := token.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", types.NewCaptchaSolutionKeyError(solutionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
"websiteURL": websiteURL,
|
||||||
|
"websiteKey": websiteKey,
|
||||||
|
}, "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",
|
||||||
|
"websiteURL": websiteURL,
|
||||||
|
"websiteKey": websiteKey,
|
||||||
|
}
|
||||||
|
if pageAction != "" {
|
||||||
|
task["pageAction"] = pageAction
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
"websiteURL": websiteURL,
|
||||||
|
"websiteKey": websiteKey,
|
||||||
|
}
|
||||||
|
if cData != "" {
|
||||||
|
task["data"] = cData
|
||||||
|
}
|
||||||
|
return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "token", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================== Anti-Captcha ========================================
|
||||||
|
|
||||||
|
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",
|
||||||
|
"websiteURL": websiteURL,
|
||||||
|
"websiteKey": websiteKey,
|
||||||
|
}, "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) {
|
||||||
|
task := map[string]any{
|
||||||
|
"type": "RecaptchaV3TaskProxyless",
|
||||||
|
"websiteURL": websiteURL,
|
||||||
|
"websiteKey": websiteKey,
|
||||||
|
"minScore": 0.3,
|
||||||
|
}
|
||||||
|
if pageAction != "" {
|
||||||
|
task["pageAction"] = pageAction
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
|
||||||
|
"type": "HCaptchaTaskProxyless",
|
||||||
|
"websiteURL": websiteURL,
|
||||||
|
"websiteKey": websiteKey,
|
||||||
|
}, "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",
|
||||||
|
"websiteURL": websiteURL,
|
||||||
|
"websiteKey": websiteKey,
|
||||||
|
}
|
||||||
|
if cData != "" {
|
||||||
|
task["cData"] = cData
|
||||||
|
}
|
||||||
|
return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "token", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================== CapSolver ========================================
|
||||||
|
|
||||||
|
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",
|
||||||
|
"websiteURL": websiteURL,
|
||||||
|
"websiteKey": websiteKey,
|
||||||
|
}, "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",
|
||||||
|
"websiteURL": websiteURL,
|
||||||
|
"websiteKey": websiteKey,
|
||||||
|
}
|
||||||
|
if pageAction != "" {
|
||||||
|
task["pageAction"] = pageAction
|
||||||
|
}
|
||||||
|
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",
|
||||||
|
"websiteURL": websiteURL,
|
||||||
|
"websiteKey": websiteKey,
|
||||||
|
}
|
||||||
|
if cData != "" {
|
||||||
|
task["metadata"] = map[string]any{"cdata": cData}
|
||||||
|
}
|
||||||
|
return solveCaptcha(capSolverBaseURL, apiKey, task, "token", true)
|
||||||
|
}
|
||||||
@@ -12,3 +12,10 @@ func NewDefaultRandSource() rand.Source {
|
|||||||
uint64(now>>32),
|
uint64(now>>32),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstOrEmpty(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return values[0]
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -85,6 +86,38 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"slice_Uint": func(values ...uint) []uint { return values },
|
"slice_Uint": func(values ...uint) []uint { return values },
|
||||||
"slice_Join": strings.Join,
|
"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
|
||||||
"time_NowUnix": func() int64 { return time.Now().Unix() },
|
"time_NowUnix": func() int64 { return time.Now().Unix() },
|
||||||
"time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() },
|
"time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() },
|
||||||
@@ -574,8 +607,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
|
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
|
||||||
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
|
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
|
||||||
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
|
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
|
||||||
// "fakeit_ErrorInput": func() string { return fakeit.ErrorInput().Error() },
|
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
|
||||||
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
|
|
||||||
|
|
||||||
// Fakeit / School
|
// Fakeit / School
|
||||||
"fakeit_School": fakeit.School,
|
"fakeit_School": fakeit.School,
|
||||||
@@ -585,6 +617,55 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_SongName": fakeit.SongName,
|
"fakeit_SongName": fakeit.SongName,
|
||||||
"fakeit_SongArtist": fakeit.SongArtist,
|
"fakeit_SongArtist": fakeit.SongArtist,
|
||||||
"fakeit_SongGenre": fakeit.SongGenre,
|
"fakeit_SongGenre": fakeit.SongGenre,
|
||||||
|
|
||||||
|
// Captcha / 2Captcha
|
||||||
|
// Usage: {{ twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||||
|
"twocaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
||||||
|
return twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
|
||||||
|
},
|
||||||
|
// Usage: {{ twocaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
|
||||||
|
"twocaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
|
||||||
|
return twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
|
||||||
|
},
|
||||||
|
// Usage: {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||||
|
// {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
|
||||||
|
"twocaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
|
||||||
|
return twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
|
||||||
|
},
|
||||||
|
|
||||||
|
// Captcha / Anti-Captcha
|
||||||
|
// Usage: {{ anticaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||||
|
"anticaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
||||||
|
return antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
|
||||||
|
},
|
||||||
|
// Usage: {{ anticaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
|
||||||
|
"anticaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
|
||||||
|
return antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
|
||||||
|
},
|
||||||
|
// Usage: {{ anticaptcha_HCaptcha "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||||
|
"anticaptcha_HCaptcha": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
||||||
|
return antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey)
|
||||||
|
},
|
||||||
|
// Usage: {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||||
|
// {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
|
||||||
|
"anticaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
|
||||||
|
return antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
|
||||||
|
},
|
||||||
|
|
||||||
|
// Captcha / CapSolver
|
||||||
|
// Usage: {{ capsolver_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||||
|
"capsolver_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
||||||
|
return capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
|
||||||
|
},
|
||||||
|
// Usage: {{ capsolver_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
|
||||||
|
"capsolver_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
|
||||||
|
return capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
|
||||||
|
},
|
||||||
|
// Usage: {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
|
||||||
|
// {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
|
||||||
|
"capsolver_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
|
||||||
|
return capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,8 +208,41 @@ func (e URLParseError) Unwrap() error {
|
|||||||
var (
|
var (
|
||||||
ErrFileCacheNotInitialized = errors.New("file cache is not initialized")
|
ErrFileCacheNotInitialized = errors.New("file cache is not initialized")
|
||||||
ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)")
|
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 {
|
type TemplateParseError struct {
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
@@ -442,3 +475,91 @@ func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError {
|
|||||||
func (e ScriptUnknownEngineError) Error() string {
|
func (e ScriptUnknownEngineError) Error() string {
|
||||||
return "unknown engine type: " + e.EngineType
|
return "unknown engine type: " + e.EngineType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================================== Captcha ========================================
|
||||||
|
|
||||||
|
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
|
||||||
|
Code string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCaptchaAPIError(endpoint, code, description string) CaptchaAPIError {
|
||||||
|
return CaptchaAPIError{Endpoint: endpoint, Code: code, Description: description}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e CaptchaAPIError) Error() string {
|
||||||
|
return fmt.Sprintf("captcha %s error: %s (%s)", e.Endpoint, e.Code, e.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaptchaRequestError struct {
|
||||||
|
Endpoint string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCaptchaRequestError(endpoint string, err error) CaptchaRequestError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return CaptchaRequestError{Endpoint: endpoint, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e CaptchaRequestError) Error() string {
|
||||||
|
return fmt.Sprintf("captcha %s request failed: %v", e.Endpoint, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e CaptchaRequestError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 NewCaptchaPollTimeoutError(taskID string) CaptchaPollTimeoutError {
|
||||||
|
return CaptchaPollTimeoutError{TaskID: taskID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e CaptchaPollTimeoutError) Error() string {
|
||||||
|
return fmt.Sprintf("captcha solving timed out (taskId: %s)", e.TaskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaptchaSolutionKeyError struct {
|
||||||
|
Key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCaptchaSolutionKeyError(key string) CaptchaSolutionKeyError {
|
||||||
|
return CaptchaSolutionKeyError{Key: key}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e CaptchaSolutionKeyError) Error() string {
|
||||||
|
return fmt.Sprintf("captcha solution missing expected key %q", e.Key)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user