html-renderer-api 1.0.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,479 @@
1
+ /**
2
+ * @file scraper-captcha.ts
3
+ * @description CAPTCHA and Cloudflare challenge detection and bypass utilities.
4
+ * Provides functions to detect challenge pages, simulate human interaction,
5
+ * click reCAPTCHA checkboxes, poll for solved state, and submit unsolvable
6
+ * challenges to the 2captcha third-party service for automated resolution.
7
+ */
8
+
9
+ import type { Page, Frame } from "@cloudflare/puppeteer";
10
+
11
+ /** 2captcha JSON response shape for both submit and result endpoints. */
12
+ interface TwoCaptchaResponse {
13
+ status: number;
14
+ request: string;
15
+ }
16
+
17
+ /**
18
+ * Checks whether the given HTML content represents a bot-challenge page.
19
+ * Tests against a custom match string first, then a list of known Cloudflare
20
+ * and reCAPTCHA patterns.
21
+ *
22
+ * @param html - Raw HTML string of the page to inspect.
23
+ * @param customMatch - Optional caller-supplied substring to detect challenges.
24
+ * @param challengePatterns - Array of known challenge indicator strings.
25
+ * @returns `true` if the page appears to be a challenge/CAPTCHA page.
26
+ */
27
+ export function isChallengePage(
28
+ html: string,
29
+ customMatch: string | undefined,
30
+ challengePatterns: string[],
31
+ ): boolean {
32
+ if (customMatch && html.includes(customMatch)) return true;
33
+
34
+ for (const pattern of challengePatterns) {
35
+ if (html.includes(pattern)) return true;
36
+ }
37
+
38
+ if (html.includes("/cdn-cgi/challenge-platform/")) return true;
39
+ if (html.includes("challenges.cloudflare.com")) return true;
40
+
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * Moves the mouse through several random points and performs a small scroll
46
+ * to mimic natural human interaction while waiting for a challenge to resolve.
47
+ *
48
+ * @param page - Puppeteer {@link Page} instance to interact with.
49
+ */
50
+ export async function simulateHumanBehavior(page: Page): Promise<void> {
51
+ try {
52
+ const viewport = page.viewport();
53
+ const width = viewport?.width ?? 1920;
54
+ const height = viewport?.height ?? 1080;
55
+
56
+ const numPoints = 3 + Math.floor(Math.random() * 3);
57
+ const points: Array<{ x: number; y: number }> = [];
58
+
59
+ for (let i = 0; i < numPoints; i++) {
60
+ points.push({
61
+ x: Math.floor(Math.random() * width * 0.8) + width * 0.1,
62
+ y: Math.floor(Math.random() * height * 0.8) + height * 0.1,
63
+ });
64
+ }
65
+
66
+ for (const point of points) {
67
+ await page.mouse.move(point.x, point.y, {
68
+ steps: 10 + Math.floor(Math.random() * 15),
69
+ });
70
+ await new Promise<void>((resolve) =>
71
+ setTimeout(resolve, 50 + Math.random() * 150),
72
+ );
73
+ }
74
+
75
+ await page.evaluate(() => {
76
+ window.scrollBy(0, Math.floor(Math.random() * 100) - 50);
77
+ });
78
+ } catch (e) {
79
+ console.log(
80
+ "Error simulating human behavior:",
81
+ (e as Error).message,
82
+ );
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Searches all page frames for a reCAPTCHA checkbox or anchor and clicks it
88
+ * with human-like mouse movement. Falls back to page-level reCAPTCHA
89
+ * element selectors when no matching frame is found.
90
+ *
91
+ * @param page - Puppeteer {@link Page} instance to search.
92
+ * @returns `true` if a reCAPTCHA element was located and clicked.
93
+ */
94
+ export async function tryClickRecaptcha(page: Page): Promise<boolean> {
95
+ try {
96
+ const frames: Frame[] = page.frames();
97
+
98
+ for (const frame of frames) {
99
+ const frameUrl = frame.url();
100
+ if (
101
+ !frameUrl.includes("recaptcha") &&
102
+ !frameUrl.includes("google.com/recaptcha")
103
+ )
104
+ continue;
105
+
106
+ try {
107
+ const checkbox = await frame.$(".recaptcha-checkbox-border");
108
+ if (checkbox) {
109
+ const box = await checkbox.boundingBox();
110
+ if (box) {
111
+ await page.mouse.move(
112
+ box.x + box.width / 2 + (Math.random() * 10 - 5),
113
+ box.y + box.height / 2 + (Math.random() * 10 - 5),
114
+ { steps: 20 + Math.floor(Math.random() * 10) },
115
+ );
116
+ await new Promise<void>((r) =>
117
+ setTimeout(r, 100 + Math.random() * 200),
118
+ );
119
+ await checkbox.click();
120
+ console.log("Clicked reCAPTCHA checkbox");
121
+ return true;
122
+ }
123
+ }
124
+
125
+ const anchor = await frame.$("#recaptcha-anchor");
126
+ if (anchor) {
127
+ const box = await anchor.boundingBox();
128
+ if (box) {
129
+ await page.mouse.move(
130
+ box.x + box.width / 2 + (Math.random() * 10 - 5),
131
+ box.y + box.height / 2 + (Math.random() * 10 - 5),
132
+ { steps: 20 + Math.floor(Math.random() * 10) },
133
+ );
134
+ await new Promise<void>((r) =>
135
+ setTimeout(r, 100 + Math.random() * 200),
136
+ );
137
+ await anchor.click();
138
+ console.log("Clicked reCAPTCHA anchor");
139
+ return true;
140
+ }
141
+ }
142
+ } catch (frameError) {
143
+ console.log(
144
+ "Error interacting with reCAPTCHA frame:",
145
+ (frameError as Error).message,
146
+ );
147
+ }
148
+ }
149
+
150
+ const recaptchaSelectors = [
151
+ ".g-recaptcha",
152
+ "#g-recaptcha",
153
+ "[data-sitekey]",
154
+ ".recaptcha-checkbox",
155
+ 'iframe[src*="recaptcha"]',
156
+ ];
157
+
158
+ for (const selector of recaptchaSelectors) {
159
+ try {
160
+ const element = await page.$(selector);
161
+ if (element) {
162
+ const box = await element.boundingBox();
163
+ if (box) {
164
+ await page.mouse.move(
165
+ box.x + box.width / 2,
166
+ box.y + box.height / 2,
167
+ { steps: 15 },
168
+ );
169
+ await new Promise<void>((r) =>
170
+ setTimeout(r, 100 + Math.random() * 200),
171
+ );
172
+ await element.click();
173
+ console.log(`Clicked reCAPTCHA element: ${selector}`);
174
+ return true;
175
+ }
176
+ }
177
+ } catch {
178
+ // Try the next selector.
179
+ }
180
+ }
181
+
182
+ return false;
183
+ } catch (e) {
184
+ console.log("Error trying to click reCAPTCHA:", (e as Error).message);
185
+ return false;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Polls the page (and all reCAPTCHA frames) until the hidden response token
191
+ * field is populated, indicating the challenge has been solved.
192
+ *
193
+ * @param page - Puppeteer {@link Page} instance to poll.
194
+ * @param timeout - Maximum polling duration in milliseconds (default 30 000).
195
+ * @returns `true` if the challenge was solved within `timeout`.
196
+ */
197
+ export async function waitForRecaptchaSolved(
198
+ page: Page,
199
+ timeout = 30_000,
200
+ ): Promise<boolean> {
201
+ const startTime = Date.now();
202
+
203
+ while (Date.now() - startTime < timeout) {
204
+ try {
205
+ const solved = await page.evaluate(() => {
206
+ const response =
207
+ document.querySelector<HTMLInputElement>("#g-recaptcha-response") ??
208
+ document.querySelector<HTMLInputElement>(
209
+ '[name="g-recaptcha-response"]',
210
+ );
211
+ return Boolean(response?.value?.length);
212
+ });
213
+
214
+ if (solved) {
215
+ console.log("reCAPTCHA solved!");
216
+ return true;
217
+ }
218
+
219
+ const frames: Frame[] = page.frames();
220
+ for (const frame of frames) {
221
+ if (!frame.url().includes("recaptcha")) continue;
222
+ try {
223
+ const frameSolved = await frame.evaluate(() => {
224
+ const checkbox = document.querySelector(".recaptcha-checkbox");
225
+ return checkbox?.getAttribute("aria-checked") === "true";
226
+ });
227
+ if (frameSolved) {
228
+ console.log("reCAPTCHA checkbox marked as solved");
229
+ return true;
230
+ }
231
+ } catch {
232
+ /* ignore per-frame errors */
233
+ }
234
+ }
235
+ } catch {
236
+ /* ignore evaluation errors; keep polling */
237
+ }
238
+
239
+ await new Promise<void>((r) => setTimeout(r, 500));
240
+ }
241
+
242
+ return false;
243
+ }
244
+
245
+ /**
246
+ * Extracts the reCAPTCHA sitekey from the page, submits it to the 2captcha
247
+ * service, polls until a token is returned, then injects the token into the
248
+ * page's hidden response fields and fires any registered callbacks.
249
+ *
250
+ * @param page - Puppeteer {@link Page} instance containing the reCAPTCHA.
251
+ * @param apiKey - 2captcha API key.
252
+ * @returns `true` if the token was successfully obtained and injected.
253
+ */
254
+ export async function solveRecaptchaWith2Captcha(
255
+ page: Page,
256
+ apiKey: string,
257
+ ): Promise<boolean> {
258
+ if (!apiKey) {
259
+ console.log("No 2captcha API key provided");
260
+ return false;
261
+ }
262
+
263
+ try {
264
+ const sitekey = await page.evaluate((): string | null => {
265
+ const recaptchaDiv = document.querySelector<HTMLElement>(".g-recaptcha");
266
+ if (recaptchaDiv) return recaptchaDiv.getAttribute("data-sitekey");
267
+
268
+ for (const script of Array.from(document.querySelectorAll("script"))) {
269
+ const match = script.textContent?.match(
270
+ /['"]sitekey['"]\s*:\s*['"]([^'"]+)['"]/,
271
+ );
272
+ if (match) return match[1];
273
+ }
274
+
275
+ const iframe = document.querySelector<HTMLIFrameElement>(
276
+ 'iframe[src*="recaptcha"]',
277
+ );
278
+ if (iframe) {
279
+ const match = (iframe.getAttribute("src") ?? "").match(/[?&]k=([^&]+)/);
280
+ if (match) return match[1];
281
+ }
282
+
283
+ return null;
284
+ });
285
+
286
+ if (!sitekey) {
287
+ console.log("Could not find reCAPTCHA sitekey");
288
+ return false;
289
+ }
290
+
291
+ const pageUrl = page.url();
292
+ console.log(`Found sitekey: ${sitekey}, requesting solution from 2captcha...`);
293
+
294
+ const submitUrl = `https://2captcha.com/in.php?key=${apiKey}&method=userrecaptcha&googlekey=${sitekey}&pageurl=${encodeURIComponent(pageUrl)}&json=1`;
295
+ const submitResult: TwoCaptchaResponse = await fetch(submitUrl).then((r) =>
296
+ r.json(),
297
+ );
298
+
299
+ if (submitResult.status !== 1) {
300
+ console.log("2captcha submit failed:", submitResult);
301
+ return false;
302
+ }
303
+
304
+ const captchaId = submitResult.request;
305
+ console.log(`Captcha submitted, ID: ${captchaId}, waiting for solution...`);
306
+
307
+ const maxAttempts = 60;
308
+ for (let attempts = 0; attempts < maxAttempts; attempts++) {
309
+ await new Promise<void>((r) => setTimeout(r, 5_000));
310
+
311
+ const resultUrl = `https://2captcha.com/res.php?key=${apiKey}&action=get&id=${captchaId}&json=1`;
312
+ const result: TwoCaptchaResponse = await fetch(resultUrl).then((r) =>
313
+ r.json(),
314
+ );
315
+
316
+ if (result.status === 1) {
317
+ const token = result.request;
318
+ console.log("Got reCAPTCHA token from 2captcha");
319
+
320
+ await page.evaluate((t: string) => {
321
+ const responseField =
322
+ document.querySelector<HTMLTextAreaElement>(
323
+ "#g-recaptcha-response",
324
+ ) ??
325
+ document.querySelector<HTMLTextAreaElement>(
326
+ '[name="g-recaptcha-response"]',
327
+ );
328
+ if (responseField) {
329
+ responseField.value = t;
330
+ responseField.style.display = "block";
331
+ }
332
+
333
+ document
334
+ .querySelectorAll<HTMLTextAreaElement>(
335
+ 'textarea[name="g-recaptcha-response"]',
336
+ )
337
+ .forEach((ta) => {
338
+ ta.value = t;
339
+ ta.innerHTML = t;
340
+ });
341
+
342
+ const w = window as unknown as Record<string, unknown>;
343
+ if (typeof w.captchaCallback === "function")
344
+ (w.captchaCallback as (t: string) => void)(t);
345
+ if (typeof w.onCaptchaSuccess === "function")
346
+ (w.onCaptchaSuccess as (t: string) => void)(t);
347
+
348
+ try {
349
+ type GrecaptchaClient = {
350
+ U?: { callback?: (t: string) => void };
351
+ };
352
+ const cfg = (window as unknown as Record<string, unknown>)
353
+ .___grecaptcha_cfg as
354
+ | { clients?: Record<string, GrecaptchaClient> }
355
+ | undefined;
356
+ const callback = cfg?.clients?.[0]?.U?.callback;
357
+ if (typeof callback === "function") callback(t);
358
+ } catch (_e) {}
359
+ }, token);
360
+
361
+ return true;
362
+ }
363
+
364
+ if (result.request === "CAPCHA_NOT_READY") {
365
+ console.log(`Captcha not ready, attempt ${attempts + 1}/${maxAttempts}`);
366
+ continue;
367
+ }
368
+
369
+ console.log("2captcha error:", result);
370
+ return false;
371
+ }
372
+
373
+ console.log("2captcha timeout");
374
+ return false;
375
+ } catch (error) {
376
+ console.error("Error solving reCAPTCHA with 2captcha:", error);
377
+ return false;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Extracts the Cloudflare Turnstile sitekey from the page, submits it to the
383
+ * 2captcha service, polls for a token, then injects the token into the
384
+ * Turnstile response field and fires any registered callback.
385
+ *
386
+ * @param page - Puppeteer {@link Page} instance containing the Turnstile widget.
387
+ * @param apiKey - 2captcha API key.
388
+ * @returns `true` if the token was successfully obtained and injected.
389
+ */
390
+ export async function solveTurnstileWith2Captcha(
391
+ page: Page,
392
+ apiKey: string,
393
+ ): Promise<boolean> {
394
+ if (!apiKey) {
395
+ console.log("No 2captcha API key provided");
396
+ return false;
397
+ }
398
+
399
+ try {
400
+ const sitekey = await page.evaluate((): string | null => {
401
+ const turnstileDiv =
402
+ document.querySelector<HTMLElement>(".cf-turnstile");
403
+ if (turnstileDiv) return turnstileDiv.getAttribute("data-sitekey");
404
+
405
+ const iframe = document.querySelector<HTMLIFrameElement>(
406
+ 'iframe[src*="challenges.cloudflare.com"]',
407
+ );
408
+ if (iframe) {
409
+ const match = (iframe.getAttribute("src") ?? "").match(/[?&]k=([^&]+)/);
410
+ if (match) return match[1];
411
+ }
412
+
413
+ return null;
414
+ });
415
+
416
+ if (!sitekey) {
417
+ console.log("Could not find Turnstile sitekey");
418
+ return false;
419
+ }
420
+
421
+ const pageUrl = page.url();
422
+ console.log(`Found Turnstile sitekey: ${sitekey}`);
423
+
424
+ const submitUrl = `https://2captcha.com/in.php?key=${apiKey}&method=turnstile&sitekey=${sitekey}&pageurl=${encodeURIComponent(pageUrl)}&json=1`;
425
+ const submitResult: TwoCaptchaResponse = await fetch(submitUrl).then((r) =>
426
+ r.json(),
427
+ );
428
+
429
+ if (submitResult.status !== 1) {
430
+ console.log("2captcha Turnstile submit failed:", submitResult);
431
+ return false;
432
+ }
433
+
434
+ const captchaId = submitResult.request;
435
+ console.log(`Turnstile submitted, ID: ${captchaId}`);
436
+
437
+ const maxAttempts = 60;
438
+ for (let attempts = 0; attempts < maxAttempts; attempts++) {
439
+ await new Promise<void>((r) => setTimeout(r, 5_000));
440
+
441
+ const resultUrl = `https://2captcha.com/res.php?key=${apiKey}&action=get&id=${captchaId}&json=1`;
442
+ const result: TwoCaptchaResponse = await fetch(resultUrl).then((r) =>
443
+ r.json(),
444
+ );
445
+
446
+ if (result.status === 1) {
447
+ const token = result.request;
448
+ console.log("Got Turnstile token from 2captcha");
449
+
450
+ await page.evaluate((t: string) => {
451
+ const responseField =
452
+ document.querySelector<HTMLInputElement>(
453
+ '[name="cf-turnstile-response"]',
454
+ ) ??
455
+ document.querySelector<HTMLInputElement>(
456
+ 'input[name="cf-turnstile-response"]',
457
+ );
458
+ if (responseField) responseField.value = t;
459
+
460
+ const w = window as unknown as Record<string, unknown>;
461
+ if (typeof w.turnstileCallback === "function")
462
+ (w.turnstileCallback as (t: string) => void)(t);
463
+ }, token);
464
+
465
+ return true;
466
+ }
467
+
468
+ if (result.request !== "CAPCHA_NOT_READY") {
469
+ console.log("2captcha Turnstile error:", result);
470
+ return false;
471
+ }
472
+ }
473
+
474
+ return false;
475
+ } catch (error) {
476
+ console.error("Error solving Turnstile with 2captcha:", error);
477
+ return false;
478
+ }
479
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @file scraper-cloudflare.ts
3
+ * @description Cloudflare Worker entry point for the Puppeteer rendering API.
4
+ * Routes incoming requests to the Swagger UI, the OpenAPI JSON spec, or the
5
+ * {@link BrowserDurableObject} that performs the actual page rendering.
6
+ * Authentication is enforced here before any request is forwarded.
7
+ *
8
+ * Re-exports {@link BrowserDurableObject} so Cloudflare Wrangler can bind it
9
+ * as a Durable Object class in `wrangler.toml`.
10
+ */
11
+
12
+ import { parseRequestParams, authenticateRequest } from "./scraper-utils.js";
13
+ import type { Env } from "./scraper-utils.js";
14
+ import { serveSwagger, serveOpenAPI } from "./scraper-openapi.js";
15
+ import { handleLogin, handleFetchAsSession } from "./scraper-login.js";
16
+ export { BrowserDurableObject } from "./browser-durable-object.js";
17
+
18
+ /** Cloudflare Worker `fetch` handler with auth + routing logic. */
19
+ export default {
20
+ /**
21
+ * Handles all inbound HTTP requests.
22
+ *
23
+ * Routes:
24
+ * - `GET /api/swagger` or `/swagger` → Swagger UI
25
+ * - `GET /api/openapi.json` → OpenAPI JSON spec
26
+ * - `GET|POST /api/render` or `/` → Puppeteer rendering via Durable Object
27
+ *
28
+ * @param request - Incoming Fetch request.
29
+ * @param env - Cloudflare Worker environment bindings.
30
+ * @returns HTTP response appropriate to the matched route.
31
+ */
32
+ async fetch(request: Request, env: Env): Promise<Response> {
33
+ const url = new URL(request.url);
34
+
35
+ if (
36
+ url.pathname === "/api/swagger" ||
37
+ url.pathname === "/swagger"
38
+ ) {
39
+ return serveSwagger();
40
+ }
41
+
42
+ if (url.pathname === "/api/openapi.json") {
43
+ return serveOpenAPI();
44
+ }
45
+
46
+ if (url.pathname === "/api/login" && request.method === "POST") {
47
+ const authResult = await authenticateRequest(request, env, await parseRequestParams(request.clone()));
48
+ if (!authResult.success) {
49
+ return new Response(authResult.error, { status: 401 });
50
+ }
51
+ return handleLogin(request, env);
52
+ }
53
+
54
+ if (url.pathname === "/api/fetch" && request.method === "POST") {
55
+ const authResult = await authenticateRequest(request, env, await parseRequestParams(request.clone()));
56
+ if (!authResult.success) {
57
+ return new Response(authResult.error, { status: 401 });
58
+ }
59
+ return handleFetchAsSession(request, env);
60
+ }
61
+
62
+ if (url.pathname !== "/api/render" && url.pathname !== "/") {
63
+ return new Response("Not Found", { status: 404 });
64
+ }
65
+
66
+ const params = await parseRequestParams(request);
67
+
68
+ const authResult = await authenticateRequest(request, env, params);
69
+ if (!authResult.success) {
70
+ return new Response(authResult.error, {
71
+ status: 401,
72
+ headers: { "WWW-Authenticate": 'Bearer realm="API"' },
73
+ });
74
+ }
75
+
76
+ if (!params.url) {
77
+ return new Response(
78
+ JSON.stringify({ error: "URL parameter is required" }),
79
+ {
80
+ status: 400,
81
+ headers: { "Content-Type": "application/json" },
82
+ },
83
+ );
84
+ }
85
+
86
+ const id = env.BROWSER_DO.idFromName(
87
+ `browser-${params.sessionId ?? "default"}`,
88
+ );
89
+ const browserDO = env.BROWSER_DO.get(id);
90
+
91
+ return browserDO.fetch(request);
92
+ },
93
+ };