capskip 1.4.0 → 1.6.0
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.
- package/CHANGELOG.md +66 -0
- package/README.md +125 -7
- package/docs/API_REFERENCE.md +243 -0
- package/examples/datadome.js +142 -0
- package/examples/hcaptcha.js +98 -0
- package/package.json +7 -2
- package/src/apiParams.js +290 -0
- package/src/index.js +1 -1
- package/src/solver.js +163 -1
- package/types/index.d.ts +124 -6
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Solve an hCaptcha widget.
|
|
5
|
+
*
|
|
6
|
+
* hCaptcha is the checkbox widget behind many sign-up, login and checkout
|
|
7
|
+
* forms. CapSkip passes the widget in a real browser for the sitekey and page
|
|
8
|
+
* you send and returns the token the widget would have put in the form.
|
|
9
|
+
* Invisible widgets and hCaptcha Enterprise use the same method.
|
|
10
|
+
*
|
|
11
|
+
* You need two values, both read off the target page:
|
|
12
|
+
*
|
|
13
|
+
* * sitekey - the public identifier of the widget, the same for every
|
|
14
|
+
* visitor. Most pages carry it on the container as `data-sitekey`:
|
|
15
|
+
* `<div class="h-captcha" data-sitekey="...">`. When a page builds the
|
|
16
|
+
* widget with a script, look for the `sitekey` option of
|
|
17
|
+
* `hcaptcha.render()`, or the `sitekey=` parameter in the address of the
|
|
18
|
+
* hCaptcha iframe.
|
|
19
|
+
* * url - the full URL of the page the widget is on.
|
|
20
|
+
*
|
|
21
|
+
* A widget with `data-size="invisible"` is invisible: pass `invisible: 1`.
|
|
22
|
+
* Some hCaptcha Enterprise sites pass an `rqdata` value to the widget through
|
|
23
|
+
* `hcaptcha.setData()` before it runs; send it as `data`. It is issued for one
|
|
24
|
+
* challenge and expires quickly, so read a fresh one for every task.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const assert = require('node:assert');
|
|
28
|
+
const { CapSkip } = require('capskip');
|
|
29
|
+
|
|
30
|
+
const solver = new CapSkip({
|
|
31
|
+
apiKey: process.env.CAPSKIP_API_KEY || 'capskip',
|
|
32
|
+
host: process.env.CAPSKIP_HOST || '127.0.0.1',
|
|
33
|
+
port: Number(process.env.CAPSKIP_PORT || 8080),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// hCaptcha's own public demo page and its sitekey.
|
|
37
|
+
const SITEKEY = 'a5f74b19-9e45-40e0-b45d-47ff91b7a6c2';
|
|
38
|
+
const PAGE_URL = 'https://accounts.hcaptcha.com/demo';
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
const result = await solver.hcaptcha(SITEKEY, PAGE_URL);
|
|
42
|
+
|
|
43
|
+
console.log('Captcha ID:', result.captchaId);
|
|
44
|
+
console.log('Token: ', `${result.token.slice(0, 60)}...`);
|
|
45
|
+
console.log('respKey: ', result.respKey ? `${result.respKey.slice(0, 40)}...` : '(empty)');
|
|
46
|
+
console.log('User-Agent:', result.userAgent || '(none reported)');
|
|
47
|
+
|
|
48
|
+
// --- Invisible and Enterprise widgets ------------------------------------
|
|
49
|
+
//
|
|
50
|
+
// const result = await solver.hcaptcha(SITEKEY, PAGE_URL, {
|
|
51
|
+
// invisible: 1, // data-size="invisible"
|
|
52
|
+
// data: rqdata, // the rqdata the page passed to hcaptcha.setData()
|
|
53
|
+
// });
|
|
54
|
+
//
|
|
55
|
+
// --- With a per-request proxy --------------------------------------------
|
|
56
|
+
// CapSkip does not force a proxy. At volume, though, hCaptcha scores the
|
|
57
|
+
// network a token comes from as well as the browser, so send a proxy with
|
|
58
|
+
// each task (or configure a pool in the app: Settings, Captcha types,
|
|
59
|
+
// hCaptcha, Configure Proxies). A proxy sent with a task always takes
|
|
60
|
+
// priority over the pool.
|
|
61
|
+
//
|
|
62
|
+
// const result = await solver.hcaptcha(SITEKEY, PAGE_URL, {
|
|
63
|
+
// proxy: { type: 'HTTP', uri: 'login:password@1.2.3.4:8080' },
|
|
64
|
+
// });
|
|
65
|
+
|
|
66
|
+
// --- Submitting the token ------------------------------------------------
|
|
67
|
+
// The widget writes its token into a hidden textarea named
|
|
68
|
+
// h-captcha-response (and a copy named g-recaptcha-response). Put the token
|
|
69
|
+
// in both and submit the form, or call the widget's data-callback with it.
|
|
70
|
+
// Without a browser, post it as those form fields with the User-Agent from
|
|
71
|
+
// the result:
|
|
72
|
+
//
|
|
73
|
+
// await fetch(FORM_URL, {
|
|
74
|
+
// method: 'POST',
|
|
75
|
+
// headers: { 'User-Agent': result.userAgent },
|
|
76
|
+
// body: new URLSearchParams({
|
|
77
|
+
// email: 'someone@example.com',
|
|
78
|
+
// 'h-captcha-response': result.token,
|
|
79
|
+
// 'g-recaptcha-response': result.token,
|
|
80
|
+
// }),
|
|
81
|
+
// });
|
|
82
|
+
//
|
|
83
|
+
// Tokens are single use and expire about two minutes after they are issued,
|
|
84
|
+
// so submit each one soon and solve again for the next form submission.
|
|
85
|
+
console.log('Form fields:', {
|
|
86
|
+
'h-captcha-response': `${result.token.slice(0, 40)}...`,
|
|
87
|
+
'g-recaptcha-response': `${result.token.slice(0, 40)}...`,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// `code` holds the same string as `token`, which is what you forward if you
|
|
91
|
+
// are porting code written against another solver's API.
|
|
92
|
+
assert.strictEqual(result.code, result.token);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main().catch((error) => {
|
|
96
|
+
console.error(`${error.name}: ${error.message}`);
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "capskip",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Captcha solver for Node.js: solve reCAPTCHA v2/v3, Cloudflare Turnstile, GeeTest, AWS WAF and image captchas with CapSkip, a local unlimited captcha solver with no per-solve fees.",
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "Captcha solver for Node.js: solve reCAPTCHA v2/v3, Cloudflare Turnstile, hCaptcha, GeeTest, AWS WAF, DataDome and image captchas with CapSkip, a local unlimited captcha solver with no per-solve fees.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "types/index.d.ts",
|
|
7
7
|
"exports": {
|
|
@@ -33,6 +33,9 @@
|
|
|
33
33
|
"recaptcha-v2",
|
|
34
34
|
"recaptcha-v3",
|
|
35
35
|
"recaptcha-enterprise",
|
|
36
|
+
"hcaptcha",
|
|
37
|
+
"hcaptcha-solver",
|
|
38
|
+
"hcaptcha-enterprise",
|
|
36
39
|
"cloudflare",
|
|
37
40
|
"turnstile",
|
|
38
41
|
"turnstile-solver",
|
|
@@ -41,6 +44,8 @@
|
|
|
41
44
|
"geetest-solver",
|
|
42
45
|
"aws-waf",
|
|
43
46
|
"aws-waf-captcha",
|
|
47
|
+
"datadome",
|
|
48
|
+
"datadome-solver",
|
|
44
49
|
"image-captcha",
|
|
45
50
|
"ocr",
|
|
46
51
|
"2captcha-alternative",
|
package/src/apiParams.js
CHANGED
|
@@ -59,6 +59,41 @@ const AMAZON_WAF_SCRIPTS = ['challenge_script', 'captcha_script', 'jsapiScript']
|
|
|
59
59
|
// the three AWS WAF script files.
|
|
60
60
|
const AMAZON_WAF_SCRIPT_PATH = /^(\/[A-Za-z0-9_-]+)+\/(challenge|captcha|jsapi)\.js$/;
|
|
61
61
|
|
|
62
|
+
const HCAPTCHA_SUBMIT = new Set([
|
|
63
|
+
'method', 'sitekey', 'pageurl', 'invisible', 'data', 'useragent', 'json',
|
|
64
|
+
'proxy', 'proxytype',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
// What the server reads as "invisible widget" ("1" or "true") and as its
|
|
68
|
+
// default. Strings are matched case-insensitively. A boolean would otherwise
|
|
69
|
+
// reach the form body as 'true' or 'false', and the server reads 'false' as
|
|
70
|
+
// neither, so every accepted form is normalized to the 1 the server reads.
|
|
71
|
+
const HCAPTCHA_INVISIBLE_ON = [true, 1, '1', 'true'];
|
|
72
|
+
const HCAPTCHA_INVISIBLE_OFF = [false, 0, '0', 'false'];
|
|
73
|
+
|
|
74
|
+
const DATADOME_SUBMIT = new Set([
|
|
75
|
+
'method', 'captcha_url', 'pageurl', 'useragent', 'json', 'proxy', 'proxytype',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// Every DataDome challenge is served from this domain or a subdomain of it
|
|
79
|
+
// (geo.captcha-delivery.com in practice). CapSkip refuses any other host.
|
|
80
|
+
const DATADOME_CHALLENGE_DOMAIN = 'captcha-delivery.com';
|
|
81
|
+
|
|
82
|
+
// The two challenge pages: /captcha/ draws the slider, /interstitial/ runs the
|
|
83
|
+
// device check. Any other path renders nothing to solve.
|
|
84
|
+
const DATADOME_CHALLENGE_PATHS = ['/captcha', '/interstitial'];
|
|
85
|
+
|
|
86
|
+
// A Chrome version as Chrome writes it in its User-Agent: four dotted numbers.
|
|
87
|
+
const CHROME_VERSION = /\bChrome\/(\d+)\.\d+\.\d+\.\d+\b/;
|
|
88
|
+
|
|
89
|
+
// Chrome-derived browsers and non-desktop builds. A User-Agent carrying any of
|
|
90
|
+
// these is not stock Windows Chrome, so a cookie earned under it would be
|
|
91
|
+
// refused on replay; the server answers ERROR_WRONG_USERAGENT for each.
|
|
92
|
+
const NOT_STOCK_WINDOWS_CHROME = [
|
|
93
|
+
'Edg/', 'EdgA/', 'OPR/', 'YaBrowser/', 'SamsungBrowser/', 'Vivaldi/',
|
|
94
|
+
'HeadlessChrome/', 'CriOS/', 'Mobile', 'Android',
|
|
95
|
+
];
|
|
96
|
+
|
|
62
97
|
// CapSkip solves the puzzle family only. `avatar` is a different challenge
|
|
63
98
|
// behind a different endpoint; the server refuses it at submit time rather than
|
|
64
99
|
// answering it with a puzzle answer, which would bill for a solve the target
|
|
@@ -100,6 +135,10 @@ const PARAM_ALIASES = {
|
|
|
100
135
|
challengeScript: 'challenge_script',
|
|
101
136
|
captchaScript: 'captcha_script',
|
|
102
137
|
jsapi_script: 'jsapiScript',
|
|
138
|
+
// DataDome: 2Captcha and the API document captcha_url; createTask-style
|
|
139
|
+
// callers arrive with captchaUrl.
|
|
140
|
+
captchaUrl: 'captcha_url',
|
|
141
|
+
captchaURL: 'captcha_url',
|
|
103
142
|
};
|
|
104
143
|
|
|
105
144
|
function has(obj, key) {
|
|
@@ -456,6 +495,240 @@ function validateAmazonWafSubmit(params) {
|
|
|
456
495
|
}
|
|
457
496
|
}
|
|
458
497
|
|
|
498
|
+
const HTML_NAMED_ENTITIES = {
|
|
499
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Decode HTML character references, as the server does before it parses a
|
|
504
|
+
* DataDome captcha_url: an iframe src copied out of the page source carries
|
|
505
|
+
* & between its query parameters.
|
|
506
|
+
*/
|
|
507
|
+
function htmlUnescape(text) {
|
|
508
|
+
return text.replace(/&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z]+);/g, (match, ref) => {
|
|
509
|
+
if (ref[0] === '#') {
|
|
510
|
+
const code = ref[1] === 'x' || ref[1] === 'X'
|
|
511
|
+
? parseInt(ref.slice(2), 16)
|
|
512
|
+
: parseInt(ref.slice(1), 10);
|
|
513
|
+
try {
|
|
514
|
+
return String.fromCodePoint(code);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
return match;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const named = HTML_NAMED_ENTITIES[ref.toLowerCase()];
|
|
520
|
+
return named === undefined ? match : named;
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function isDataDomeHost(host) {
|
|
525
|
+
const lowered = String(host || '').toLowerCase();
|
|
526
|
+
return lowered === DATADOME_CHALLENGE_DOMAIN
|
|
527
|
+
|| lowered.endsWith(`.${DATADOME_CHALLENGE_DOMAIN}`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The query of a DataDome challenge URL as a URLSearchParams, or null if it is
|
|
532
|
+
* not one.
|
|
533
|
+
*
|
|
534
|
+
* Mirrors the server: an https URL on captcha-delivery.com (or a subdomain),
|
|
535
|
+
* path /captcha/ or /interstitial/, carrying initialCid, hash and cid. An
|
|
536
|
+
* iframe src copied HTML-escaped (&) and a scheme-relative //geo... URL
|
|
537
|
+
* are both read the way the server reads them.
|
|
538
|
+
*/
|
|
539
|
+
function parseDataDomeCaptchaUrl(url) {
|
|
540
|
+
let text = htmlUnescape(String(url === undefined || url === null ? '' : url).trim());
|
|
541
|
+
if (text.startsWith('//')) {
|
|
542
|
+
text = `https:${text}`;
|
|
543
|
+
}
|
|
544
|
+
let parsed;
|
|
545
|
+
try {
|
|
546
|
+
parsed = new URL(text);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
if (parsed.protocol !== 'https:' || !isDataDomeHost(parsed.hostname)) {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
const pathname = parsed.pathname.replace(/\/+$/, '').toLowerCase();
|
|
554
|
+
if (!DATADOME_CHALLENGE_PATHS.includes(pathname)) {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
const query = parsed.searchParams;
|
|
558
|
+
if (!['initialCid', 'hash', 'cid'].every((key) => query.get(key))) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
return query;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* True for a stock Chrome-for-Windows User-Agent, the only kind CapSkip takes.
|
|
566
|
+
*
|
|
567
|
+
* DataDome ties the cookie to the browser that earned it, and CapSkip solves
|
|
568
|
+
* in Chrome on Windows, so a cookie for any other User-Agent would be refused
|
|
569
|
+
* on replay. The server also requires the same Chrome major version as its
|
|
570
|
+
* own browser; that part can only be checked there.
|
|
571
|
+
*/
|
|
572
|
+
function isDataDomeUserAgent(userAgent) {
|
|
573
|
+
const ua = String(userAgent === undefined || userAgent === null ? '' : userAgent);
|
|
574
|
+
return ua.includes('Windows NT ')
|
|
575
|
+
&& CHROME_VERSION.test(ua)
|
|
576
|
+
&& !NOT_STOCK_WINDOWS_CHROME.some((token) => ua.includes(token));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Drop unset optionals, and the empty ones too: a DataDome optional left as ''
|
|
581
|
+
* means "not sent" to the server, exactly like an absent one.
|
|
582
|
+
*/
|
|
583
|
+
function normalizeDataDomeSubmit(params) {
|
|
584
|
+
const out = {};
|
|
585
|
+
for (const [key, value] of Object.entries(dropUnset(params))) {
|
|
586
|
+
if (value !== '') {
|
|
587
|
+
out[key] = value;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** An absolute http(s) URL with a host that is not DataDome's own. */
|
|
594
|
+
function isDataDomePageUrl(pageurl) {
|
|
595
|
+
let parsed;
|
|
596
|
+
try {
|
|
597
|
+
parsed = new URL(pageurl);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
|
602
|
+
&& parsed.hostname !== ''
|
|
603
|
+
&& !isDataDomeHost(parsed.hostname);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function validateDataDomeSubmit(params) {
|
|
607
|
+
// Without captcha_url the server answers ERROR_BAD_PARAMETERS and without
|
|
608
|
+
// pageurl ERROR_PAGEURL. Fail locally so a missing value costs no round-trip.
|
|
609
|
+
for (const key of ['captcha_url', 'pageurl']) {
|
|
610
|
+
if (!params[key]) {
|
|
611
|
+
throw new ValidationException(`'${key}' is required for DataDome.`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const query = parseDataDomeCaptchaUrl(params.captcha_url);
|
|
616
|
+
if (query === null) {
|
|
617
|
+
throw new ValidationException(
|
|
618
|
+
"'captcha_url' must be the https address of a DataDome challenge on "
|
|
619
|
+
+ 'captcha-delivery.com, under /captcha/ or /interstitial/ and carrying '
|
|
620
|
+
+ `initialCid, hash and cid, got '${params.captcha_url}'. Take it from `
|
|
621
|
+
+ "the block page's iframe src, or build it from the page's `var dd` object.",
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
// t=bv is DataDome saying the address that fetched the block page is
|
|
625
|
+
// banned. Nothing sent from it can pass, and the server refuses the task
|
|
626
|
+
// with ERROR_PROXY_BANNED, so say so before the round-trip.
|
|
627
|
+
if (query.get('t') === 'bv') {
|
|
628
|
+
throw new ValidationException(
|
|
629
|
+
'This DataDome block page bans the IP address that requested it '
|
|
630
|
+
+ '(t=bv), so no solve from that address can pass. Change the address '
|
|
631
|
+
+ '(proxy), request the page again and submit the new captcha_url.',
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const pageurl = String(params.pageurl).trim();
|
|
636
|
+
if (!isDataDomePageUrl(pageurl)) {
|
|
637
|
+
throw new ValidationException(
|
|
638
|
+
"'pageurl' must be the full http or https URL of the page that "
|
|
639
|
+
+ `returned the DataDome block page, got '${pageurl}'.`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const userAgent = params.useragent;
|
|
644
|
+
if (userAgent !== undefined && userAgent !== null && userAgent !== ''
|
|
645
|
+
&& !isDataDomeUserAgent(userAgent)) {
|
|
646
|
+
throw new ValidationException(
|
|
647
|
+
"DataDome 'userAgent' must be a stock Chrome for Windows User-Agent "
|
|
648
|
+
+ 'with the same major version as the browser CapSkip solves with, got '
|
|
649
|
+
+ `'${userAgent}'. The cookie only works with the User-Agent that `
|
|
650
|
+
+ "earned it. Leave it out and CapSkip uses its own browser's, which "
|
|
651
|
+
+ 'the result reports as `userAgent`.',
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const unknown = unknownKeys(params, DATADOME_SUBMIT);
|
|
656
|
+
if (unknown.length > 0) {
|
|
657
|
+
throw new ValidationException(
|
|
658
|
+
`Unsupported parameters for DataDome: ${reprList(unknown)}.`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Drop unset and empty optionals, and send `invisible` as the 1 the server
|
|
665
|
+
* reads. `invisible: 0` is the server's default, so it is not sent at all.
|
|
666
|
+
*/
|
|
667
|
+
function normalizeHCaptchaSubmit(params) {
|
|
668
|
+
const out = {};
|
|
669
|
+
for (const [key, value] of Object.entries(dropUnset(params))) {
|
|
670
|
+
if (value !== '') {
|
|
671
|
+
out[key] = value;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (has(out, 'invisible')) {
|
|
675
|
+
const raw = out.invisible;
|
|
676
|
+
const key = typeof raw === 'string' ? raw.toLowerCase() : raw;
|
|
677
|
+
if (HCAPTCHA_INVISIBLE_ON.includes(key)) {
|
|
678
|
+
out.invisible = 1;
|
|
679
|
+
} else if (HCAPTCHA_INVISIBLE_OFF.includes(key)) {
|
|
680
|
+
delete out.invisible;
|
|
681
|
+
} else {
|
|
682
|
+
const shown = typeof raw === 'string' ? `'${raw}'` : String(raw);
|
|
683
|
+
throw new ValidationException(
|
|
684
|
+
`invisible must be 0 or 1 (or a boolean) for hCaptcha, not ${shown}.`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return out;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** An absolute http(s) URL with a host. */
|
|
692
|
+
function isHttpUrl(url) {
|
|
693
|
+
let parsed;
|
|
694
|
+
try {
|
|
695
|
+
parsed = new URL(url);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
|
700
|
+
&& parsed.hostname !== '';
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function validateHCaptchaSubmit(params) {
|
|
704
|
+
// The server answers ERROR_RECAPTCHA_INVALID_SITEKEY for a missing sitekey
|
|
705
|
+
// and ERROR_PAGEURL for a page URL that is missing or not http(s). Fail
|
|
706
|
+
// locally so neither costs a round-trip.
|
|
707
|
+
const sitekey = params.sitekey;
|
|
708
|
+
if (sitekey === undefined || sitekey === null || !String(sitekey).trim()) {
|
|
709
|
+
throw new ValidationException("'sitekey' is required for hCaptcha.");
|
|
710
|
+
}
|
|
711
|
+
const rawPageurl = params.pageurl;
|
|
712
|
+
const pageurl = rawPageurl === undefined || rawPageurl === null
|
|
713
|
+
? ''
|
|
714
|
+
: String(rawPageurl).trim();
|
|
715
|
+
if (!pageurl) {
|
|
716
|
+
throw new ValidationException("'pageurl' is required for hCaptcha.");
|
|
717
|
+
}
|
|
718
|
+
if (!isHttpUrl(pageurl)) {
|
|
719
|
+
throw new ValidationException(
|
|
720
|
+
`pageurl must be an absolute http or https URL for hCaptcha, not '${pageurl}'.`,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const unknown = unknownKeys(params, HCAPTCHA_SUBMIT);
|
|
725
|
+
if (unknown.length > 0) {
|
|
726
|
+
throw new ValidationException(
|
|
727
|
+
`Unsupported parameters for hCaptcha: ${reprList(unknown)}.`,
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
459
732
|
function validateProxyType(params) {
|
|
460
733
|
const proxytype = params.proxytype;
|
|
461
734
|
if (proxytype === undefined || proxytype === null || proxytype === '') {
|
|
@@ -496,6 +769,12 @@ function prepareSubmitParams(params, captchaType, version = 'v2') {
|
|
|
496
769
|
} else if (captchaType === 'amazon_waf') {
|
|
497
770
|
prepared = normalizeAmazonWafSubmit(prepared);
|
|
498
771
|
validateAmazonWafSubmit(prepared);
|
|
772
|
+
} else if (captchaType === 'datadome') {
|
|
773
|
+
prepared = normalizeDataDomeSubmit(prepared);
|
|
774
|
+
validateDataDomeSubmit(prepared);
|
|
775
|
+
} else if (captchaType === 'hcaptcha') {
|
|
776
|
+
prepared = normalizeHCaptchaSubmit(prepared);
|
|
777
|
+
validateHCaptchaSubmit(prepared);
|
|
499
778
|
}
|
|
500
779
|
|
|
501
780
|
// Skipped for 'normal', which rejects proxy outright with a clearer message.
|
|
@@ -518,6 +797,10 @@ module.exports = {
|
|
|
518
797
|
FRIENDLY_CAPTCHA_SUBMIT,
|
|
519
798
|
AMAZON_WAF_SUBMIT,
|
|
520
799
|
AMAZON_WAF_SCRIPTS,
|
|
800
|
+
DATADOME_SUBMIT,
|
|
801
|
+
DATADOME_CHALLENGE_DOMAIN,
|
|
802
|
+
DATADOME_CHALLENGE_PATHS,
|
|
803
|
+
HCAPTCHA_SUBMIT,
|
|
521
804
|
CAPY_VERSIONS,
|
|
522
805
|
FRIENDLY_CAPTCHA_VERSIONS,
|
|
523
806
|
PROXY_TYPES,
|
|
@@ -537,6 +820,13 @@ module.exports = {
|
|
|
537
820
|
isAmazonWafScriptUrl,
|
|
538
821
|
normalizeAmazonWafSubmit,
|
|
539
822
|
validateAmazonWafSubmit,
|
|
823
|
+
htmlUnescape,
|
|
824
|
+
parseDataDomeCaptchaUrl,
|
|
825
|
+
isDataDomeUserAgent,
|
|
826
|
+
normalizeDataDomeSubmit,
|
|
827
|
+
validateDataDomeSubmit,
|
|
828
|
+
normalizeHCaptchaSubmit,
|
|
829
|
+
validateHCaptchaSubmit,
|
|
540
830
|
validateProxyType,
|
|
541
831
|
prepareSubmitParams,
|
|
542
832
|
};
|
package/src/index.js
CHANGED
package/src/solver.js
CHANGED
|
@@ -83,6 +83,17 @@ function applyPollResult(result, polled) {
|
|
|
83
83
|
if (userAgent) {
|
|
84
84
|
result.userAgent = userAgent;
|
|
85
85
|
}
|
|
86
|
+
// DataDome names the proxy a task was solved through when the task sent
|
|
87
|
+
// none and CapSkip took one from its own DataDome proxy list. The cookie
|
|
88
|
+
// only works from that address, so it is reported rather than dropped.
|
|
89
|
+
if (polled.proxy) {
|
|
90
|
+
result.proxy = polled.proxy;
|
|
91
|
+
}
|
|
92
|
+
// hCaptcha hands the widget a second value next to the token. It is
|
|
93
|
+
// carried even when empty, which is how the server reports "none".
|
|
94
|
+
if (Object.prototype.hasOwnProperty.call(polled, 'respKey')) {
|
|
95
|
+
result.respKey = polled.respKey;
|
|
96
|
+
}
|
|
86
97
|
// ALTCHA's createTask-shaped `solution` object. Carried through so
|
|
87
98
|
// applyAltchaSolution can read the counter the server already worked out,
|
|
88
99
|
// which is the only reliable source for a proof-of-work v2 answer; that
|
|
@@ -277,6 +288,24 @@ function applyAmazonWafSolution(result) {
|
|
|
277
288
|
return expandObjectAnswer(result, AMAZON_WAF_FIELDS);
|
|
278
289
|
}
|
|
279
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Expose the DataDome cookie as `cookie`, ready for a Cookie header.
|
|
293
|
+
*
|
|
294
|
+
* `code` keeps the cookie exactly as DataDome set it, attributes included
|
|
295
|
+
* (`datadome=...; Max-Age=...; Domain=...; Path=/; Secure; SameSite=Lax`),
|
|
296
|
+
* for a cookie jar. `cookie` is the part before the first semicolon, the
|
|
297
|
+
* `datadome=...` pair a request sends back. `userAgent` and, when CapSkip
|
|
298
|
+
* picked the proxy, `proxy` are carried through from the poll as they are.
|
|
299
|
+
* An answer that is not a string is left untouched.
|
|
300
|
+
*/
|
|
301
|
+
function applyDataDomeSolution(result) {
|
|
302
|
+
const { code } = result;
|
|
303
|
+
if (typeof code === 'string' && code) {
|
|
304
|
+
result.cookie = code.split(';', 1)[0].trim();
|
|
305
|
+
}
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
|
|
280
309
|
/**
|
|
281
310
|
* Expose a single-token answer as `token`, named for the form field it fills.
|
|
282
311
|
*
|
|
@@ -301,6 +330,20 @@ function applyTokenSolution(result) {
|
|
|
301
330
|
return result;
|
|
302
331
|
}
|
|
303
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Expose the hCaptcha token as `token`, next to its `respKey`.
|
|
335
|
+
*
|
|
336
|
+
* `code` keeps the token as the server sent it and `token` is the same string,
|
|
337
|
+
* for the `h-captcha-response` field. `respKey` is always present, empty when
|
|
338
|
+
* the widget handed out none, so callers can read it without a key check.
|
|
339
|
+
*/
|
|
340
|
+
function applyHCaptchaSolution(result) {
|
|
341
|
+
applyTokenSolution(result);
|
|
342
|
+
const { respKey } = result;
|
|
343
|
+
result.respKey = typeof respKey === 'string' ? respKey : '';
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
|
|
304
347
|
// CapSkip's in.php returns OK|<id> by default, or {"status":1,"request":"<id>"}
|
|
305
348
|
// when the submit carried json=1. Accept both so submitting with json=1 works.
|
|
306
349
|
function parseSubmitResponse(response) {
|
|
@@ -326,7 +369,8 @@ function parseSubmitResponse(response) {
|
|
|
326
369
|
|
|
327
370
|
/**
|
|
328
371
|
* Client for the CapSkip local captcha solver (image, reCAPTCHA, Turnstile,
|
|
329
|
-
* GeeTest v3, ALTCHA, Capy Puzzle, CaptchaFox, Friendly Captcha, AWS WAF
|
|
372
|
+
* GeeTest v3, ALTCHA, Capy Puzzle, CaptchaFox, Friendly Captcha, AWS WAF,
|
|
373
|
+
* DataDome, hCaptcha).
|
|
330
374
|
*/
|
|
331
375
|
class CapSkip {
|
|
332
376
|
constructor({
|
|
@@ -620,6 +664,116 @@ class CapSkip {
|
|
|
620
664
|
return applyAmazonWafSolution(result);
|
|
621
665
|
}
|
|
622
666
|
|
|
667
|
+
/**
|
|
668
|
+
* Solve a DataDome challenge and return the `datadome` cookie.
|
|
669
|
+
*
|
|
670
|
+
* `captchaUrl` and `url` are required; everything else goes in `options`:
|
|
671
|
+
*
|
|
672
|
+
* - `captchaUrl` is the address of the challenge iframe on
|
|
673
|
+
* geo.captcha-delivery.com, under /captcha/ (slider) or /interstitial/
|
|
674
|
+
* (device check). Take the iframe's src from the block page, or build it
|
|
675
|
+
* from the page's `var dd` object as the API docs show.
|
|
676
|
+
* - `url` is the page that returned the block page.
|
|
677
|
+
* - `options.userAgent` is the one you will send with the cookie. It must be
|
|
678
|
+
* Chrome for Windows with the same major version as the browser CapSkip
|
|
679
|
+
* solves with; leave it out and CapSkip uses that browser's own.
|
|
680
|
+
* - `options.proxy` is the proxy your later requests go through, as a URI
|
|
681
|
+
* string or `{ type: 'HTTP', uri: 'login:password@IP:PORT' }`.
|
|
682
|
+
*
|
|
683
|
+
* DataDome ties the cookie to the IP address and User-Agent that earned it.
|
|
684
|
+
* The result carries:
|
|
685
|
+
*
|
|
686
|
+
* - `code` -- the cookie exactly as DataDome set it, attributes included;
|
|
687
|
+
* - `cookie` -- just the `datadome=...` pair, for a Cookie header;
|
|
688
|
+
* - `userAgent` -- the User-Agent the cookie was earned with. Send it with
|
|
689
|
+
* every request that carries the cookie;
|
|
690
|
+
* - `proxy` -- present only when you sent no proxy and CapSkip solved
|
|
691
|
+
* through its own DataDome proxy list: that proxy as `host:port`. The
|
|
692
|
+
* cookie works only through it.
|
|
693
|
+
*/
|
|
694
|
+
async dataDome(captchaUrl, url, options = {}) {
|
|
695
|
+
// An unset value is dropped rather than sent as undefined, so
|
|
696
|
+
// `dataDome(undefined, url, { captchaUrl })` reaches the alias without
|
|
697
|
+
// tripping the conflict check; an empty one is dropped during validation,
|
|
698
|
+
// since the server reads '' as "not sent".
|
|
699
|
+
const given = {};
|
|
700
|
+
const add = (key, value) => {
|
|
701
|
+
if (value !== undefined && value !== null) {
|
|
702
|
+
given[key] = value;
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
add('captcha_url', captchaUrl);
|
|
706
|
+
add('url', url);
|
|
707
|
+
for (const [key, value] of Object.entries(options)) {
|
|
708
|
+
add(key, value);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// A real browser session, slider or device check, so it gets the longer
|
|
712
|
+
// of the two timeouts unless the caller asked for a specific one.
|
|
713
|
+
const result = await this.solve({
|
|
714
|
+
timeout: this.recaptchaTimeout,
|
|
715
|
+
...given,
|
|
716
|
+
method: 'datadome',
|
|
717
|
+
poll_json: 1,
|
|
718
|
+
});
|
|
719
|
+
return applyDataDomeSolution(result);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Solve an hCaptcha widget and return its token.
|
|
724
|
+
*
|
|
725
|
+
* `sitekey` and `url` are required; everything else goes in `options`:
|
|
726
|
+
*
|
|
727
|
+
* - `sitekey` is the widget's `data-sitekey` (or the `sitekey` option of
|
|
728
|
+
* `hcaptcha.render()`).
|
|
729
|
+
* - `url` is the full address of the page the widget is on. `pageurl` in
|
|
730
|
+
* `options` is accepted in its place.
|
|
731
|
+
* - `options.invisible` is `1` (or `true`) for a `data-size="invisible"`
|
|
732
|
+
* widget.
|
|
733
|
+
* - `options.data` is the `rqdata` value an hCaptcha Enterprise site passes
|
|
734
|
+
* to `hcaptcha.setData()`. It is single use and expires quickly, so fetch
|
|
735
|
+
* a fresh one for every task.
|
|
736
|
+
* - `options.userAgent` is the User-Agent you will submit the form with.
|
|
737
|
+
* CapSkip uses it only when it matches the browser it solves with.
|
|
738
|
+
* - `options.proxy` is a URI string or
|
|
739
|
+
* `{ type: 'HTTP', uri: 'login:password@IP:PORT' }`.
|
|
740
|
+
*
|
|
741
|
+
* hCaptcha Enterprise and invisible widgets use this same method.
|
|
742
|
+
*
|
|
743
|
+
* The result carries the token as both `code` and `token` (for the
|
|
744
|
+
* `h-captcha-response` and `g-recaptcha-response` fields), `respKey` (the
|
|
745
|
+
* second value hCaptcha hands the widget; most sites never read it, and it
|
|
746
|
+
* can be empty), and `userAgent`, the User-Agent the token was earned with.
|
|
747
|
+
* Submit the token with that User-Agent, soon: hCaptcha tokens are single
|
|
748
|
+
* use and expire about two minutes after they are issued.
|
|
749
|
+
*/
|
|
750
|
+
async hcaptcha(sitekey, url, options = {}) {
|
|
751
|
+
// An unset value is dropped rather than sent as undefined, so
|
|
752
|
+
// `hcaptcha(sitekey, undefined, { pageurl })` reaches the alias without
|
|
753
|
+
// tripping the conflict check; an empty one is dropped during validation.
|
|
754
|
+
const given = {};
|
|
755
|
+
const add = (key, value) => {
|
|
756
|
+
if (value !== undefined && value !== null) {
|
|
757
|
+
given[key] = value;
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
add('sitekey', sitekey);
|
|
761
|
+
add('url', url);
|
|
762
|
+
for (const [key, value] of Object.entries(options)) {
|
|
763
|
+
add(key, value);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// A real browser session, like reCAPTCHA, so it gets the longer of the
|
|
767
|
+
// two timeouts unless the caller asked for a specific one.
|
|
768
|
+
const result = await this.solve({
|
|
769
|
+
timeout: this.recaptchaTimeout,
|
|
770
|
+
...given,
|
|
771
|
+
method: 'hcaptcha',
|
|
772
|
+
poll_json: 1,
|
|
773
|
+
});
|
|
774
|
+
return applyHCaptchaSolution(result);
|
|
775
|
+
}
|
|
776
|
+
|
|
623
777
|
async solve(options = {}) {
|
|
624
778
|
const {
|
|
625
779
|
timeout = 0,
|
|
@@ -723,6 +877,12 @@ class CapSkip {
|
|
|
723
877
|
if (method === 'amazon_waf') {
|
|
724
878
|
return prepareSubmitParams(params, 'amazon_waf');
|
|
725
879
|
}
|
|
880
|
+
if (method === 'datadome') {
|
|
881
|
+
return prepareSubmitParams(params, 'datadome');
|
|
882
|
+
}
|
|
883
|
+
if (method === 'hcaptcha') {
|
|
884
|
+
return prepareSubmitParams(params, 'hcaptcha');
|
|
885
|
+
}
|
|
726
886
|
return applyProxy(applyParamAliases(params));
|
|
727
887
|
}
|
|
728
888
|
}
|
|
@@ -738,6 +898,8 @@ module.exports = {
|
|
|
738
898
|
applyAltchaSolution,
|
|
739
899
|
applyCapySolution,
|
|
740
900
|
applyAmazonWafSolution,
|
|
901
|
+
applyDataDomeSolution,
|
|
902
|
+
applyHCaptchaSolution,
|
|
741
903
|
expandObjectAnswer,
|
|
742
904
|
applyTokenSolution,
|
|
743
905
|
};
|