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.
@@ -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.0",
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
+ * &amp; 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 (&amp;) 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
@@ -27,5 +27,5 @@ module.exports = {
27
27
  NetworkException,
28
28
  ApiException,
29
29
  TimeoutException,
30
- version: '1.4.0',
30
+ version: '1.6.0',
31
31
  };
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
  };