10 Commits

Author SHA1 Message Date
e62fd33f9c Merge pull request #186 from aykhans/feat/captcha-template-funcs
feat: add captcha solving template funcs for 2Captcha, Anti-Captcha, and CapSolver
2026-04-15 18:42:47 +04:00
e9b9b8890c update docs 2026-04-15 18:42:06 +04:00
8577c771e4 feat: add CaptchaDecodeError type and retry transient HTTP errors during captcha polling 2026-04-12 22:03:21 +04:00
c839b71c9e refactor: rename CaptchaTimeoutError to CaptchaPollTimeoutError and separate HTTP client timeout from poll timeout 2026-04-12 21:09:24 +04:00
cea692cf1b feat: add json_Object and json_Encode template funcs 2026-04-12 04:42:52 +04:00
88f5171132 docs: update template function count to 340+ 2026-04-12 01:40:37 +04:00
16b0081d3e docs: document captcha solving template functions in README and guides 2026-04-12 01:34:28 +04:00
1bd58a02b7 refactor: tighten captcha poll loop and document solver funcs 2026-04-12 01:10:20 +04:00
0e0ef72778 Merge pull request #185 from aykhans/dependabot/go_modules/golang.org/x/net-0.53.0
Bump golang.org/x/net from 0.52.0 to 0.53.0
2026-04-10 11:35:40 +04:00
dependabot[bot]
8d10198f02 Bump golang.org/x/net from 0.52.0 to 0.53.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.52.0 to 0.53.0.
- [Commits](https://github.com/golang/net/compare/v0.52.0...v0.53.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 00:16:33 +00:00
9 changed files with 436 additions and 53 deletions

View File

@@ -20,16 +20,17 @@
## Overview
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicityfeatures 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:**

View File

@@ -1,6 +1,6 @@
# Configuration
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalentYAML 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 orderthe 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 orderthe 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:**

View File

@@ -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

View File

@@ -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 ~560 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
View File

@@ -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
View File

@@ -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=

View File

@@ -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",

View File

@@ -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() },

View File

@@ -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)
}