capskip 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs/promises');
4
+ const path = require('path');
5
+
6
+ const { ApiException, NetworkException } = require('./exceptions');
7
+ const { postForm, postMultipart, get } = require('./http');
8
+
9
+ /** Low-level HTTP client for the CapSkip in.php / res.php endpoints. */
10
+ class ApiClient {
11
+ constructor({ host = '127.0.0.1', port = 8080 } = {}) {
12
+ this.host = host;
13
+ this.port = port;
14
+ }
15
+
16
+ get baseUrl() {
17
+ return `http://${this.host}:${this.port}`;
18
+ }
19
+
20
+ /**
21
+ * Submit a captcha to `in.php`.
22
+ *
23
+ * @param {object} options Form fields for the request. A `files` map
24
+ * (`{ field: path }`) or a single `file` path triggers a multipart upload;
25
+ * otherwise the fields are sent url-encoded.
26
+ * @returns {Promise<string>} The raw `in.php` response text.
27
+ */
28
+ async in_(options = {}) {
29
+ const { files = {}, ...fields } = options;
30
+ const url = `${this.baseUrl}/in.php`;
31
+
32
+ // File reads happen before the request so a missing file surfaces as a
33
+ // filesystem error rather than being masked as a NetworkException.
34
+ let requestPromise;
35
+ if (Object.keys(files).length > 0) {
36
+ const parts = [];
37
+ for (const [name, filePath] of Object.entries(files)) {
38
+ parts.push({ name, filename: path.basename(filePath), content: await fs.readFile(filePath) });
39
+ }
40
+ requestPromise = () => postMultipart(url, fields, parts);
41
+ } else if (Object.prototype.hasOwnProperty.call(fields, 'file')) {
42
+ const filePath = fields.file;
43
+ delete fields.file;
44
+ const content = await fs.readFile(filePath);
45
+ requestPromise = () => postMultipart(url, fields, [
46
+ { name: 'file', filename: path.basename(filePath), content },
47
+ ]);
48
+ } else {
49
+ requestPromise = () => postForm(url, fields);
50
+ }
51
+
52
+ let resp;
53
+ try {
54
+ resp = await requestPromise();
55
+ } catch (err) {
56
+ throw new NetworkException(err);
57
+ }
58
+
59
+ if (resp.statusCode !== 200) {
60
+ throw new NetworkException(`bad response: ${resp.statusCode}`);
61
+ }
62
+
63
+ const text = resp.body.toString('utf-8');
64
+ if (text.includes('ERROR')) {
65
+ throw new ApiException(text);
66
+ }
67
+ return text;
68
+ }
69
+
70
+ /**
71
+ * Poll a result from `res.php`.
72
+ *
73
+ * @param {object} params Query parameters (`key`, `action`, `id`, `json`).
74
+ * @returns {Promise<string>} The raw `res.php` response text.
75
+ */
76
+ async res(params = {}) {
77
+ let resp;
78
+ try {
79
+ resp = await get(`${this.baseUrl}/res.php`, params);
80
+ } catch (err) {
81
+ throw new NetworkException(err);
82
+ }
83
+
84
+ if (resp.statusCode !== 200) {
85
+ throw new NetworkException(`bad response: ${resp.statusCode}`);
86
+ }
87
+
88
+ const text = resp.body.toString('utf-8');
89
+ if (text.includes('ERROR')) {
90
+ throw new ApiException(text);
91
+ }
92
+ return text;
93
+ }
94
+ }
95
+
96
+ module.exports = { ApiClient };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ // CapSkip API parameter validation (https://capskip.com/api-docs/).
4
+
5
+ const { ValidationException } = require('./exceptions');
6
+
7
+ const NORMAL_SUBMIT = new Set(['method', 'body', 'json', 'file']);
8
+
9
+ const RECAPTCHA_V2_SUBMIT = new Set([
10
+ 'method', 'googlekey', 'pageurl', 'enterprise', 'invisible', 'data-s', 'json',
11
+ 'proxy', 'proxytype',
12
+ ]);
13
+
14
+ const RECAPTCHA_V3_SUBMIT = new Set([
15
+ 'method', 'version', 'googlekey', 'pageurl', 'enterprise', 'action', 'min_score',
16
+ 'json', 'proxy', 'proxytype',
17
+ ]);
18
+
19
+ const TURNSTILE_SUBMIT = new Set([
20
+ 'method', 'sitekey', 'pageurl', 'action', 'data', 'pagedata', 'json',
21
+ 'proxy', 'proxytype',
22
+ ]);
23
+
24
+ const PARAM_ALIASES = {
25
+ url: 'pageurl',
26
+ score: 'min_score',
27
+ minScore: 'min_score',
28
+ datas: 'data-s',
29
+ data_s: 'data-s',
30
+ };
31
+
32
+ function has(obj, key) {
33
+ return Object.prototype.hasOwnProperty.call(obj, key);
34
+ }
35
+
36
+ /** Format a list the way Python's `sorted(...)` repr does, for message parity. */
37
+ function reprList(values) {
38
+ return `[${values.map((value) => `'${value}'`).join(', ')}]`;
39
+ }
40
+
41
+ function applyParamAliases(params) {
42
+ const out = { ...params };
43
+ for (const [alias, apiName] of Object.entries(PARAM_ALIASES)) {
44
+ if (has(out, alias)) {
45
+ if (has(out, apiName) && out[alias] !== out[apiName]) {
46
+ throw new ValidationException(`Conflicting parameters: '${alias}' and '${apiName}'`);
47
+ }
48
+ out[apiName] = out[alias];
49
+ delete out[alias];
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function isEmptyProxy(proxy) {
56
+ if (proxy === null || proxy === undefined || proxy === '' || proxy === false || proxy === 0) {
57
+ return true;
58
+ }
59
+ if (typeof proxy === 'object' && !Array.isArray(proxy) && Object.keys(proxy).length === 0) {
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function applyProxy(params) {
66
+ const out = { ...params };
67
+ const proxy = out.proxy;
68
+ delete out.proxy;
69
+
70
+ if (isEmptyProxy(proxy)) {
71
+ return out;
72
+ }
73
+
74
+ if (typeof proxy === 'object' && !Array.isArray(proxy)) {
75
+ if (!has(proxy, 'uri') || !has(proxy, 'type')) {
76
+ throw new ValidationException("proxy dict must contain 'type' and 'uri' keys");
77
+ }
78
+ out.proxy = proxy.uri;
79
+ out.proxytype = proxy.type;
80
+ } else {
81
+ out.proxy = proxy;
82
+ if (!has(out, 'proxytype')) {
83
+ out.proxytype = 'HTTP';
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ function unknownKeys(params, allowed) {
90
+ const excluded = new Set([...allowed, 'key', 'file', 'files']);
91
+ return Object.keys(params).filter((key) => !excluded.has(key)).sort();
92
+ }
93
+
94
+ function validateNormalSubmit(params) {
95
+ const unknown = unknownKeys(params, NORMAL_SUBMIT);
96
+ if (unknown.length > 0) {
97
+ throw new ValidationException(
98
+ `Unsupported parameters for image captcha: ${reprList(unknown)}. `
99
+ + 'CapSkip only supports: method, file/body, json.',
100
+ );
101
+ }
102
+ if (has(params, 'proxy') || has(params, 'proxytype')) {
103
+ throw new ValidationException(
104
+ 'Proxy is not supported for image captcha. '
105
+ + 'Use proxy only with reCAPTCHA or Turnstile.',
106
+ );
107
+ }
108
+ }
109
+
110
+ function validateRecaptchaSubmit(params, version) {
111
+ const normalized = String(version || 'v2').toLowerCase();
112
+ let allowed;
113
+
114
+ if (normalized === 'v3') {
115
+ allowed = RECAPTCHA_V3_SUBMIT;
116
+ if (params.invisible) {
117
+ throw new ValidationException('invisible is only supported for reCAPTCHA v2.');
118
+ }
119
+ } else {
120
+ allowed = RECAPTCHA_V2_SUBMIT;
121
+ if (params.version === 'v3') {
122
+ throw new ValidationException("Use version='v3' for reCAPTCHA v3.");
123
+ }
124
+ for (const key of ['action', 'min_score']) {
125
+ if (has(params, key)) {
126
+ throw new ValidationException(`'${key}' is only supported for reCAPTCHA v3.`);
127
+ }
128
+ }
129
+ }
130
+
131
+ const unknown = unknownKeys(params, allowed);
132
+ if (unknown.length > 0) {
133
+ throw new ValidationException(
134
+ `Unsupported parameters for reCAPTCHA ${normalized}: ${reprList(unknown)}.`,
135
+ );
136
+ }
137
+ }
138
+
139
+ function validateTurnstileSubmit(params) {
140
+ const unknown = unknownKeys(params, TURNSTILE_SUBMIT);
141
+ if (unknown.length > 0) {
142
+ throw new ValidationException(
143
+ `Unsupported parameters for Turnstile: ${reprList(unknown)}.`,
144
+ );
145
+ }
146
+ }
147
+
148
+ function prepareSubmitParams(params, captchaType, version = 'v2') {
149
+ let prepared = applyParamAliases(params);
150
+ prepared = applyProxy(prepared);
151
+
152
+ if (captchaType === 'normal') {
153
+ validateNormalSubmit(prepared);
154
+ } else if (captchaType === 'recaptcha') {
155
+ validateRecaptchaSubmit(prepared, version);
156
+ } else if (captchaType === 'turnstile') {
157
+ validateTurnstileSubmit(prepared);
158
+ }
159
+
160
+ return prepared;
161
+ }
162
+
163
+ module.exports = {
164
+ NORMAL_SUBMIT,
165
+ RECAPTCHA_V2_SUBMIT,
166
+ RECAPTCHA_V3_SUBMIT,
167
+ TURNSTILE_SUBMIT,
168
+ PARAM_ALIASES,
169
+ applyParamAliases,
170
+ applyProxy,
171
+ validateNormalSubmit,
172
+ validateRecaptchaSubmit,
173
+ validateTurnstileSubmit,
174
+ prepareSubmitParams,
175
+ };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ /** Base error for all CapSkip SDK failures. */
4
+ class CapSkipError extends Error {
5
+ constructor(message) {
6
+ if (message instanceof Error) {
7
+ super(message.message, { cause: message });
8
+ } else {
9
+ super(message);
10
+ }
11
+ this.name = this.constructor.name;
12
+ }
13
+ }
14
+
15
+ /** Alias kept for API parity with the CapSkip clients (`solver.exceptions`). */
16
+ const SolverExceptions = CapSkipError;
17
+
18
+ /** Invalid or unsupported parameters. */
19
+ class ValidationException extends CapSkipError {}
20
+
21
+ /** Connection failure or captcha not ready. */
22
+ class NetworkException extends CapSkipError {}
23
+
24
+ /** CapSkip API returned an error. */
25
+ class ApiException extends CapSkipError {}
26
+
27
+ /** Polling exceeded the configured timeout. */
28
+ class TimeoutException extends CapSkipError {}
29
+
30
+ module.exports = {
31
+ CapSkipError,
32
+ SolverExceptions,
33
+ ValidationException,
34
+ NetworkException,
35
+ ApiException,
36
+ TimeoutException,
37
+ };
package/src/http.js ADDED
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+ const crypto = require('crypto');
6
+ const { URL } = require('url');
7
+
8
+ // Zero-dependency HTTP layer built on Node's core http/https modules. CapSkip
9
+ // only ever talks to a local endpoint (in.php / res.php) plus the occasional
10
+ // image download, so a small request helper is all the SDK needs.
11
+
12
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
13
+
14
+ function stringifyValue(value) {
15
+ if (value === null || value === undefined) {
16
+ return '';
17
+ }
18
+ return String(value);
19
+ }
20
+
21
+ /** Encode an object as an application/x-www-form-urlencoded body / query string. */
22
+ function encodeParams(params) {
23
+ return Object.entries(params)
24
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(stringifyValue(value))}`)
25
+ .join('&');
26
+ }
27
+
28
+ /** Build a `?a=b&c=d` query string (empty string when there are no params). */
29
+ function buildQuery(params) {
30
+ const query = encodeParams(params);
31
+ return query ? `?${query}` : '';
32
+ }
33
+
34
+ /** Encode form fields plus file parts as a multipart/form-data body. */
35
+ function encodeMultipart(fields, files) {
36
+ const boundary = `----CapSkipFormBoundary${crypto.randomBytes(16).toString('hex')}`;
37
+ const parts = [];
38
+
39
+ for (const [name, value] of Object.entries(fields)) {
40
+ parts.push(Buffer.from(
41
+ `--${boundary}\r\n`
42
+ + `Content-Disposition: form-data; name="${name}"\r\n\r\n`
43
+ + `${stringifyValue(value)}\r\n`,
44
+ 'utf-8',
45
+ ));
46
+ }
47
+
48
+ for (const file of files) {
49
+ const contentType = file.contentType || 'application/octet-stream';
50
+ parts.push(Buffer.from(
51
+ `--${boundary}\r\n`
52
+ + `Content-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\n`
53
+ + `Content-Type: ${contentType}\r\n\r\n`,
54
+ 'utf-8',
55
+ ));
56
+ parts.push(file.content);
57
+ parts.push(Buffer.from('\r\n', 'utf-8'));
58
+ }
59
+
60
+ parts.push(Buffer.from(`--${boundary}--\r\n`, 'utf-8'));
61
+
62
+ return {
63
+ body: Buffer.concat(parts),
64
+ contentType: `multipart/form-data; boundary=${boundary}`,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Perform a single HTTP request, following redirects for safe methods.
70
+ * Resolves with `{ statusCode, headers, body }` where `body` is a Buffer.
71
+ */
72
+ function request(method, urlString, { headers = {}, body = null, maxRedirects = 5 } = {}) {
73
+ return new Promise((resolve, reject) => {
74
+ let url;
75
+ try {
76
+ url = new URL(urlString);
77
+ } catch (err) {
78
+ reject(err);
79
+ return;
80
+ }
81
+
82
+ const lib = url.protocol === 'https:' ? https : http;
83
+ const options = {
84
+ method,
85
+ hostname: url.hostname,
86
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
87
+ path: `${url.pathname}${url.search}`,
88
+ headers,
89
+ };
90
+
91
+ const req = lib.request(options, (res) => {
92
+ if (REDIRECT_STATUSES.has(res.statusCode) && res.headers.location && maxRedirects > 0) {
93
+ res.resume();
94
+ const nextUrl = new URL(res.headers.location, url).toString();
95
+ const nextMethod = res.statusCode === 303 ? 'GET' : method;
96
+ request(nextMethod, nextUrl, {
97
+ headers,
98
+ body: nextMethod === 'GET' ? null : body,
99
+ maxRedirects: maxRedirects - 1,
100
+ }).then(resolve, reject);
101
+ return;
102
+ }
103
+
104
+ const chunks = [];
105
+ res.on('data', (chunk) => chunks.push(chunk));
106
+ res.on('end', () => resolve({
107
+ statusCode: res.statusCode,
108
+ headers: res.headers,
109
+ body: Buffer.concat(chunks),
110
+ }));
111
+ });
112
+
113
+ req.on('error', reject);
114
+ if (body !== null && body !== undefined) {
115
+ req.write(body);
116
+ }
117
+ req.end();
118
+ });
119
+ }
120
+
121
+ /** POST an application/x-www-form-urlencoded body. */
122
+ function postForm(url, fields) {
123
+ const body = encodeParams(fields);
124
+ return request('POST', url, {
125
+ headers: {
126
+ 'Content-Type': 'application/x-www-form-urlencoded',
127
+ 'Content-Length': Buffer.byteLength(body),
128
+ },
129
+ body,
130
+ });
131
+ }
132
+
133
+ /** POST a multipart/form-data body carrying one or more file parts. */
134
+ function postMultipart(url, fields, files) {
135
+ const { body, contentType } = encodeMultipart(fields, files);
136
+ return request('POST', url, {
137
+ headers: {
138
+ 'Content-Type': contentType,
139
+ 'Content-Length': body.length,
140
+ },
141
+ body,
142
+ });
143
+ }
144
+
145
+ /** GET with an object of query parameters. */
146
+ function get(url, params = {}) {
147
+ return request('GET', `${url}${buildQuery(params)}`);
148
+ }
149
+
150
+ module.exports = {
151
+ request,
152
+ postForm,
153
+ postMultipart,
154
+ get,
155
+ encodeParams,
156
+ buildQuery,
157
+ encodeMultipart,
158
+ };
package/src/index.js ADDED
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ // CapSkip Node.js SDK — local captcha solver client.
4
+
5
+ const { CapSkip } = require('./solver');
6
+ const { ApiClient } = require('./apiClient');
7
+ const {
8
+ CapSkipError,
9
+ SolverExceptions,
10
+ ValidationException,
11
+ NetworkException,
12
+ ApiException,
13
+ TimeoutException,
14
+ } = require('./exceptions');
15
+
16
+ // Node I/O is asynchronous by nature, so every CapSkip method already returns a
17
+ // Promise. `AsyncCapSkip` / `AsyncApiClient` are exported as aliases so code
18
+ // ported from other CapSkip SDKs keeps working unchanged.
19
+ module.exports = {
20
+ CapSkip,
21
+ AsyncCapSkip: CapSkip,
22
+ ApiClient,
23
+ AsyncApiClient: ApiClient,
24
+ CapSkipError,
25
+ SolverExceptions,
26
+ ValidationException,
27
+ NetworkException,
28
+ ApiException,
29
+ TimeoutException,
30
+ version: '1.0.2',
31
+ };