@mercuryo-ai/captcha-solver 0.1.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/README.md +38 -0
- package/dist/captcha-detector.d.ts +23 -0
- package/dist/captcha-detector.d.ts.map +1 -0
- package/dist/captcha-detector.js +638 -0
- package/dist/captcha-runtime.d.ts +21 -0
- package/dist/captcha-runtime.d.ts.map +1 -0
- package/dist/captcha-runtime.js +166 -0
- package/dist/captcha-solver.d.ts +37 -0
- package/dist/captcha-solver.d.ts.map +1 -0
- package/dist/captcha-solver.js +99 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @mercuryo-ai/captcha-solver
|
|
2
|
+
|
|
3
|
+
Standalone captcha-solving runtime primitives.
|
|
4
|
+
|
|
5
|
+
This package owns the captcha-specific execution layer that should not live in
|
|
6
|
+
`@mercuryo-ai/agentbrowse` and is not part of the long-term MagicPay payment SDK
|
|
7
|
+
surface.
|
|
8
|
+
|
|
9
|
+
It currently provides:
|
|
10
|
+
|
|
11
|
+
- browser-connected captcha solving via `solveCaptchasByCdp(...)`
|
|
12
|
+
- low-level solver client `CaptchaSolver`
|
|
13
|
+
- detection and solving helpers for reCAPTCHA v2, hCaptcha, and Turnstile
|
|
14
|
+
|
|
15
|
+
It does not provide:
|
|
16
|
+
|
|
17
|
+
- browser session persistence
|
|
18
|
+
- MagicPay CLI orchestration
|
|
19
|
+
- payment/session/approval flow semantics
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm i @mercuryo-ai/captcha-solver
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Example
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { solveCaptchasByCdp } from '@mercuryo-ai/captcha-solver';
|
|
31
|
+
|
|
32
|
+
const result = await solveCaptchasByCdp('ws://127.0.0.1:9222/devtools/browser/test', 'ap_test', {
|
|
33
|
+
apiUrl: 'https://agents-api.mercuryo.io/functions/v1/api',
|
|
34
|
+
timeoutMs: 90_000,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
console.log(result);
|
|
38
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Page } from 'puppeteer';
|
|
2
|
+
import type { CaptchaSolver } from './captcha-solver.js';
|
|
3
|
+
import type { DetectedCaptcha } from './types.js';
|
|
4
|
+
export type SolveVisibleCaptchasOptions = {
|
|
5
|
+
solveTimeoutMs?: number;
|
|
6
|
+
onProgress?: (message: string) => void;
|
|
7
|
+
sharedTransport?: boolean;
|
|
8
|
+
skipCaptcha?: (captcha: DetectedCaptcha) => boolean;
|
|
9
|
+
onSolvedCaptcha?: (captcha: DetectedCaptcha) => void;
|
|
10
|
+
onCaptchaResult?: (result: CaptchaSolveOutcome) => void;
|
|
11
|
+
};
|
|
12
|
+
export type CaptchaSolveOutcome = {
|
|
13
|
+
captcha: DetectedCaptcha;
|
|
14
|
+
injected: boolean;
|
|
15
|
+
clicked: boolean;
|
|
16
|
+
verified: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function detectCaptchas(page: Page): Promise<DetectedCaptcha[]>;
|
|
20
|
+
export declare function solveVisibleCaptchas(page: Page, solver: CaptchaSolver): Promise<number>;
|
|
21
|
+
export declare function solveVisibleCaptchasWithOptions(page: Page, solver: CaptchaSolver, opts?: SolveVisibleCaptchasOptions): Promise<CaptchaSolveOutcome[]>;
|
|
22
|
+
export declare function setupCaptchaMonitor(page: Page, solver: CaptchaSolver): void;
|
|
23
|
+
//# sourceMappingURL=captcha-detector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"captcha-detector.d.ts","sourceRoot":"","sources":["../src/captcha-detector.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD,MAAM,MAAM,2BAA2B,GAAG;IACxC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC;IACpD,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,IAAI,CAAC;IACrD,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,mBAAmB,KAAK,IAAI,CAAC;CACzD,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,OAAO,EAAE,eAAe,CAAC;IACzB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAKF,wBAAsB,cAAc,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAwI3E;AAED,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAG7F;AAED,wBAAsB,+BAA+B,CACnD,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,aAAa,EACrB,IAAI,CAAC,EAAE,2BAA2B,GACjC,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAuHhC;AA2YD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,GAAG,IAAI,CA4B3E"}
|
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
const DEFAULT_SOLVE_TIMEOUT_MS = 90000;
|
|
2
|
+
const SOLVE_TIMEOUT = Symbol('solve-timeout');
|
|
3
|
+
export async function detectCaptchas(page) {
|
|
4
|
+
const pageUrl = page.url();
|
|
5
|
+
return page.evaluate((url) => {
|
|
6
|
+
const found = [];
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
const recaptchaEl = document.querySelector('.g-recaptcha[data-sitekey], div[data-sitekey]:not([data-hcaptcha-widget-id])');
|
|
9
|
+
if (recaptchaEl) {
|
|
10
|
+
const key = recaptchaEl.getAttribute('data-sitekey');
|
|
11
|
+
if (key) {
|
|
12
|
+
const seenKey = ['recaptcha-v2', '', key, ''].join(':');
|
|
13
|
+
if (!seen.has(seenKey)) {
|
|
14
|
+
seen.add(seenKey);
|
|
15
|
+
found.push({ type: 'recaptcha-v2', siteKey: key, pageUrl: url });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const recaptchaIframe = document.querySelector('iframe[src*="recaptcha/api2"], iframe[src*="recaptcha/enterprise"]');
|
|
20
|
+
if (recaptchaIframe && found.length === 0) {
|
|
21
|
+
const match = recaptchaIframe.src.match(/[?&]k=([^&]+)/);
|
|
22
|
+
if (match?.[1]) {
|
|
23
|
+
const seenKey = ['recaptcha-v2', '', match[1], ''].join(':');
|
|
24
|
+
if (!seen.has(seenKey)) {
|
|
25
|
+
seen.add(seenKey);
|
|
26
|
+
found.push({ type: 'recaptcha-v2', siteKey: match[1], pageUrl: url });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const hcaptchaEl = document.querySelector('.h-captcha[data-sitekey], div[data-hcaptcha-widget-id][data-sitekey]');
|
|
31
|
+
if (hcaptchaEl) {
|
|
32
|
+
const key = hcaptchaEl.getAttribute('data-sitekey');
|
|
33
|
+
if (key) {
|
|
34
|
+
const seenKey = ['hcaptcha', '', key, ''].join(':');
|
|
35
|
+
if (!seen.has(seenKey)) {
|
|
36
|
+
seen.add(seenKey);
|
|
37
|
+
found.push({ type: 'hcaptcha', siteKey: key, pageUrl: url });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const turnstileEl = document.querySelector('.cf-turnstile[data-sitekey]');
|
|
42
|
+
if (turnstileEl) {
|
|
43
|
+
const key = turnstileEl.getAttribute('data-sitekey');
|
|
44
|
+
if (key) {
|
|
45
|
+
const seenKey = ['turnstile', '', key, ''].join(':');
|
|
46
|
+
if (!seen.has(seenKey)) {
|
|
47
|
+
seen.add(seenKey);
|
|
48
|
+
found.push({
|
|
49
|
+
type: 'turnstile',
|
|
50
|
+
siteKey: key,
|
|
51
|
+
pageUrl: url,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const challengeStore = window.__agentbrowseTurnstileChallenges;
|
|
57
|
+
if (Array.isArray(challengeStore)) {
|
|
58
|
+
for (const challenge of challengeStore) {
|
|
59
|
+
const callbackId = challenge.callbackId ?? '';
|
|
60
|
+
const siteKey = challenge.siteKey ?? '';
|
|
61
|
+
const seenKey = ['turnstile', 'cloudflare-challenge', siteKey, callbackId].join(':');
|
|
62
|
+
if (!seen.has(seenKey)) {
|
|
63
|
+
seen.add(seenKey);
|
|
64
|
+
found.push({
|
|
65
|
+
type: 'turnstile',
|
|
66
|
+
variant: 'cloudflare-challenge',
|
|
67
|
+
siteKey,
|
|
68
|
+
pageUrl: url,
|
|
69
|
+
captureSource: challenge.captureSource ?? 'render-hook',
|
|
70
|
+
challengeReady: Boolean(siteKey && challenge.cData && challenge.chlPageData),
|
|
71
|
+
action: challenge.action ?? '',
|
|
72
|
+
cData: challenge.cData ?? '',
|
|
73
|
+
chlPageData: challenge.chlPageData ?? '',
|
|
74
|
+
callbackId,
|
|
75
|
+
userAgent: challenge.userAgent ?? '',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (found.length === 0) {
|
|
81
|
+
const hasChallengePlatformScript = document.querySelectorAll("script[src*='/cdn-cgi/challenge-platform/']").length > 0;
|
|
82
|
+
const hasChallengeField = Array.from(document.querySelectorAll("input[name='cf-turnstile-response'], textarea[name='cf-turnstile-response']")).some((node) => {
|
|
83
|
+
const id = node.getAttribute('id') ?? '';
|
|
84
|
+
return id.startsWith('cf-chl-widget');
|
|
85
|
+
});
|
|
86
|
+
const hasCfChlOpt = Boolean(window._cf_chl_opt &&
|
|
87
|
+
typeof window._cf_chl_opt === 'object');
|
|
88
|
+
if (hasChallengePlatformScript || hasChallengeField || hasCfChlOpt) {
|
|
89
|
+
const seenKey = ['turnstile', 'cloudflare-challenge', '', ''].join(':');
|
|
90
|
+
if (!seen.has(seenKey)) {
|
|
91
|
+
seen.add(seenKey);
|
|
92
|
+
found.push({
|
|
93
|
+
type: 'turnstile',
|
|
94
|
+
variant: 'cloudflare-challenge',
|
|
95
|
+
siteKey: '',
|
|
96
|
+
pageUrl: url,
|
|
97
|
+
captureSource: 'dom-fallback',
|
|
98
|
+
challengeReady: false,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return found;
|
|
104
|
+
}, pageUrl);
|
|
105
|
+
}
|
|
106
|
+
export async function solveVisibleCaptchas(page, solver) {
|
|
107
|
+
const outcomes = await solveVisibleCaptchasWithOptions(page, solver, undefined);
|
|
108
|
+
return outcomes.filter((outcome) => outcome.injected).length;
|
|
109
|
+
}
|
|
110
|
+
export async function solveVisibleCaptchasWithOptions(page, solver, opts) {
|
|
111
|
+
const captchas = await detectCaptchas(page);
|
|
112
|
+
const outcomes = [];
|
|
113
|
+
const timeoutMs = opts?.solveTimeoutMs ?? DEFAULT_SOLVE_TIMEOUT_MS;
|
|
114
|
+
for (const captcha of captchas) {
|
|
115
|
+
if (opts?.skipCaptcha?.(captcha)) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (captcha.variant === 'cloudflare-challenge' && captcha.challengeReady === false) {
|
|
119
|
+
const missingParamsOutcome = {
|
|
120
|
+
captcha,
|
|
121
|
+
injected: false,
|
|
122
|
+
clicked: false,
|
|
123
|
+
verified: false,
|
|
124
|
+
error: 'challenge-params-missing',
|
|
125
|
+
};
|
|
126
|
+
opts?.onProgress?.(`[captcha] detected ${captcha.type} on ${captcha.pageUrl}, but challenge params were not captured`);
|
|
127
|
+
outcomes.push(missingParamsOutcome);
|
|
128
|
+
opts?.onCaptchaResult?.(missingParamsOutcome);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (captcha.variant === 'cloudflare-challenge' && opts?.sharedTransport !== true) {
|
|
132
|
+
const transportRequiredOutcome = {
|
|
133
|
+
captcha,
|
|
134
|
+
injected: false,
|
|
135
|
+
clicked: false,
|
|
136
|
+
verified: false,
|
|
137
|
+
error: 'challenge-shared-transport-required',
|
|
138
|
+
};
|
|
139
|
+
opts?.onProgress?.(`[captcha] detected ${captcha.type} on ${captcha.pageUrl}, but Cloudflare challenge pages need shared browser/solver transport and that orchestration path is still deferred`);
|
|
140
|
+
outcomes.push(transportRequiredOutcome);
|
|
141
|
+
opts?.onCaptchaResult?.(transportRequiredOutcome);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
opts?.onProgress?.(`[captcha] detected ${captcha.type} on ${captcha.pageUrl}, sending to solver (timeout ${Math.ceil(timeoutMs / 1000)}s)`);
|
|
145
|
+
try {
|
|
146
|
+
const solveRequest = toSolverRequest(captcha);
|
|
147
|
+
const maybeToken = await Promise.race([
|
|
148
|
+
solveRequest
|
|
149
|
+
? solver.solve(captcha.type, captcha.siteKey, captcha.pageUrl, solveRequest)
|
|
150
|
+
: solver.solve(captcha.type, captcha.siteKey, captcha.pageUrl),
|
|
151
|
+
sleep(timeoutMs).then(() => SOLVE_TIMEOUT),
|
|
152
|
+
]);
|
|
153
|
+
if (maybeToken === SOLVE_TIMEOUT) {
|
|
154
|
+
opts?.onProgress?.(`[captcha] solver timed out for ${captcha.type}`);
|
|
155
|
+
const timeoutOutcome = {
|
|
156
|
+
captcha,
|
|
157
|
+
injected: false,
|
|
158
|
+
clicked: false,
|
|
159
|
+
verified: false,
|
|
160
|
+
error: 'solver-timeout',
|
|
161
|
+
};
|
|
162
|
+
outcomes.push(timeoutOutcome);
|
|
163
|
+
opts?.onCaptchaResult?.(timeoutOutcome);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const solution = maybeToken;
|
|
167
|
+
await applySolverUserAgent(page, solution.userAgent);
|
|
168
|
+
await injectCaptchaToken(page, captcha, solution.token);
|
|
169
|
+
let verified = await verifyCaptchaResolved(page, captcha);
|
|
170
|
+
let clicked = false;
|
|
171
|
+
// Click only as last resort — it triggers a new Google challenge flow,
|
|
172
|
+
// which ignores the injected token. Token + callback is the primary path.
|
|
173
|
+
if (!verified) {
|
|
174
|
+
clicked = await bestEffortClickCaptchaCheckbox(page, captcha, opts?.onProgress);
|
|
175
|
+
if (clicked) {
|
|
176
|
+
verified = await verifyCaptchaResolved(page, captcha);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const outcome = {
|
|
180
|
+
captcha,
|
|
181
|
+
injected: true,
|
|
182
|
+
clicked,
|
|
183
|
+
verified,
|
|
184
|
+
};
|
|
185
|
+
outcomes.push(outcome);
|
|
186
|
+
opts?.onCaptchaResult?.(outcome);
|
|
187
|
+
if (verified) {
|
|
188
|
+
opts?.onSolvedCaptcha?.(captcha);
|
|
189
|
+
opts?.onProgress?.(`[captcha] solved ${captcha.type}, token injected${clicked ? ' + click' : ''}, verified`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
opts?.onProgress?.(`[captcha] solved ${captcha.type}, token injected${clicked ? ' + click' : ''}, unresolved`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
const reason = formatSolveError(error);
|
|
197
|
+
opts?.onProgress?.(`[captcha] solver failed for ${captcha.type}: ${reason}`);
|
|
198
|
+
const failOutcome = {
|
|
199
|
+
captcha,
|
|
200
|
+
injected: false,
|
|
201
|
+
clicked: false,
|
|
202
|
+
verified: false,
|
|
203
|
+
error: 'solver-failed',
|
|
204
|
+
};
|
|
205
|
+
outcomes.push(failOutcome);
|
|
206
|
+
opts?.onCaptchaResult?.(failOutcome);
|
|
207
|
+
// Continue with remaining captcha widgets.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return outcomes;
|
|
211
|
+
}
|
|
212
|
+
async function injectCaptchaToken(page, captcha, token) {
|
|
213
|
+
await page.evaluate((info) => {
|
|
214
|
+
if (info.type === 'recaptcha-v2') {
|
|
215
|
+
const selectors = [
|
|
216
|
+
'#g-recaptcha-response',
|
|
217
|
+
"textarea[name='g-recaptcha-response']",
|
|
218
|
+
"textarea[id*='g-recaptcha-response']",
|
|
219
|
+
"input[name='g-recaptcha-response']",
|
|
220
|
+
];
|
|
221
|
+
for (const selector of selectors) {
|
|
222
|
+
const nodes = document.querySelectorAll(selector);
|
|
223
|
+
for (const node of Array.from(nodes)) {
|
|
224
|
+
node.value = info.token;
|
|
225
|
+
node.dispatchEvent(new Event('input', { bubbles: true }));
|
|
226
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Invoke reCAPTCHA success callback
|
|
230
|
+
let callbackInvoked = false;
|
|
231
|
+
// Method 1: data-callback attribute (implicit rendering)
|
|
232
|
+
const el = document.querySelector('.g-recaptcha[data-callback], div[data-sitekey][data-callback]');
|
|
233
|
+
const callbackName = el?.getAttribute('data-callback');
|
|
234
|
+
if (callbackName) {
|
|
235
|
+
const cb = window[callbackName];
|
|
236
|
+
if (typeof cb === 'function') {
|
|
237
|
+
cb(info.token);
|
|
238
|
+
callbackInvoked = true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Method 2: ___grecaptcha_cfg.clients (explicit rendering / no data-callback)
|
|
242
|
+
if (!callbackInvoked) {
|
|
243
|
+
/* eslint-disable -- plain JS in browser evaluate context, no TS helpers available */
|
|
244
|
+
const win = window;
|
|
245
|
+
const cfg = win.___grecaptcha_cfg;
|
|
246
|
+
if (cfg && cfg.clients) {
|
|
247
|
+
const queue = [];
|
|
248
|
+
let found = null;
|
|
249
|
+
for (const client of Object.values(cfg.clients)) {
|
|
250
|
+
queue.push([client, 0]);
|
|
251
|
+
}
|
|
252
|
+
while (queue.length > 0 && !found) {
|
|
253
|
+
const pair = queue.shift();
|
|
254
|
+
const obj = pair[0];
|
|
255
|
+
const depth = pair[1];
|
|
256
|
+
if (depth > 5 || !obj || typeof obj !== 'object')
|
|
257
|
+
continue;
|
|
258
|
+
if (typeof obj.callback === 'function') {
|
|
259
|
+
found = obj.callback;
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
for (const val of Object.values(obj)) {
|
|
263
|
+
if (typeof val === 'object' && val !== null) {
|
|
264
|
+
queue.push([val, depth + 1]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (found)
|
|
269
|
+
found(info.token);
|
|
270
|
+
}
|
|
271
|
+
/* eslint-enable */
|
|
272
|
+
}
|
|
273
|
+
const submitBtn = document.querySelector("button[type='submit'], input[type='submit']");
|
|
274
|
+
if (submitBtn)
|
|
275
|
+
submitBtn.removeAttribute('disabled');
|
|
276
|
+
}
|
|
277
|
+
else if (info.type === 'hcaptcha') {
|
|
278
|
+
const selectors = [
|
|
279
|
+
"textarea[name='h-captcha-response']",
|
|
280
|
+
"textarea[id*='h-captcha-response']",
|
|
281
|
+
"textarea[name*='hcaptcha-response']",
|
|
282
|
+
"textarea[id*='hcaptcha-response']",
|
|
283
|
+
"input[name='h-captcha-response']",
|
|
284
|
+
"input[name*='hcaptcha-response']",
|
|
285
|
+
];
|
|
286
|
+
for (const selector of selectors) {
|
|
287
|
+
const nodes = document.querySelectorAll(selector);
|
|
288
|
+
for (const node of Array.from(nodes)) {
|
|
289
|
+
node.value = info.token;
|
|
290
|
+
node.dispatchEvent(new Event('input', { bubbles: true }));
|
|
291
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else if (info.type === 'turnstile') {
|
|
296
|
+
const win = window;
|
|
297
|
+
const selectors = [
|
|
298
|
+
"input[name='cf-turnstile-response']",
|
|
299
|
+
"textarea[name='cf-turnstile-response']",
|
|
300
|
+
"[id*='turnstile-response']",
|
|
301
|
+
];
|
|
302
|
+
for (const selector of selectors) {
|
|
303
|
+
const nodes = document.querySelectorAll(selector);
|
|
304
|
+
for (const node of Array.from(nodes)) {
|
|
305
|
+
node.value = info.token;
|
|
306
|
+
node.dispatchEvent(new Event('input', { bubbles: true }));
|
|
307
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (info.variant === 'cloudflare-challenge' && info.callbackId) {
|
|
311
|
+
const callback = win.__agentbrowseTurnstileCallbacks?.[info.callbackId];
|
|
312
|
+
if (typeof callback === 'function') {
|
|
313
|
+
callback(info.token);
|
|
314
|
+
win.__agentbrowseTurnstileSolved = {
|
|
315
|
+
...(win.__agentbrowseTurnstileSolved ?? {}),
|
|
316
|
+
[info.callbackId]: info.token,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}, {
|
|
322
|
+
type: captcha.type,
|
|
323
|
+
token,
|
|
324
|
+
variant: captcha.variant,
|
|
325
|
+
callbackId: captcha.callbackId,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
function recaptchaAnchorFrames(page, siteKey) {
|
|
329
|
+
const all = page.frames().filter((frame) => frame.url().includes('/recaptcha/api2/anchor'));
|
|
330
|
+
const keyed = all.filter((frame) => {
|
|
331
|
+
try {
|
|
332
|
+
return new URL(frame.url()).searchParams.get('k') === siteKey;
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
return keyed.length > 0 ? keyed : all;
|
|
339
|
+
}
|
|
340
|
+
async function bestEffortClickCaptchaCheckbox(page, captcha, onProgress) {
|
|
341
|
+
if (captcha.type !== 'recaptcha-v2') {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
const topology = await inspectRecaptchaTopology(page, captcha.siteKey);
|
|
345
|
+
if (topology.anchorCount === 0) {
|
|
346
|
+
onProgress?.('[captcha] best-effort click skipped: no anchor frames found');
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
if (topology.hasMultipleSiteKeys || topology.anchorCount > 1) {
|
|
350
|
+
onProgress?.(`[captcha] recaptcha topology: ${topology.anchorCount} anchor(s), ${topology.hasMultipleSiteKeys ? 'multiple' : 'single'} siteKey(s) — proceeding with targeted click`);
|
|
351
|
+
}
|
|
352
|
+
if (await isVisibleRecaptchaChallenge(page, captcha.siteKey)) {
|
|
353
|
+
onProgress?.('[captcha] best-effort click skipped: recaptcha image challenge already visible');
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
const frames = recaptchaAnchorFrames(page, captcha.siteKey);
|
|
357
|
+
for (const frame of frames) {
|
|
358
|
+
try {
|
|
359
|
+
const state = await frame.evaluate(() => {
|
|
360
|
+
const anchor = document.querySelector('#recaptcha-anchor');
|
|
361
|
+
if (!anchor) {
|
|
362
|
+
return { present: false, checked: false };
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
present: true,
|
|
366
|
+
checked: anchor.getAttribute('aria-checked') === 'true',
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
if (!state.present || state.checked) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const anchor = await frame.$('#recaptcha-anchor');
|
|
373
|
+
if (!anchor) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
await anchor.click({ delay: 50 });
|
|
377
|
+
await anchor.dispose();
|
|
378
|
+
onProgress?.('[captcha] best-effort click: recaptcha checkbox');
|
|
379
|
+
await sleep(700);
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// Continue with other matching frames.
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
async function inspectRecaptchaTopology(page, siteKey) {
|
|
389
|
+
try {
|
|
390
|
+
return await page.evaluate((key) => {
|
|
391
|
+
const anchors = Array.from(document.querySelectorAll('iframe[src*="recaptcha/api2/anchor"]'));
|
|
392
|
+
const siteKeys = new Set();
|
|
393
|
+
let anchorCount = 0;
|
|
394
|
+
for (const anchor of anchors) {
|
|
395
|
+
try {
|
|
396
|
+
const k = new URL(anchor.src).searchParams.get('k');
|
|
397
|
+
if (k)
|
|
398
|
+
siteKeys.add(k);
|
|
399
|
+
if (k === key)
|
|
400
|
+
anchorCount += 1;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
// ignore malformed URL
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
anchorCount,
|
|
408
|
+
hasMultipleSiteKeys: siteKeys.size > 1,
|
|
409
|
+
};
|
|
410
|
+
}, siteKey);
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return { anchorCount: 0, hasMultipleSiteKeys: false };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function isVisibleRecaptchaChallenge(page, siteKey) {
|
|
417
|
+
try {
|
|
418
|
+
return await page.evaluate((key) => {
|
|
419
|
+
const frames = Array.from(document.querySelectorAll('iframe[src*="recaptcha/api2/bframe"]'));
|
|
420
|
+
for (const frame of frames) {
|
|
421
|
+
try {
|
|
422
|
+
const url = new URL(frame.src);
|
|
423
|
+
const k = url.searchParams.get('k');
|
|
424
|
+
if (k && k !== key)
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
// ignore malformed frame URL
|
|
429
|
+
}
|
|
430
|
+
const rect = frame.getBoundingClientRect();
|
|
431
|
+
if (rect.width <= 0 || rect.height <= 0) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const style = window.getComputedStyle(frame);
|
|
438
|
+
if (style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0') {
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return false;
|
|
443
|
+
}, siteKey);
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async function hasTokenInAnyFrame(page, selectors) {
|
|
450
|
+
for (const frame of page.frames()) {
|
|
451
|
+
try {
|
|
452
|
+
const hasToken = await frame.evaluate((selList) => {
|
|
453
|
+
for (const selector of selList) {
|
|
454
|
+
const nodes = document.querySelectorAll(selector);
|
|
455
|
+
for (const node of Array.from(nodes)) {
|
|
456
|
+
const input = node;
|
|
457
|
+
const direct = typeof input.value === 'string' ? input.value : '';
|
|
458
|
+
const value = (direct || node.textContent || '').trim();
|
|
459
|
+
if (value.length > 20) {
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return false;
|
|
465
|
+
}, selectors);
|
|
466
|
+
if (hasToken)
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
// Some frames are inaccessible/transient; ignore.
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
async function verifyRecaptchaResolved(page, siteKey) {
|
|
476
|
+
const frames = recaptchaAnchorFrames(page, siteKey);
|
|
477
|
+
if (frames.length > 0) {
|
|
478
|
+
let sawAnchor = false;
|
|
479
|
+
for (const frame of frames) {
|
|
480
|
+
try {
|
|
481
|
+
const checked = await frame.evaluate(() => {
|
|
482
|
+
const anchor = document.querySelector('#recaptcha-anchor');
|
|
483
|
+
if (!anchor)
|
|
484
|
+
return null;
|
|
485
|
+
return anchor.getAttribute('aria-checked') === 'true';
|
|
486
|
+
});
|
|
487
|
+
if (checked === null)
|
|
488
|
+
continue;
|
|
489
|
+
sawAnchor = true;
|
|
490
|
+
if (checked)
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
// Ignore transient frame access errors.
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// Token in response field is sufficient — injection + callback handles site-side verification;
|
|
499
|
+
// aria-checked is a visual indicator inside Google's iframe, not required for form submission.
|
|
500
|
+
return hasTokenInAnyFrame(page, [
|
|
501
|
+
'#g-recaptcha-response',
|
|
502
|
+
"textarea[name='g-recaptcha-response']",
|
|
503
|
+
"textarea[id*='g-recaptcha-response']",
|
|
504
|
+
"input[name='g-recaptcha-response']",
|
|
505
|
+
]);
|
|
506
|
+
}
|
|
507
|
+
async function verifyCaptchaResolved(page, captcha) {
|
|
508
|
+
switch (captcha.type) {
|
|
509
|
+
case 'recaptcha-v2':
|
|
510
|
+
return verifyRecaptchaResolved(page, captcha.siteKey);
|
|
511
|
+
case 'hcaptcha':
|
|
512
|
+
return hasTokenInAnyFrame(page, [
|
|
513
|
+
"textarea[name='h-captcha-response']",
|
|
514
|
+
"textarea[id*='h-captcha-response']",
|
|
515
|
+
"textarea[name*='hcaptcha-response']",
|
|
516
|
+
"textarea[id*='hcaptcha-response']",
|
|
517
|
+
"input[name='h-captcha-response']",
|
|
518
|
+
"input[name*='hcaptcha-response']",
|
|
519
|
+
]);
|
|
520
|
+
case 'turnstile':
|
|
521
|
+
if (captcha.variant === 'cloudflare-challenge') {
|
|
522
|
+
return verifyCloudflareChallengeCleared(page);
|
|
523
|
+
}
|
|
524
|
+
return hasTokenInAnyFrame(page, [
|
|
525
|
+
"input[name='cf-turnstile-response']",
|
|
526
|
+
"textarea[name='cf-turnstile-response']",
|
|
527
|
+
"[id*='turnstile-response']",
|
|
528
|
+
]);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
async function verifyCloudflareChallengeCleared(page) {
|
|
532
|
+
const deadline = Date.now() + 10_000;
|
|
533
|
+
while (Date.now() < deadline) {
|
|
534
|
+
try {
|
|
535
|
+
const stillChallenge = await page.evaluate(() => {
|
|
536
|
+
const hasChallengePlatformScript = document.querySelectorAll("script[src*='/cdn-cgi/challenge-platform/']").length > 0;
|
|
537
|
+
const hasChallengeField = Array.from(document.querySelectorAll("input[name='cf-turnstile-response'], textarea[name='cf-turnstile-response']")).some((node) => {
|
|
538
|
+
const id = node.getAttribute('id') ?? '';
|
|
539
|
+
return id.startsWith('cf-chl-widget');
|
|
540
|
+
});
|
|
541
|
+
const hasCfChlOpt = Boolean(window._cf_chl_opt &&
|
|
542
|
+
typeof window._cf_chl_opt === 'object');
|
|
543
|
+
const bodyText = document.body?.innerText ?? '';
|
|
544
|
+
return (hasChallengePlatformScript ||
|
|
545
|
+
hasChallengeField ||
|
|
546
|
+
hasCfChlOpt ||
|
|
547
|
+
document.title === 'Just a moment...' ||
|
|
548
|
+
bodyText.includes('Just a moment'));
|
|
549
|
+
});
|
|
550
|
+
if (!stillChallenge) {
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
// Page may be navigating between challenge and destination; retry briefly.
|
|
556
|
+
}
|
|
557
|
+
await sleep(500);
|
|
558
|
+
}
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
export function setupCaptchaMonitor(page, solver) {
|
|
562
|
+
let solving = false;
|
|
563
|
+
const checkAndSolve = async () => {
|
|
564
|
+
if (solving)
|
|
565
|
+
return;
|
|
566
|
+
solving = true;
|
|
567
|
+
try {
|
|
568
|
+
await solveVisibleCaptchasWithOptions(page, solver);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// Page may navigate/close during scan.
|
|
572
|
+
}
|
|
573
|
+
finally {
|
|
574
|
+
solving = false;
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
page.on('domcontentloaded', () => {
|
|
578
|
+
void checkAndSolve();
|
|
579
|
+
});
|
|
580
|
+
const interval = setInterval(() => {
|
|
581
|
+
if (page.isClosed()) {
|
|
582
|
+
clearInterval(interval);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
void checkAndSolve();
|
|
586
|
+
}, 3000);
|
|
587
|
+
page.once('close', () => clearInterval(interval));
|
|
588
|
+
}
|
|
589
|
+
function sleep(ms) {
|
|
590
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
591
|
+
}
|
|
592
|
+
function formatSolveError(error) {
|
|
593
|
+
if (error == null)
|
|
594
|
+
return 'unknown-error';
|
|
595
|
+
if (typeof error === 'string')
|
|
596
|
+
return error;
|
|
597
|
+
if (error instanceof Error) {
|
|
598
|
+
const withErr = error;
|
|
599
|
+
const details = [];
|
|
600
|
+
if (withErr.message)
|
|
601
|
+
details.push(withErr.message);
|
|
602
|
+
if (withErr.err)
|
|
603
|
+
details.push(String(withErr.err));
|
|
604
|
+
if (withErr.code != null)
|
|
605
|
+
details.push(`code=${String(withErr.code)}`);
|
|
606
|
+
return details.length > 0 ? details.join(' | ') : error.name;
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
return JSON.stringify(error);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return String(error);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function toSolverRequest(captcha) {
|
|
616
|
+
if (captcha.type !== 'turnstile' || captcha.variant !== 'cloudflare-challenge') {
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
return {
|
|
620
|
+
action: captcha.action,
|
|
621
|
+
data: captcha.cData,
|
|
622
|
+
pagedata: captcha.chlPageData,
|
|
623
|
+
userAgent: captcha.userAgent,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
async function applySolverUserAgent(page, userAgent) {
|
|
627
|
+
if (!userAgent || !userAgent.trim())
|
|
628
|
+
return;
|
|
629
|
+
const client = await page.createCDPSession();
|
|
630
|
+
try {
|
|
631
|
+
await client.send('Network.setUserAgentOverride', {
|
|
632
|
+
userAgent,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
finally {
|
|
636
|
+
await client.detach().catch(() => { });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ProxyConfig } from './types.js';
|
|
2
|
+
export type SolveCaptchasByCdpOptions = {
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
pollIntervalMs?: number;
|
|
5
|
+
solveTimeoutMs?: number;
|
|
6
|
+
connectTimeoutMs?: number;
|
|
7
|
+
apiUrl?: string;
|
|
8
|
+
proxy?: ProxyConfig;
|
|
9
|
+
sharedTransport?: boolean;
|
|
10
|
+
onProgress?: (message: string) => void;
|
|
11
|
+
};
|
|
12
|
+
export type SolveCaptchasByCdpResult = {
|
|
13
|
+
solved: number;
|
|
14
|
+
verified: number;
|
|
15
|
+
unresolved: number;
|
|
16
|
+
unresolvedCaptchas: string[];
|
|
17
|
+
detected: number;
|
|
18
|
+
timedOut: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare function solveCaptchasByCdp(cdpUrl: string, apiKey: string, opts?: SolveCaptchasByCdpOptions): Promise<SolveCaptchasByCdpResult>;
|
|
21
|
+
//# sourceMappingURL=captcha-runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"captcha-runtime.d.ts","sourceRoot":"","sources":["../src/captcha-runtime.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,MAAM,MAAM,yBAAyB,GAAG;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;CACnB,CAAC;AAMF,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,yBAAyB,GAC/B,OAAO,CAAC,wBAAwB,CAAC,CAgInC"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import puppeteer from 'puppeteer';
|
|
2
|
+
import { CaptchaSolver } from './captcha-solver.js';
|
|
3
|
+
import { detectCaptchas, solveVisibleCaptchasWithOptions } from './captcha-detector.js';
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 90000;
|
|
5
|
+
const DEFAULT_POLL_MS = 1500;
|
|
6
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
|
|
7
|
+
export async function solveCaptchasByCdp(cdpUrl, apiKey, opts) {
|
|
8
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
9
|
+
const pollIntervalMs = opts?.pollIntervalMs ?? DEFAULT_POLL_MS;
|
|
10
|
+
const connectTimeoutMs = opts?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
11
|
+
const deadline = Date.now() + timeoutMs;
|
|
12
|
+
const onProgress = opts?.onProgress;
|
|
13
|
+
const sharedTransport = opts?.sharedTransport ?? Boolean(opts?.proxy);
|
|
14
|
+
onProgress?.('[captcha] connecting to browser...');
|
|
15
|
+
const browser = await withTimeout(puppeteer.connect({
|
|
16
|
+
...buildConnectOptions(cdpUrl),
|
|
17
|
+
defaultViewport: null,
|
|
18
|
+
}), connectTimeoutMs, `Failed to connect to browser within ${Math.ceil(connectTimeoutMs / 1000)}s`);
|
|
19
|
+
try {
|
|
20
|
+
const solver = new CaptchaSolver({
|
|
21
|
+
apiKey,
|
|
22
|
+
apiUrl: opts?.apiUrl,
|
|
23
|
+
taskTimeoutMs: opts?.solveTimeoutMs,
|
|
24
|
+
proxy: opts?.proxy,
|
|
25
|
+
});
|
|
26
|
+
let solved = 0;
|
|
27
|
+
const detectedKeys = new Set();
|
|
28
|
+
const solvedKeys = new Set();
|
|
29
|
+
const verifiedKeys = new Set();
|
|
30
|
+
let sawCaptcha = false;
|
|
31
|
+
let lastWaitLogAt = 0;
|
|
32
|
+
onProgress?.(`[captcha] connected to browser, waiting for captcha up to ${Math.ceil(timeoutMs / 1000)}s`);
|
|
33
|
+
while (Date.now() < deadline) {
|
|
34
|
+
const pages = await getCandidatePages(browser);
|
|
35
|
+
if (pages.length === 0) {
|
|
36
|
+
if (Date.now() - lastWaitLogAt > 10000) {
|
|
37
|
+
onProgress?.('[captcha] waiting for active page...');
|
|
38
|
+
lastWaitLogAt = Date.now();
|
|
39
|
+
}
|
|
40
|
+
await sleep(pollIntervalMs);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
let foundAnyCaptcha = false;
|
|
44
|
+
for (const page of pages) {
|
|
45
|
+
const captchas = await detectCaptchas(page);
|
|
46
|
+
if (captchas.length === 0)
|
|
47
|
+
continue;
|
|
48
|
+
foundAnyCaptcha = true;
|
|
49
|
+
if (!sawCaptcha) {
|
|
50
|
+
onProgress?.(`[captcha] captcha detected on ${stripQuery(page.url()) || 'current tab'} (${captchas.length})`);
|
|
51
|
+
}
|
|
52
|
+
for (const captcha of captchas) {
|
|
53
|
+
const key = `${captcha.type}:${captcha.siteKey}:${captcha.pageUrl}`;
|
|
54
|
+
detectedKeys.add(key);
|
|
55
|
+
}
|
|
56
|
+
sawCaptcha = true;
|
|
57
|
+
const unsolvedBefore = captchas.filter((captcha) => !solvedKeys.has(captchaKey(captcha)));
|
|
58
|
+
if (unsolvedBefore.length === 0) {
|
|
59
|
+
return finalizeResult(solved, detectedKeys, verifiedKeys, false, onProgress);
|
|
60
|
+
}
|
|
61
|
+
const outcomes = await solveVisibleCaptchasWithOptions(page, solver, {
|
|
62
|
+
solveTimeoutMs: opts?.solveTimeoutMs,
|
|
63
|
+
onProgress,
|
|
64
|
+
sharedTransport,
|
|
65
|
+
skipCaptcha: (captcha) => solvedKeys.has(captchaKey(captcha)),
|
|
66
|
+
});
|
|
67
|
+
const hasMissingParamsChallenge = outcomes.some((outcome) => outcome.error === 'challenge-params-missing');
|
|
68
|
+
const hasSharedTransportRequiredChallenge = outcomes.some((outcome) => outcome.error === 'challenge-shared-transport-required');
|
|
69
|
+
for (const outcome of outcomes) {
|
|
70
|
+
const key = captchaKey(outcome.captcha);
|
|
71
|
+
if (outcome.injected || outcome.verified) {
|
|
72
|
+
solved += 1;
|
|
73
|
+
solvedKeys.add(key);
|
|
74
|
+
}
|
|
75
|
+
if (outcome.verified) {
|
|
76
|
+
verifiedKeys.add(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const unsolvedAfter = unsolvedBefore.filter((captcha) => !solvedKeys.has(captchaKey(captcha)));
|
|
80
|
+
if (hasMissingParamsChallenge) {
|
|
81
|
+
return finalizeResult(solved, detectedKeys, verifiedKeys, false, onProgress);
|
|
82
|
+
}
|
|
83
|
+
if (hasSharedTransportRequiredChallenge) {
|
|
84
|
+
return finalizeResult(solved, detectedKeys, verifiedKeys, false, onProgress);
|
|
85
|
+
}
|
|
86
|
+
if (unsolvedAfter.length === 0) {
|
|
87
|
+
return finalizeResult(solved, detectedKeys, verifiedKeys, false, onProgress);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (foundAnyCaptcha) {
|
|
91
|
+
await sleep(1000);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (sawCaptcha) {
|
|
95
|
+
return finalizeResult(solved, detectedKeys, verifiedKeys, false, onProgress);
|
|
96
|
+
}
|
|
97
|
+
if (Date.now() - lastWaitLogAt > 10000) {
|
|
98
|
+
onProgress?.('[captcha] waiting for captcha...');
|
|
99
|
+
lastWaitLogAt = Date.now();
|
|
100
|
+
}
|
|
101
|
+
await sleep(pollIntervalMs);
|
|
102
|
+
}
|
|
103
|
+
onProgress?.('[captcha] timeout reached');
|
|
104
|
+
return finalizeResult(solved, detectedKeys, verifiedKeys, true, onProgress);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await browser.disconnect();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function finalizeResult(solved, detectedKeys, verifiedKeys, timedOut, onProgress) {
|
|
111
|
+
const detected = detectedKeys.size;
|
|
112
|
+
const unresolvedCaptchas = Array.from(detectedKeys).filter((key) => !verifiedKeys.has(key));
|
|
113
|
+
const verified = verifiedKeys.size;
|
|
114
|
+
const unresolved = unresolvedCaptchas.length;
|
|
115
|
+
onProgress?.(`[captcha] done: solved ${solved}, verified ${verified}, unresolved ${unresolved}, detected ${detected}`);
|
|
116
|
+
return {
|
|
117
|
+
solved,
|
|
118
|
+
verified,
|
|
119
|
+
unresolved,
|
|
120
|
+
unresolvedCaptchas,
|
|
121
|
+
detected,
|
|
122
|
+
timedOut,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function captchaKey(captcha) {
|
|
126
|
+
return `${captcha.type}:${captcha.siteKey}:${captcha.pageUrl}`;
|
|
127
|
+
}
|
|
128
|
+
async function getCandidatePages(browser) {
|
|
129
|
+
const pages = await browser.pages();
|
|
130
|
+
return pages.filter((page) => !page.url().startsWith('devtools://'));
|
|
131
|
+
}
|
|
132
|
+
function buildConnectOptions(cdpUrl) {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = new URL(cdpUrl);
|
|
135
|
+
if (parsed.protocol === 'ws:' || parsed.protocol === 'wss:') {
|
|
136
|
+
return {
|
|
137
|
+
browserURL: `${parsed.protocol === 'wss:' ? 'https:' : 'http:'}//${parsed.host}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
|
141
|
+
return { browserURL: `${parsed.protocol}//${parsed.host}` };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Fallback to raw WS endpoint below.
|
|
146
|
+
}
|
|
147
|
+
return { browserWSEndpoint: cdpUrl };
|
|
148
|
+
}
|
|
149
|
+
async function withTimeout(promise, timeoutMs, message) {
|
|
150
|
+
const timeoutPromise = sleep(timeoutMs).then(() => {
|
|
151
|
+
throw new Error(message);
|
|
152
|
+
});
|
|
153
|
+
return Promise.race([promise, timeoutPromise]);
|
|
154
|
+
}
|
|
155
|
+
function stripQuery(url) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = new URL(url);
|
|
158
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return url;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function sleep(ms) {
|
|
165
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
166
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CaptchaType, ProxyConfig } from './types.js';
|
|
2
|
+
export type CaptchaSolverConfig = {
|
|
3
|
+
apiKey: string;
|
|
4
|
+
apiUrl?: string;
|
|
5
|
+
requestTimeoutMs?: number;
|
|
6
|
+
taskPollIntervalMs?: number;
|
|
7
|
+
taskTimeoutMs?: number;
|
|
8
|
+
proxy?: ProxyConfig;
|
|
9
|
+
};
|
|
10
|
+
export type CaptchaSolveRequest = {
|
|
11
|
+
action?: string;
|
|
12
|
+
data?: string;
|
|
13
|
+
pagedata?: string;
|
|
14
|
+
userAgent?: string;
|
|
15
|
+
proxy?: ProxyConfig;
|
|
16
|
+
};
|
|
17
|
+
export type CaptchaSolveResult = {
|
|
18
|
+
token: string;
|
|
19
|
+
userAgent?: string;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Captcha gateway client used by higher-level orchestration layers.
|
|
23
|
+
*/
|
|
24
|
+
export declare class CaptchaSolver {
|
|
25
|
+
private readonly apiKey;
|
|
26
|
+
private readonly apiUrl;
|
|
27
|
+
private readonly requestTimeoutMs;
|
|
28
|
+
private readonly taskPollIntervalMs;
|
|
29
|
+
private readonly taskTimeoutMs;
|
|
30
|
+
private readonly proxy?;
|
|
31
|
+
constructor(config: CaptchaSolverConfig);
|
|
32
|
+
solve(type: CaptchaType, siteKey: string, pageUrl: string, request?: CaptchaSolveRequest): Promise<CaptchaSolveResult>;
|
|
33
|
+
private createTask;
|
|
34
|
+
private getTaskStatus;
|
|
35
|
+
private request;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=captcha-solver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"captcha-solver.d.ts","sourceRoot":"","sources":["../src/captcha-solver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE3D,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB,CAAC;AAiBF,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAMF;;GAEG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAS;IAC5C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAc;gBAEzB,MAAM,EAAE,mBAAmB;IAgBjC,KAAK,CACT,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,kBAAkB,CAAC;YAqBhB,UAAU;YAkBV,aAAa;YAOb,OAAO;CAuCtB"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
2
|
+
const DEFAULT_TASK_POLL_MS = 5_000;
|
|
3
|
+
const DEFAULT_TASK_TIMEOUT_MS = 120_000;
|
|
4
|
+
/**
|
|
5
|
+
* Captcha gateway client used by higher-level orchestration layers.
|
|
6
|
+
*/
|
|
7
|
+
export class CaptchaSolver {
|
|
8
|
+
apiKey;
|
|
9
|
+
apiUrl;
|
|
10
|
+
requestTimeoutMs;
|
|
11
|
+
taskPollIntervalMs;
|
|
12
|
+
taskTimeoutMs;
|
|
13
|
+
proxy;
|
|
14
|
+
constructor(config) {
|
|
15
|
+
this.apiKey = config.apiKey;
|
|
16
|
+
this.apiUrl = (config.apiUrl || '').replace(/\/$/, '');
|
|
17
|
+
this.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
18
|
+
this.taskPollIntervalMs = config.taskPollIntervalMs ?? DEFAULT_TASK_POLL_MS;
|
|
19
|
+
this.taskTimeoutMs = config.taskTimeoutMs ?? DEFAULT_TASK_TIMEOUT_MS;
|
|
20
|
+
this.proxy = config.proxy;
|
|
21
|
+
if (!this.apiKey || this.apiKey.trim().length === 0) {
|
|
22
|
+
throw new Error('CaptchaSolver: apiKey is required');
|
|
23
|
+
}
|
|
24
|
+
if (!this.apiUrl || this.apiUrl.trim().length === 0) {
|
|
25
|
+
throw new Error('CaptchaSolver: apiUrl is required');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async solve(type, siteKey, pageUrl, request) {
|
|
29
|
+
const task = await this.createTask(type, siteKey, pageUrl, request);
|
|
30
|
+
const deadline = Date.now() + this.taskTimeoutMs;
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
await sleep(this.taskPollIntervalMs);
|
|
33
|
+
const status = await this.getTaskStatus(task.task_id);
|
|
34
|
+
if (status.status === 'ready' && status.token) {
|
|
35
|
+
return {
|
|
36
|
+
token: status.token,
|
|
37
|
+
userAgent: status.user_agent,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (status.status === 'failed') {
|
|
41
|
+
throw new Error(`CAPTCHA solving failed: ${status.error || 'provider error'}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`CAPTCHA solving timed out after ${Math.ceil(this.taskTimeoutMs / 1000)}s`);
|
|
45
|
+
}
|
|
46
|
+
async createTask(type, siteKey, pageUrl, request) {
|
|
47
|
+
return this.request('POST', '/tools/captcha/tasks', {
|
|
48
|
+
type,
|
|
49
|
+
site_key: siteKey,
|
|
50
|
+
page_url: pageUrl,
|
|
51
|
+
action: request?.action,
|
|
52
|
+
data: request?.data,
|
|
53
|
+
pagedata: request?.pagedata,
|
|
54
|
+
user_agent: request?.userAgent,
|
|
55
|
+
proxy: request?.proxy ?? this.proxy,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async getTaskStatus(taskId) {
|
|
59
|
+
return this.request('GET', `/tools/captcha/tasks/${encodeURIComponent(taskId)}`);
|
|
60
|
+
}
|
|
61
|
+
async request(method, path, body) {
|
|
62
|
+
const url = `${this.apiUrl}${path}`;
|
|
63
|
+
const headers = {
|
|
64
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
65
|
+
Accept: 'application/json',
|
|
66
|
+
};
|
|
67
|
+
if (body !== undefined) {
|
|
68
|
+
headers['Content-Type'] = 'application/json';
|
|
69
|
+
}
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
const requestInit = {
|
|
73
|
+
method,
|
|
74
|
+
headers,
|
|
75
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
76
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
77
|
+
};
|
|
78
|
+
response = await fetch(url, requestInit);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
throw new Error(`CAPTCHA gateway network error: ${err instanceof Error ? err.message : String(err)}`);
|
|
82
|
+
}
|
|
83
|
+
let json;
|
|
84
|
+
try {
|
|
85
|
+
json = (await response.json());
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error(`CAPTCHA gateway returned non-JSON response (${response.status})`);
|
|
89
|
+
}
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
const message = typeof json.error === 'string' ? json.error : `HTTP ${response.status}`;
|
|
92
|
+
throw new Error(`CAPTCHA gateway error (${response.status}): ${message}`);
|
|
93
|
+
}
|
|
94
|
+
return json;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function sleep(ms) {
|
|
98
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
99
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC"}
|
package/dist/index.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type ProxyConfig = {
|
|
2
|
+
server: string;
|
|
3
|
+
username?: string;
|
|
4
|
+
password?: string;
|
|
5
|
+
};
|
|
6
|
+
export type CaptchaType = 'recaptcha-v2' | 'hcaptcha' | 'turnstile';
|
|
7
|
+
export type TurnstileVariant = 'standalone' | 'cloudflare-challenge';
|
|
8
|
+
export type TurnstileCaptureSource = 'render-hook' | 'api-shim' | 'dom-fallback';
|
|
9
|
+
export type DetectedCaptcha = {
|
|
10
|
+
type: CaptchaType;
|
|
11
|
+
siteKey: string;
|
|
12
|
+
pageUrl: string;
|
|
13
|
+
variant?: TurnstileVariant;
|
|
14
|
+
captureSource?: TurnstileCaptureSource;
|
|
15
|
+
challengeReady?: boolean;
|
|
16
|
+
action?: string;
|
|
17
|
+
cData?: string;
|
|
18
|
+
chlPageData?: string;
|
|
19
|
+
callbackId?: string;
|
|
20
|
+
userAgent?: string;
|
|
21
|
+
};
|
|
22
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,cAAc,GAAG,UAAU,GAAG,WAAW,CAAC;AAEpE,MAAM,MAAM,gBAAgB,GAAG,YAAY,GAAG,sBAAsB,CAAC;AACrE,MAAM,MAAM,sBAAsB,GAAG,aAAa,GAAG,UAAU,GAAG,cAAc,CAAC;AAEjF,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mercuryo-ai/captcha-solver",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Captcha-solving runtime primitives for MagicPay and agent workflows",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/nuanu-ai/mercuryo-agent-pay.git",
|
|
18
|
+
"directory": "packages/captcha-solver"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/nuanu-ai/mercuryo-agent-pay/issues"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/nuanu-ai/mercuryo-agent-pay/tree/main/packages/captcha-solver",
|
|
24
|
+
"keywords": [
|
|
25
|
+
"captcha",
|
|
26
|
+
"solver",
|
|
27
|
+
"browser-automation",
|
|
28
|
+
"turnstile",
|
|
29
|
+
"hcaptcha",
|
|
30
|
+
"recaptcha"
|
|
31
|
+
],
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"puppeteer": "^23.11.1"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"typescript": "5.9.2",
|
|
48
|
+
"vitest": "^4.0.18"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.build.json",
|
|
52
|
+
"check-types": "tsc --noEmit",
|
|
53
|
+
"pack:verify": "node scripts/verify-pack-artifact.mjs",
|
|
54
|
+
"smoke:pack-install": "node scripts/verify-pack-install-smoke.mjs",
|
|
55
|
+
"test": "vitest run"
|
|
56
|
+
}
|
|
57
|
+
}
|