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. */