From 7dcbff85e6046b2b7bd012cc679f1d5e9d55cf62 Mon Sep 17 00:00:00 2001 From: capskip Date: Sun, 26 Jul 2026 03:29:46 +0800 Subject: [PATCH] Add GeeTest v3 support (v1.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds geetest(gt, challenge, url, options) to CapSkip (and so the AsyncCapSkip alias), wrapping CapSkip's method=geetest endpoint. The answer comes back as a JSON string in `request`. It stays verbatim in `code`, so code ported from another solver's API keeps working, and is also expanded into `challenge`, `validate`, and `seccode` for direct use — unlike every other captcha type, `code` alone is not directly usable here. An unparseable payload is passed through untouched rather than masked. Submit params are limited to the documented set (gt, challenge, pageurl, api_server, json, proxy, proxytype); all three required fields are checked locally so a missing value fails before the round-trip. GeeTest uses the longer recaptchaTimeout budget rather than defaultTimeout, since it is a real browser solve with internal retries. A caller-supplied timeout still wins. Also validates proxytype against the values CapSkip actually maps (HTTP, HTTPS, SOCKS5, SOCKS5H, case-insensitive) for every proxy-capable captcha type. SOCKS4 and other values previously reached the server and returned ERROR_BAD_PARAMETERS; they now raise ValidationException locally. Image captcha is excluded so it keeps its clearer "proxy not supported" message. TypeScript definitions cover geetest(), GeetestOptions, and the new SolveResult fields. Drops the "no cloud service" phrasing from the README intro; the sentence still makes the local / no-per-solve-fee point. Verified end-to-end against a live CapSkip instance, both from source and from the packed npm tarball: 5/5 GeeTest solves, with image captcha, reCAPTCHA v2, and Turnstile unaffected. --- CHANGELOG.md | 16 ++++ README.md | 26 ++++- docs/API_REFERENCE.md | 78 ++++++++++++++- docs/GETTING_STARTED.md | 1 + docs/TROUBLESHOOTING.md | 42 +++++++++ docs/TUTORIAL.md | 118 ++++++++++++++++++----- examples/geetest.js | 68 ++++++++++++++ package.json | 3 +- src/apiParams.js | 55 +++++++++++ src/index.js | 2 +- src/solver.js | 69 +++++++++++++- test/geetest.test.js | 204 ++++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 45 ++++++++- 13 files changed, 696 insertions(+), 31 deletions(-) create mode 100644 examples/geetest.js create mode 100644 test/geetest.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 419fc91..ac79dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-07-26 + +### Added + +- **GeeTest v3 (slide) support** via `geetest(gt, challenge, url, options)`. + Accepts the optional `api_server` domain override and the usual `proxy` + option. The result exposes the answer as the parsed `challenge`, `validate`, + and `seccode` fields, while `code` keeps the raw JSON string CapSkip returns. +- Parameter aliases `apiServer` and `api_subdomain` for `api_server`. +- TypeScript definitions for `geetest()`, `GeetestOptions`, and the new + `SolveResult` fields. +- `proxytype` is now validated against the values CapSkip accepts (`HTTP`, + `HTTPS`, `SOCKS5`, `SOCKS5H`, case-insensitive) for every proxy-capable captcha + type. `SOCKS4` and other values previously reached the server and came back as + `ERROR_BAD_PARAMETERS`; they now raise `ValidationException` locally. + ## [1.0.2] - 2026-07-15 ### Fixed diff --git a/README.md b/README.md index f27cfd3..9339858 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Official Node.js client for the [CapSkip](https://capskip.com) **local** captcha solver. -CapSkip runs on your machine and exposes a standard captcha-solver HTTP API (the familiar `in.php` / `res.php` endpoints). This SDK wraps that API with clean, familiar method names, so you can solve captchas locally — no cloud service and no per-solve API fees beyond your CapSkip license. +CapSkip runs on your machine and exposes a standard captcha-solver HTTP API (the familiar `in.php` / `res.php` endpoints). This SDK wraps that API with clean, familiar method names, so you can solve captchas locally — no per-solve API fees beyond your CapSkip license. --- @@ -70,6 +70,7 @@ Every solve method returns a Promise — use `await` or `.then()`. | reCAPTCHA v3 Enterprise | `solver.recaptcha(sitekey, url, { version: 'v3', enterprise: 1 })` | | Cloudflare Turnstile (widget) | `solver.turnstile(sitekey, url)` | | Cloudflare Turnstile (challenge page) | `solver.turnstile(sitekey, url, { data, pagedata })` | +| GeeTest v3 (slide) | `solver.geetest(gt, challenge, url)` | --- @@ -97,7 +98,7 @@ const solver = new CapSkip({ host: '127.0.0.1', // CapSkip host port: 8080, // CapSkip port from app settings defaultTimeout: 120, // seconds — image captcha polling timeout - recaptchaTimeout: 300, // seconds — reCAPTCHA / Turnstile polling timeout + recaptchaTimeout: 300, // seconds — reCAPTCHA / Turnstile / GeeTest polling timeout pollingInterval: 5, // max seconds between res.php polls (starts at 0.25s, backs off to this) }); ``` @@ -161,7 +162,23 @@ const v3 = await solver.recaptcha('...', 'https://example.com', { const result = await solver.turnstile('0x4AAAAAAA...', 'https://example.com'); ``` -### With a proxy (reCAPTCHA & Turnstile only) +### GeeTest v3 + +`gt` is static per site, but `challenge` is single-use and expires in about a +minute — fetch a fresh pair right before solving. + +```js +const result = await solver.geetest( + '81388ea1fc187e0c335c0a8907ff2625', + '7cf6a8b1a2c34d5e6f7089abcdef0123', + 'https://example.com/login', +); + +// Post these back exactly as the site's own front-end would +result.challenge, result.validate, result.seccode; +``` + +### With a proxy (reCAPTCHA, Turnstile & GeeTest only) ```js // Proxy is not supported for image captcha @@ -208,6 +225,9 @@ Every solve method resolves to: } ``` +GeeTest additionally expands its answer into `challenge`, `validate`, and +`seccode`, while `code` keeps the raw JSON string. + --- ## Error handling diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index c38a31f..01458d2 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -22,8 +22,9 @@ method returns a `Promise`. | reCAPTCHA v2 | `recaptcha(sitekey, url)` | `userrecaptcha` | | reCAPTCHA v3 | `recaptcha(sitekey, url, { version: 'v3' })` | `userrecaptcha` + `version=v3` | | Cloudflare Turnstile | `turnstile()` | `turnstile` | +| GeeTest v3 (slide) | `geetest()` | `geetest` | -**Proxy** is supported for reCAPTCHA and Turnstile only — not for image captcha. +**Proxy** is supported for reCAPTCHA, Turnstile, and GeeTest — not for image captcha. --- @@ -206,6 +207,66 @@ const challenge = await solver.turnstile('0x4AAAAAAA...', 'https://example.com', --- +## 5. GeeTest v3 — `geetest(gt, challenge, url, { ... })` + +### POST `/in.php` + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `key` | string | Yes | CapSkip API key | +| `method` | string | Yes | `geetest` | +| `gt` | string | Yes | Static per-site GeeTest id | +| `challenge` | string | Yes | Single-use challenge token | +| `pageurl` | string | Yes | Full page URL | +| `api_server` | string | No | GeeTest API server domain, e.g. `api-na.geetest.com` | +| `json` | int | No | `0` plain text, `1` JSON | +| `proxy` | string | No | Proxy address | +| `proxytype` | string | No | Proxy type | + +### Getting `gt` and `challenge` + +Both come from the target site, which fetches them from an endpoint returning +`{"gt": "...", "challenge": "..."}` (often `.../register.php` or a `gettype`/`get.php` +request). Find it in DevTools → Network, or read them out of the +`initGeetest({ gt, challenge })` call in the page scripts. + +> **`challenge` is single-use and expires in about a minute.** Fetch a fresh pair +> immediately before each solve. If a solve comes back with a bad-challenge error, +> request a new pair and retry — reusing one never succeeds. + +### SDK usage + +```js +const result = await solver.geetest( + '81388ea1fc187e0c335c0a8907ff2625', + '7cf6a8b1a2c34d5e6f7089abcdef0123', + 'https://example.com/login', +); + +result.challenge; // geetest_challenge +result.validate; // geetest_validate +result.seccode; // geetest_seccode +result.code; // the same answer as a raw JSON string +``` + +Post the three fields back exactly as the site's own front-end would: + +```js +await fetch(LOGIN_URL, { + method: 'POST', + body: new URLSearchParams({ + geetest_challenge: result.challenge, + geetest_validate: result.validate, + geetest_seccode: result.seccode, + }), +}); +``` + +GeeTest is a real browser solve, so it uses the longer `recaptchaTimeout` budget +rather than `defaultTimeout`. + +--- + ## Return value Every solve method resolves to: @@ -218,6 +279,19 @@ Every solve method resolves to: } ``` +GeeTest additionally expands its answer into `challenge`, `validate`, and +`seccode` (`code` keeps the raw JSON string): + +```js +{ + captchaId: '12345', + code: '{"geetest_challenge":"...","geetest_validate":"...","geetest_seccode":"..."}', + challenge: '...', + validate: '...', + seccode: '...', +} +``` + --- ## SDK parameter aliases @@ -231,6 +305,8 @@ Convenience aliases mapped before sending to CapSkip: | `minScore` | `min_score` | | `datas` | `data-s` | | `data_s` | `data-s` | +| `apiServer` | `api_server` | +| `api_subdomain` | `api_server` | | `proxy` object | `proxy` + `proxytype` strings | ```js diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 55916e2..0905c9f 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -136,6 +136,7 @@ node examples/recaptcha.js | `image_captcha.js` | Image captcha from file, URL, or base64 | | `recaptcha.js` | reCAPTCHA v2, v3, invisible, enterprise, proxy | | `turnstile.js` | Cloudflare Turnstile widget and challenge page | +| `geetest.js` | GeeTest v3 slider, including fetching a fresh `gt`/`challenge` pair | | `async_example.js` | Parallel solving | | `verify_connection.js` | Check CapSkip is running | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e4477eb..4c774b1 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -152,6 +152,48 @@ const result = await solver.recaptcha('...', '...', { proxy }); --- +## GeeTest keeps failing with a bad-challenge error + +**Symptom:** `geetest()` rejects with `ApiException` (or times out) even though +`gt` and `challenge` were copied correctly from the page. + +**Cause:** The `challenge` value is **single-use and expires in about a minute**. +A pair copied out of DevTools minutes earlier, cached in a config file, or reused +across two solves is already dead. + +**Fix:** Fetch a fresh pair programmatically immediately before each solve, and on +failure request a *new* pair rather than retrying the old one: + +```js +const { gt, challenge } = await (await fetch(REGISTER_URL)).json(); +const result = await solver.geetest(gt, challenge, PAGE_URL); +``` + +If the site loads GeeTest from a non-default API server domain, pass it through as well: + +```js +await solver.geetest(gt, challenge, url, { api_server: 'api-na.geetest.com' }); +``` + +--- + +## GeeTest result — where are challenge/validate/seccode? + +`result.code` holds the raw JSON string CapSkip returns. The SDK also parses it +for you, so prefer the individual fields: + +```js +result.challenge; // geetest_challenge +result.validate; // geetest_validate +result.seccode; // geetest_seccode +``` + +Submit all three under their `geetest_`-prefixed names, exactly as the site's own +front-end does. Sending only `validate` is the most common reason a correct solve +is rejected. + +--- + ## NetworkException during manual polling **Symptom:** `getResult()` keeps rejecting with `NetworkException`. diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index f9ad5a2..509f8a2 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -12,14 +12,15 @@ Work through it top to bottom, or jump to the section you need. 5. [reCAPTCHA v2](#5-recaptcha-v2) 6. [reCAPTCHA v3](#6-recaptcha-v3) 7. [Cloudflare Turnstile](#7-cloudflare-turnstile) -8. [Using a proxy](#8-using-a-proxy) -9. [Concurrency](#9-concurrency) -10. [The manual workflow](#10-the-manual-workflow) -11. [Return values](#11-return-values) -12. [Error handling](#12-error-handling) -13. [End-to-end: solve and submit](#13-end-to-end-solve-and-submit) -14. [Parameter reference](#14-parameter-reference) -15. [Best practices](#15-best-practices) +8. [GeeTest v3](#8-geetest-v3) +9. [Using a proxy](#9-using-a-proxy) +10. [Concurrency](#10-concurrency) +11. [The manual workflow](#11-the-manual-workflow) +12. [Return values](#12-return-values) +13. [Error handling](#13-error-handling) +14. [End-to-end: solve and submit](#14-end-to-end-solve-and-submit) +15. [Parameter reference](#15-parameter-reference) +16. [Best practices](#16-best-practices) --- @@ -77,7 +78,7 @@ const solver = new CapSkip({ host: '127.0.0.1', // where CapSkip is listening port: 8080, // API port from CapSkip settings defaultTimeout: 120, // seconds to wait for an image captcha - recaptchaTimeout: 300, // seconds to wait for reCAPTCHA / Turnstile + recaptchaTimeout: 300, // seconds to wait for reCAPTCHA / Turnstile / GeeTest pollingInterval: 5, // max seconds between result polls (starts at 0.25s, backs off to this) }); ``` @@ -149,7 +150,7 @@ await solver.normal('captcha.png', { json: 1 }); ``` > **Note:** Proxies are **not** supported for image captcha — passing one throws -> `ValidationException`. Proxies apply only to reCAPTCHA and Turnstile. +> `ValidationException`. Proxies apply only to reCAPTCHA, Turnstile, and GeeTest. --- @@ -231,24 +232,94 @@ await solver.turnstile('0x4AAAAAAA...', 'https://example.com', { --- -## 8. Using a proxy +## 8. GeeTest v3 + +`solver.geetest(gt, challenge, url)` solves the GeeTest v3 slide puzzle. + +### Finding `gt` and `challenge` + +Unlike a sitekey, GeeTest needs **two** values, and one of them is short-lived: + +| Value | Lifetime | Where it comes from | +|---|---|---| +| `gt` | Static per site | The same place as `challenge` | +| `challenge` | **Single-use, expires in ~1 minute** | An endpoint the site calls that returns `{"gt": "...", "challenge": "..."}` | + +Open DevTools → Network on the target page and look for a request to something like +`.../register.php`, `gettype`, or `get.php`. You can also read the values out of the +`initGeetest({ gt, challenge })` call in the page scripts. + +```js +// Ask the target site for a fresh pair immediately before solving. +const resp = await fetch('https://example.com/captcha/register.php'); +const { gt, challenge } = await resp.json(); + +const result = await solver.geetest(gt, challenge, 'https://example.com/login'); +``` + +### Using the answer + +The result carries the three values the site's own front-end would submit: + +```js +result.challenge; // geetest_challenge +result.validate; // geetest_validate +result.seccode; // geetest_seccode + +result.code; // the same answer as a raw JSON string +``` + +Post them back exactly as the site expects: + +```js +await fetch('https://example.com/login', { + method: 'POST', + body: new URLSearchParams({ + username: '...', + password: '...', + geetest_challenge: result.challenge, + geetest_validate: result.validate, + geetest_seccode: result.seccode, + }), +}); +``` + +> **`challenge` is one-shot.** Never cache or reuse a pair. If a solve fails with a +> bad-challenge error, request a *new* pair and retry — retrying with the same +> `challenge` can never succeed. + +If the site uses a non-default GeeTest API server domain, pass it through: + +```js +await solver.geetest(gt, challenge, url, { api_server: 'api-na.geetest.com' }); +``` + +Because GeeTest is a real browser solve (load, slide, verify), it uses the longer +`recaptchaTimeout` budget rather than `defaultTimeout`. + +--- + +## 9. Using a proxy Solving through the same IP you will submit from greatly improves acceptance rates -for reCAPTCHA and Turnstile. Pass the proxy as an object with `type` and `uri`: +for reCAPTCHA, Turnstile, and GeeTest. Pass the proxy as an object with `type` and `uri`: ```js const proxy = { type: 'HTTPS', uri: 'user:pass@1.2.3.4:3128' }; await solver.recaptcha('...', 'https://example.com', { proxy }); await solver.turnstile('...', 'https://example.com', { proxy }); +await solver.geetest('gt', 'challenge', 'https://example.com', { proxy }); ``` -Supported proxy types: `HTTP`, `HTTPS`, `SOCKS5`, `SOCKS5H`. The `uri` may include +Supported proxy types: `HTTP`, `HTTPS`, `SOCKS5`, `SOCKS5H` — matched +case-insensitively. Anything else (including `SOCKS4`) raises +`ValidationException` before the request is sent. The `uri` may include credentials (`login:password@host:port`) or be a bare `host:port`. --- -## 9. Concurrency +## 10. Concurrency Every solve method returns a Promise, so you can solve many captchas concurrently with `Promise.all`: @@ -279,7 +350,7 @@ const results = await Promise.allSettled([task1, task2]); --- -## 10. The manual workflow +## 11. The manual workflow If you want to submit now and collect the answer later, use the two low-level steps directly. @@ -316,7 +387,7 @@ Pass `1` as the second argument to `getResult` to get the full object (including --- -## 11. Return values +## 12. Return values Every high-level solve method (`normal`, `recaptcha`, `turnstile`, `solve`) resolves to an object: @@ -334,7 +405,7 @@ the solution string (or an object when called with `json = 1`). --- -## 12. Error handling +## 13. Error handling The SDK throws four error types, all subclasses of `CapSkipError`: @@ -388,7 +459,7 @@ try { --- -## 13. End-to-end: solve and submit +## 14. End-to-end: solve and submit A realistic flow — solve a reCAPTCHA, then submit the token to the target site through the **same** proxy: @@ -453,7 +524,7 @@ await fetch(CHALLENGE_URL, { --- -## 14. Parameter reference +## 15. Parameter reference ### Solve methods @@ -462,6 +533,7 @@ await fetch(CHALLENGE_URL, { | Image | `normal(file, { json })` | | reCAPTCHA | `recaptcha(sitekey, url, { version, enterprise, ... })` | | Turnstile | `turnstile(sitekey, url, { ... })` | +| GeeTest v3 | `geetest(gt, challenge, url, { ... })` | | Manual submit | `send(params) -> Promise` | | Manual poll | `getResult(id, json)` | @@ -474,6 +546,7 @@ The SDK accepts friendly names and converts them to the raw API parameters: | `url` | `pageurl` | | `score`, `minScore` | `min_score` | | `datas`, `data_s` | `data-s` | +| `apiServer`, `api_subdomain` | `api_server` | | `proxy` (object) | `proxy` + `proxytype` strings | Anything CapSkip does not document for a given captcha type is rejected with @@ -481,14 +554,17 @@ Anything CapSkip does not document for a given captcha type is rejected with --- -## 15. Best practices +## 16. Best practices - **Keep CapSkip running.** The SDK talks to a local app; if it is not running you get `NetworkException`. - **Use the token immediately.** reCAPTCHA and Turnstile tokens expire within a couple of minutes. - **Match sitekey and pageurl exactly** to the page the widget loads on. -- **Solve and submit from the same IP** (same proxy) for reCAPTCHA and Turnstile. +- **Fetch a fresh GeeTest `challenge` per solve.** It is single-use and expires in + about a minute; a cached pair always fails. +- **Solve and submit from the same IP** (same proxy) for reCAPTCHA, Turnstile, and + GeeTest. - **Never commit secrets.** Read `CAPSKIP_API_KEY` and proxy credentials from the environment, not source code. - **Tune timeouts** for slow captcha types with `recaptchaTimeout` and diff --git a/examples/geetest.js b/examples/geetest.js new file mode 100644 index 0000000..7368d31 --- /dev/null +++ b/examples/geetest.js @@ -0,0 +1,68 @@ +'use strict'; + +/** + * Solve a GeeTest v3 slider. + * + * GeeTest v3 needs two values from the target site: + * + * - `gt` static per site + * - `challenge` single-use, expires in about a minute + * + * The site fetches them itself from an endpoint that returns + * `{"gt": "...", "challenge": "..."}` (often `.../register.php` or a + * `gettype`/`get.php` request). Open DevTools -> Network to find that request, + * then request a *fresh* pair right before solving, as this example does. + */ + +const { CapSkip } = require('capskip'); + +const solver = new CapSkip({ + apiKey: process.env.CAPSKIP_API_KEY || 'capskip', + host: process.env.CAPSKIP_HOST || '127.0.0.1', + port: Number(process.env.CAPSKIP_PORT || 8080), +}); + +// A public GeeTest v3 demo page, and the endpoint that page calls to issue a +// fresh gt/challenge pair. Safe to run as-is. +const PAGE_URL = 'https://2captcha.com/demo/geetest'; +const REGISTER_URL = 'https://2captcha.com/api/v1/captcha-demo/gee-test/init-params'; + +/** Get a fresh gt/challenge pair. Replace with the endpoint your target uses. */ +async function fetchChallenge() { + const resp = await fetch(REGISTER_URL); + if (!resp.ok) { + throw new Error(`Could not fetch a gt/challenge pair: HTTP ${resp.status}`); + } + return resp.json(); +} + +(async () => { + const { gt, challenge } = await fetchChallenge(); + + const result = await solver.geetest(gt, challenge, PAGE_URL); + + console.log('Captcha ID:', result.captchaId); + console.log('Challenge: ', result.challenge); + console.log('Validate: ', result.validate); + console.log('Seccode: ', result.seccode); + + // `code` holds the same answer as a raw JSON string, which is what you forward + // if you are porting code written against another solver's API. + console.log('Raw code: ', result.code); + + // Post these back exactly as the site's own front-end would, e.g.: + // + // await fetch(LOGIN_URL, { + // method: 'POST', + // body: new URLSearchParams({ + // geetest_challenge: result.challenge, + // geetest_validate: result.validate, + // geetest_seccode: result.seccode, + // }), + // }); + console.log('Form fields:', { + geetest_challenge: result.challenge, + geetest_validate: result.validate, + geetest_seccode: result.seccode, + }); +})(); diff --git a/package.json b/package.json index 4307b2d..07a1e00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "capskip", - "version": "1.0.2", + "version": "1.1.0", "description": "Node.js SDK for the CapSkip local captcha solver", "main": "src/index.js", "types": "types/index.d.ts", @@ -26,6 +26,7 @@ "recaptcha", "cloudflare", "turnstile", + "geetest", "capskip", "automation" ], diff --git a/src/apiParams.js b/src/apiParams.js index 9135773..9ae3923 100644 --- a/src/apiParams.js +++ b/src/apiParams.js @@ -21,12 +21,24 @@ const TURNSTILE_SUBMIT = new Set([ 'proxy', 'proxytype', ]); +const GEETEST_SUBMIT = new Set([ + 'method', 'gt', 'challenge', 'pageurl', 'api_server', 'json', + 'proxy', 'proxytype', +]); + +// The only values CapSkip maps to a proxy scheme; it answers +// ERROR_BAD_PARAMETERS for anything else, SOCKS4 included. Matched +// case-insensitively, as the server does. +const PROXY_TYPES = ['HTTP', 'HTTPS', 'SOCKS5', 'SOCKS5H']; + const PARAM_ALIASES = { url: 'pageurl', score: 'min_score', minScore: 'min_score', datas: 'data-s', data_s: 'data-s', + apiServer: 'api_server', + api_subdomain: 'api_server', }; function has(obj, key) { @@ -145,6 +157,38 @@ function validateTurnstileSubmit(params) { } } +function validateGeetestSubmit(params) { + // All three are documented as required. gt is static per site, challenge is + // single-use and expires in about a minute; without them CapSkip answers + // ERROR_BAD_PARAMETERS, and without pageurl ERROR_PAGEURL. Fail locally so a + // missing value does not cost a round-trip. + for (const key of ['gt', 'challenge', 'pageurl']) { + if (!params[key]) { + throw new ValidationException(`'${key}' is required for GeeTest v3.`); + } + } + + const unknown = unknownKeys(params, GEETEST_SUBMIT); + if (unknown.length > 0) { + throw new ValidationException( + `Unsupported parameters for GeeTest: ${reprList(unknown)}.`, + ); + } +} + +function validateProxyType(params) { + const proxytype = params.proxytype; + if (proxytype === undefined || proxytype === null || proxytype === '') { + return; + } + if (!PROXY_TYPES.includes(String(proxytype).toUpperCase())) { + throw new ValidationException( + `Unsupported proxytype '${proxytype}'. ` + + `CapSkip accepts: ${PROXY_TYPES.join(', ')}.`, + ); + } +} + function prepareSubmitParams(params, captchaType, version = 'v2') { let prepared = applyParamAliases(params); prepared = applyProxy(prepared); @@ -155,6 +199,13 @@ function prepareSubmitParams(params, captchaType, version = 'v2') { validateRecaptchaSubmit(prepared, version); } else if (captchaType === 'turnstile') { validateTurnstileSubmit(prepared); + } else if (captchaType === 'geetest') { + validateGeetestSubmit(prepared); + } + + // Skipped for 'normal', which rejects proxy outright with a clearer message. + if (captchaType !== 'normal') { + validateProxyType(prepared); } return prepared; @@ -165,11 +216,15 @@ module.exports = { RECAPTCHA_V2_SUBMIT, RECAPTCHA_V3_SUBMIT, TURNSTILE_SUBMIT, + GEETEST_SUBMIT, + PROXY_TYPES, PARAM_ALIASES, applyParamAliases, applyProxy, validateNormalSubmit, validateRecaptchaSubmit, validateTurnstileSubmit, + validateGeetestSubmit, + validateProxyType, prepareSubmitParams, }; diff --git a/src/index.js b/src/index.js index 3de2746..c6db401 100644 --- a/src/index.js +++ b/src/index.js @@ -27,5 +27,5 @@ module.exports = { NetworkException, ApiException, TimeoutException, - version: '1.0.2', + version: '1.1.0', }; diff --git a/src/solver.js b/src/solver.js index 5fa6999..b1d8d57 100644 --- a/src/solver.js +++ b/src/solver.js @@ -89,6 +89,43 @@ function applyPollResult(result, polled) { return result; } +// GeeTest answers come back as a JSON string in `request`, keyed with the +// geetest_ prefix that the target site's own form fields use. +const GEETEST_FIELDS = [ + ['challenge', 'geetest_challenge'], + ['validate', 'geetest_validate'], + ['seccode', 'geetest_seccode'], +]; + +/** + * Expand the GeeTest answer into `challenge` / `validate` / `seccode`. + * + * `code` keeps the raw JSON string so callers that forward it verbatim (or that + * were written against another solver's API) keep working. If it does not parse, + * the result is returned untouched rather than masking the server's reply. + */ +function applyGeetestSolution(result) { + let payload; + try { + payload = JSON.parse(result.code || ''); + } catch (err) { + return result; + } + + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + return result; + } + + for (const [short, prefixed] of GEETEST_FIELDS) { + const value = payload[prefixed] !== undefined ? payload[prefixed] : payload[short]; + if (value !== undefined && value !== null) { + result[short] = value; + } + } + + return result; +} + // CapSkip's in.php returns OK| by default, or {"status":1,"request":""} // when the submit carried json=1. Accept both so submitting with json=1 works. function parseSubmitResponse(response) { @@ -112,7 +149,7 @@ function parseSubmitResponse(response) { throw new ApiException(`cannot recognize response ${response}`); } -/** Client for the CapSkip local captcha solver (image, reCAPTCHA, Turnstile). */ +/** Client for the CapSkip local captcha solver (image, reCAPTCHA, Turnstile, GeeTest v3). */ class CapSkip { constructor({ apiKey = 'capskip', @@ -167,6 +204,32 @@ class CapSkip { }); } + /** + * Solve a GeeTest v3 slider. + * + * `gt` is static per site; `challenge` is single-use and expires in about a + * minute, so fetch a fresh pair immediately before calling this. Pass + * `api_server` when the site uses a non-default GeeTest API server domain. + * + * The result carries the raw answer as `code` (a JSON string) plus the parsed + * `challenge`, `validate`, and `seccode` fields to post back to the target site. + */ + async geetest(gt, challenge, url, options = {}) { + // Like reCAPTCHA, this is a real browser solve (load, slide, verify) and can + // retry internally, so it gets the longer of the two timeouts unless the + // caller asked for a specific one. + const result = await this.solve({ + timeout: this.recaptchaTimeout, + gt, + challenge, + url, + ...options, + method: 'geetest', + poll_json: 1, + }); + return applyGeetestSolution(result); + } + async solve(options = {}) { const { timeout = 0, @@ -252,6 +315,9 @@ class CapSkip { if (method === 'turnstile') { return prepareSubmitParams(params, 'turnstile'); } + if (method === 'geetest') { + return prepareSubmitParams(params, 'geetest'); + } return applyProxy(applyParamAliases(params)); } } @@ -263,4 +329,5 @@ module.exports = { parsePollResponse, parseSubmitResponse, applyPollResult, + applyGeetestSolution, }; diff --git a/test/geetest.test.js b/test/geetest.test.js new file mode 100644 index 0000000..2d65510 --- /dev/null +++ b/test/geetest.test.js @@ -0,0 +1,204 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { CapSkip } = require('../src'); +const { ValidationException } = require('../src/exceptions'); + +const GT = '81388ea1fc187e0c335c0a8907ff2625'; +const CHALLENGE = '7cf6a8b1a2c34d5e6f7089abcdef0123'; +const URL = 'https://mysite.com/page/with/geetest'; + +const SOLUTION = { + geetest_challenge: CHALLENGE, + geetest_validate: '9b1f4a2c8e7d6b5a4938271605f4e3d2', + geetest_seccode: '9b1f4a2c8e7d6b5a4938271605f4e3d2|jordan', +}; + +// Mock client returning a realistic GeeTest v3 answer (JSON string in `request`). +class GeetestApiClient { + constructor(request = JSON.stringify(SOLUTION)) { + this.request = request; + } + + async in_(options = {}) { + const { files = {}, ...fields } = options; + this.incomings = fields; + this.incomingFiles = files; + return 'OK|123'; + } + + async res(params = {}) { + if (params.json === 1 || params.json === '1') { + return JSON.stringify({ status: 1, request: this.request }); + } + return `OK|${this.request}`; + } +} + +function makeSolver(request) { + const solver = new CapSkip({ apiKey: 'API_KEY', pollingInterval: 1 }); + solver.apiClient = new GeetestApiClient(request); + return solver; +} + +function assertSent(solver, expected) { + assert.deepStrictEqual(solver.apiClient.incomings, { ...expected, key: 'API_KEY' }); +} + +test('geetest: basic solve', async () => { + const solver = makeSolver(); + + const result = await solver.geetest(GT, CHALLENGE, URL); + + assertSent(solver, { + method: 'geetest', + gt: GT, + challenge: CHALLENGE, + pageurl: URL, + }); + assert.strictEqual(result.captchaId, '123'); +}); + +test('geetest: api_server domain override', async () => { + const solver = makeSolver(); + + await solver.geetest(GT, CHALLENGE, URL, { api_server: 'api-na.geetest.com' }); + + assertSent(solver, { + method: 'geetest', + gt: GT, + challenge: CHALLENGE, + pageurl: URL, + api_server: 'api-na.geetest.com', + }); +}); + +test('geetest: apiServer camelCase alias', async () => { + const solver = makeSolver(); + + await solver.geetest(GT, CHALLENGE, URL, { apiServer: 'api-na.geetest.com' }); + + assertSent(solver, { + method: 'geetest', + gt: GT, + challenge: CHALLENGE, + pageurl: URL, + api_server: 'api-na.geetest.com', + }); +}); + +test('geetest: proxy', async () => { + const solver = makeSolver(); + + await solver.geetest(GT, CHALLENGE, URL, { + proxy: { type: 'HTTP', uri: '1.2.3.4:3128' }, + }); + + assertSent(solver, { + method: 'geetest', + gt: GT, + challenge: CHALLENGE, + pageurl: URL, + proxy: '1.2.3.4:3128', + proxytype: 'HTTP', + }); +}); + +test('geetest: keeps the raw JSON answer in code', async () => { + const solver = makeSolver(); + + const result = await solver.geetest(GT, CHALLENGE, URL); + + assert.deepStrictEqual(JSON.parse(result.code), SOLUTION); +}); + +test('geetest: expands the solution fields', async () => { + const solver = makeSolver(); + + const result = await solver.geetest(GT, CHALLENGE, URL); + + assert.strictEqual(result.challenge, SOLUTION.geetest_challenge); + assert.strictEqual(result.validate, SOLUTION.geetest_validate); + assert.strictEqual(result.seccode, SOLUTION.geetest_seccode); +}); + +test('geetest: a non-JSON answer is left alone', async () => { + const solver = makeSolver('not-json'); + + const result = await solver.geetest(GT, CHALLENGE, URL); + + assert.strictEqual(result.code, 'not-json'); + assert.strictEqual(result.validate, undefined); +}); + +test('geetest: missing challenge is rejected', async () => { + const solver = makeSolver(); + + await assert.rejects( + () => solver.geetest(GT, '', URL), + ValidationException, + ); +}); + +test('geetest: missing gt is rejected', async () => { + const solver = makeSolver(); + + await assert.rejects( + () => solver.geetest('', CHALLENGE, URL), + ValidationException, + ); +}); + +test('geetest: missing pageurl is rejected', async () => { + // pageurl is documented as required; fail locally rather than paying a + // round-trip for ERROR_PAGEURL. + const solver = makeSolver(); + + await assert.rejects( + () => solver.geetest(GT, CHALLENGE, ''), + ValidationException, + ); +}); + +test('geetest: unsupported parameter is rejected', async () => { + const solver = makeSolver(); + + await assert.rejects( + () => solver.geetest(GT, CHALLENGE, URL, { sitekey: 'not-a-geetest-param' }), + ValidationException, + ); +}); + +test('geetest: accepted proxy types', async () => { + for (const proxytype of ['HTTP', 'HTTPS', 'SOCKS5', 'SOCKS5H', 'socks5h']) { + const solver = makeSolver(); + await solver.geetest(GT, CHALLENGE, URL, { + proxy: { type: proxytype, uri: '1.2.3.4:3128' }, + }); + assert.strictEqual(solver.apiClient.incomings.proxytype, proxytype); + } +}); + +test('geetest: SOCKS4 is rejected', async () => { + // CapSkip maps only HTTP/HTTPS/SOCKS5/SOCKS5H and answers + // ERROR_BAD_PARAMETERS for SOCKS4, so fail before the round-trip. + const solver = makeSolver(); + + await assert.rejects( + () => solver.geetest(GT, CHALLENGE, URL, { + proxy: { type: 'SOCKS4', uri: '1.2.3.4:3128' }, + }), + ValidationException, + ); +}); + +test('geetest: unknown proxy type is rejected', async () => { + const solver = makeSolver(); + + await assert.rejects( + () => solver.geetest(GT, CHALLENGE, URL, { proxy: '1.2.3.4:3128', proxytype: 'FTP' }), + ValidationException, + ); +}); diff --git a/types/index.d.ts b/types/index.d.ts index aba1735..21c5ef4 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,6 +1,6 @@ // Type definitions for the CapSkip Node.js SDK. -/** Proxy passed to reCAPTCHA / Turnstile solves. */ +/** Proxy passed to reCAPTCHA / Turnstile / GeeTest solves. */ export interface Proxy { /** Proxy type: `HTTP`, `HTTPS`, `SOCKS5`, or `SOCKS5H`. */ type: string; @@ -18,7 +18,7 @@ export interface CapSkipOptions { port?: number; /** Seconds to poll an image captcha before timing out. Default `120`. */ defaultTimeout?: number; - /** Seconds to poll reCAPTCHA / Turnstile before timing out. Default `300`. */ + /** Seconds to poll reCAPTCHA / Turnstile / GeeTest before timing out. Default `300`. */ recaptchaTimeout?: number; /** Max seconds between polls; starts at `0.25` and backs off to this. Default `5`. */ pollingInterval?: number; @@ -28,10 +28,20 @@ export interface CapSkipOptions { export interface SolveResult { /** CapSkip's internal id for this solve. */ captchaId: string; - /** The solution: recognized text for images, a token otherwise. */ + /** + * The solution: recognized text for images, a token otherwise. For GeeTest + * this is the raw JSON string CapSkip returns — prefer the parsed + * `challenge` / `validate` / `seccode` fields below. + */ code: string; /** Turnstile only — the User-Agent to use when submitting the token. */ userAgent?: string; + /** GeeTest only — the `geetest_challenge` value to post back. */ + challenge?: string; + /** GeeTest only — the `geetest_validate` value to post back. */ + validate?: string; + /** GeeTest only — the `geetest_seccode` value to post back. */ + seccode?: string; } /** Extra options for {@link CapSkip.normal}. */ @@ -84,6 +94,23 @@ export interface TurnstileOptions { [key: string]: unknown; } +/** Extra options for {@link CapSkip.geetest}. */ +export interface GeetestOptions { + /** GeeTest API server domain (alias for `api_server`). */ + apiServer?: string; + /** GeeTest API server domain (alias for `api_server`). */ + api_subdomain?: string; + /** GeeTest API server domain, e.g. `"api-na.geetest.com"`. */ + api_server?: string; + /** `1` to request the raw JSON response from CapSkip. */ + json?: number; + /** Proxy used for solving. */ + proxy?: Proxy | string; + /** Proxy type when `proxy` is a bare string. */ + proxytype?: string; + [key: string]: unknown; +} + /** Options for the {@link CapSkip.solve} manual workflow. */ export interface SolveOptions { /** Poll timeout in seconds (falls back to the configured default). */ @@ -111,6 +138,18 @@ export class CapSkip { recaptcha(sitekey: string, url: string, options?: RecaptchaOptions): Promise; /** Solve Cloudflare Turnstile (widget or challenge page). */ turnstile(sitekey: string, url: string, options?: TurnstileOptions): Promise; + /** + * Solve a GeeTest v3 slider. + * + * `gt` is static per site; `challenge` is single-use and expires in about a + * minute, so fetch a fresh pair immediately before calling this. + */ + geetest( + gt: string, + challenge: string, + url: string, + options?: GeetestOptions, + ): Promise; /** Submit then poll to completion. Used by the higher-level solve methods. */ solve(options?: SolveOptions): Promise; /** Submit a captcha without polling; resolves to the captcha id. */