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.
- package/INTEGRATION.md +319 -0
- package/QUICKSTART.md +270 -0
- package/package.json +19 -0
- package/readme.md +317 -0
- package/src/config/setup.sh +60 -0
- package/src/durable-objects/browser-durable-object.ts +534 -0
- package/src/scrapers/scraper-captcha.ts +479 -0
- package/src/scrapers/scraper-cloudflare.ts +93 -0
- package/src/scrapers/scraper-login.ts +271 -0
- package/src/scrapers/scraper-openapi.ts +363 -0
- package/src/scrapers/scraper-stealth.ts +521 -0
- package/src/utils/scraper-utils.ts +219 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file browser-durable-object.ts
|
|
3
|
+
* @description Cloudflare Durable Object that owns a single Puppeteer browser
|
|
4
|
+
* instance. All scrape requests are routed here so that one long-lived
|
|
5
|
+
* browser process is shared across many Workers invocations, avoiding the
|
|
6
|
+
* cold-start cost of launching a new browser for every request.
|
|
7
|
+
*
|
|
8
|
+
* The browser is kept alive for up to {@link BrowserDurableObject.BROWSER_TIMEOUT}
|
|
9
|
+
* milliseconds of inactivity. An alarm is set after each request; if the
|
|
10
|
+
* alarm fires and the idle threshold has been exceeded the browser is closed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { applyStealthEvasions } from "./scraper-stealth.js";
|
|
14
|
+
import {
|
|
15
|
+
isChallengePage,
|
|
16
|
+
simulateHumanBehavior,
|
|
17
|
+
tryClickRecaptcha,
|
|
18
|
+
waitForRecaptchaSolved,
|
|
19
|
+
solveRecaptchaWith2Captcha,
|
|
20
|
+
solveTurnstileWith2Captcha,
|
|
21
|
+
} from "./scraper-captcha.js";
|
|
22
|
+
import { executeGoogleLogin } from "./scraper-login.js";
|
|
23
|
+
import puppeteer from "@cloudflare/puppeteer";
|
|
24
|
+
import type { Browser, Page, Cookie } from "@cloudflare/puppeteer";
|
|
25
|
+
import { parseRequestParams } from "./scraper-utils.js";
|
|
26
|
+
import type { Env } from "./scraper-utils.js";
|
|
27
|
+
|
|
28
|
+
/** Cloudflare Durable Object storage interface (subset used here). */
|
|
29
|
+
interface DurableObjectStorage {
|
|
30
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
31
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
32
|
+
getAlarm(): Promise<number | null>;
|
|
33
|
+
setAlarm(scheduledTime: number): Promise<void>;
|
|
34
|
+
deleteAlarm(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Cloudflare Durable Object state passed to the constructor. */
|
|
38
|
+
interface DurableObjectState {
|
|
39
|
+
storage: DurableObjectStorage;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** JSON shape returned when `format=json` is requested. */
|
|
43
|
+
interface ScrapeJsonResponse {
|
|
44
|
+
html: string;
|
|
45
|
+
url: string;
|
|
46
|
+
title: string;
|
|
47
|
+
cookies: Cookie[];
|
|
48
|
+
challengeBypassed: boolean;
|
|
49
|
+
retryCount: number;
|
|
50
|
+
loadTime: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Cloudflare Durable Object that manages a persistent Puppeteer browser for
|
|
55
|
+
* rendering web pages. One instance is created per `sessionId` so that
|
|
56
|
+
* cookie state is isolated between logical sessions.
|
|
57
|
+
*/
|
|
58
|
+
export class BrowserDurableObject {
|
|
59
|
+
private state: DurableObjectState;
|
|
60
|
+
private env: Env;
|
|
61
|
+
private browser: Browser | null;
|
|
62
|
+
private lastUsed: number;
|
|
63
|
+
private storage: DurableObjectStorage;
|
|
64
|
+
|
|
65
|
+
/** Inactivity period after which the browser process is closed. */
|
|
66
|
+
private readonly BROWSER_TIMEOUT = 5 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
/** Default HTML substrings that indicate a Cloudflare bot challenge. */
|
|
69
|
+
private readonly CHALLENGE_PATTERNS: string[] = [
|
|
70
|
+
"challenge-platform",
|
|
71
|
+
"cf-challenge",
|
|
72
|
+
"cf_chl_opt",
|
|
73
|
+
"_cf_chl",
|
|
74
|
+
"cf-turnstile",
|
|
75
|
+
"challenge-running",
|
|
76
|
+
"cf-please-wait",
|
|
77
|
+
"Checking your browser",
|
|
78
|
+
"Just a moment...",
|
|
79
|
+
"Verifying you are human",
|
|
80
|
+
"cf-spinner",
|
|
81
|
+
"ray-id",
|
|
82
|
+
"g-recaptcha",
|
|
83
|
+
"recaptcha",
|
|
84
|
+
"grecaptcha",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
constructor(state: DurableObjectState, env: Env) {
|
|
88
|
+
this.state = state;
|
|
89
|
+
this.env = env;
|
|
90
|
+
this.browser = null;
|
|
91
|
+
this.lastUsed = 0;
|
|
92
|
+
this.storage = state.storage;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Main request handler. Parses params, spins up a Puppeteer page, applies
|
|
97
|
+
* stealth evasions, optionally bypasses Cloudflare challenges, and returns
|
|
98
|
+
* the rendered HTML (or a JSON envelope when `format=json`).
|
|
99
|
+
*
|
|
100
|
+
* @param request - Incoming Fetch request forwarded from the Worker.
|
|
101
|
+
* @returns HTML or JSON response with load-time and session metadata headers.
|
|
102
|
+
*/
|
|
103
|
+
async fetch(request: Request): Promise<Response> {
|
|
104
|
+
// Handle login action requests from the NotebookLM integration
|
|
105
|
+
if (request.method === "POST") {
|
|
106
|
+
try {
|
|
107
|
+
const clonedReq = request.clone();
|
|
108
|
+
const body = await clonedReq.json() as Record<string, unknown>;
|
|
109
|
+
if (body._action === "login") {
|
|
110
|
+
return this.handleLoginAction(body);
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// Not a login request, continue with normal flow
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const params = await parseRequestParams(request);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const normalizedUrl = new URL(params.url).toString();
|
|
121
|
+
|
|
122
|
+
await this.ensureBrowser();
|
|
123
|
+
|
|
124
|
+
const page: Page = await this.browser!.newPage();
|
|
125
|
+
|
|
126
|
+
await applyStealthEvasions(page);
|
|
127
|
+
|
|
128
|
+
if (params.proxyUser && params.proxyPass) {
|
|
129
|
+
await page.authenticate({
|
|
130
|
+
username: params.proxyUser,
|
|
131
|
+
password: params.proxyPass,
|
|
132
|
+
});
|
|
133
|
+
} else if (this.env.PROXY_USER && this.env.PROXY_PASS) {
|
|
134
|
+
await page.authenticate({
|
|
135
|
+
username: this.env.PROXY_USER,
|
|
136
|
+
password: this.env.PROXY_PASS,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await page.setUserAgent(
|
|
141
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
await page.setViewport({
|
|
145
|
+
width: 1920,
|
|
146
|
+
height: 1080,
|
|
147
|
+
deviceScaleFactor: 1,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await page.setExtraHTTPHeaders({
|
|
151
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
152
|
+
"sec-ch-ua":
|
|
153
|
+
'"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
|
|
154
|
+
"sec-ch-ua-mobile": "?0",
|
|
155
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
156
|
+
"Sec-Fetch-Dest": "document",
|
|
157
|
+
"Sec-Fetch-Mode": "navigate",
|
|
158
|
+
"Sec-Fetch-Site": "none",
|
|
159
|
+
"Sec-Fetch-User": "?1",
|
|
160
|
+
"Upgrade-Insecure-Requests": "1",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (params.blockImages || Object.keys(params.headers).length > 0) {
|
|
164
|
+
await page.setRequestInterception(true);
|
|
165
|
+
page.on("request", (req) => {
|
|
166
|
+
const resourceType = req.resourceType();
|
|
167
|
+
if (params.blockImages && resourceType === "image") {
|
|
168
|
+
req.abort();
|
|
169
|
+
} else {
|
|
170
|
+
const headers = { ...req.headers(), ...params.headers };
|
|
171
|
+
req.continue({ headers });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await this.loadCookies(page, params.sessionId, params.cookies);
|
|
177
|
+
|
|
178
|
+
const startTime = Date.now();
|
|
179
|
+
let response = await page.goto(normalizedUrl, {
|
|
180
|
+
waitUntil: "domcontentloaded",
|
|
181
|
+
timeout: params.timeout,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
let responseBody = await page.content();
|
|
185
|
+
let retryCount = 0;
|
|
186
|
+
let challengeBypassed = false;
|
|
187
|
+
|
|
188
|
+
if (params.bypassCaptcha) {
|
|
189
|
+
const challengeMatch =
|
|
190
|
+
params.challengeMatch ?? this.env.CHALLENGE_MATCH;
|
|
191
|
+
const twoCaptchaKey =
|
|
192
|
+
params.twoCaptchaKey ?? this.env.TWO_CAPTCHA_KEY;
|
|
193
|
+
|
|
194
|
+
while (
|
|
195
|
+
isChallengePage(responseBody, challengeMatch, this.CHALLENGE_PATTERNS) &&
|
|
196
|
+
retryCount < params.maxRetries
|
|
197
|
+
) {
|
|
198
|
+
console.log(
|
|
199
|
+
`Challenge detected, attempt ${retryCount + 1}/${params.maxRetries}`,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
await simulateHumanBehavior(page);
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const recaptchaClicked = await tryClickRecaptcha(page);
|
|
206
|
+
|
|
207
|
+
if (recaptchaClicked) {
|
|
208
|
+
await new Promise<void>((r) =>
|
|
209
|
+
setTimeout(r, 2000 + Math.random() * 2000),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const solved = await waitForRecaptchaSolved(page, 5000);
|
|
213
|
+
|
|
214
|
+
if (!solved && twoCaptchaKey) {
|
|
215
|
+
console.log("Attempting to solve reCAPTCHA with 2captcha...");
|
|
216
|
+
const captchaSolved = await solveRecaptchaWith2Captcha(
|
|
217
|
+
page,
|
|
218
|
+
twoCaptchaKey,
|
|
219
|
+
);
|
|
220
|
+
if (captchaSolved) {
|
|
221
|
+
await new Promise<void>((r) => setTimeout(r, 1000));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (twoCaptchaKey && responseBody.includes("cf-turnstile")) {
|
|
227
|
+
console.log("Attempting to solve Turnstile with 2captcha...");
|
|
228
|
+
await solveTurnstileWith2Captcha(page, twoCaptchaKey);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const newResponse = await page.waitForNavigation({
|
|
232
|
+
timeout: params.challengeTimeout,
|
|
233
|
+
waitUntil: "domcontentloaded",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (newResponse) response = newResponse;
|
|
237
|
+
|
|
238
|
+
responseBody = await page.content();
|
|
239
|
+
retryCount++;
|
|
240
|
+
challengeBypassed = true;
|
|
241
|
+
} catch (_navError) {
|
|
242
|
+
responseBody = await page.content();
|
|
243
|
+
retryCount++;
|
|
244
|
+
|
|
245
|
+
if (
|
|
246
|
+
isChallengePage(
|
|
247
|
+
responseBody,
|
|
248
|
+
challengeMatch,
|
|
249
|
+
this.CHALLENGE_PATTERNS,
|
|
250
|
+
)
|
|
251
|
+
) {
|
|
252
|
+
await new Promise<void>((r) =>
|
|
253
|
+
setTimeout(r, 1000 + Math.random() * 2000),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (challengeBypassed || retryCount > 0) {
|
|
260
|
+
try {
|
|
261
|
+
await page.waitForNetworkIdle({ timeout: 5000 });
|
|
262
|
+
} catch {
|
|
263
|
+
// Page may already be idle; ignore.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (params.wait > 0) {
|
|
269
|
+
await new Promise<void>((r) => setTimeout(r, params.wait));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (params.waitUntil !== "domcontentloaded") {
|
|
273
|
+
try {
|
|
274
|
+
await page.waitForNetworkIdle({
|
|
275
|
+
timeout: params.timeout,
|
|
276
|
+
idleTime: 500,
|
|
277
|
+
});
|
|
278
|
+
} catch {
|
|
279
|
+
// Ignore navigation timeout.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const html = await page.content();
|
|
284
|
+
const title = await page.title();
|
|
285
|
+
const finalUrl = page.url();
|
|
286
|
+
const loadTime = Date.now() - startTime;
|
|
287
|
+
|
|
288
|
+
const cookies: Cookie[] = await page.cookies();
|
|
289
|
+
await this.saveCookies(params.sessionId, cookies);
|
|
290
|
+
|
|
291
|
+
await page.close();
|
|
292
|
+
|
|
293
|
+
this.lastUsed = Date.now();
|
|
294
|
+
await this.scheduleCleanup();
|
|
295
|
+
|
|
296
|
+
if (params.format === "json") {
|
|
297
|
+
const responseData: ScrapeJsonResponse = {
|
|
298
|
+
html,
|
|
299
|
+
url: finalUrl,
|
|
300
|
+
title,
|
|
301
|
+
cookies,
|
|
302
|
+
challengeBypassed,
|
|
303
|
+
retryCount,
|
|
304
|
+
loadTime,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return new Response(JSON.stringify(responseData, null, 2), {
|
|
308
|
+
headers: {
|
|
309
|
+
"Content-Type": "application/json",
|
|
310
|
+
"X-Load-Time": String(loadTime),
|
|
311
|
+
"X-Session-Id": params.sessionId,
|
|
312
|
+
"X-Challenge-Bypassed": String(challengeBypassed),
|
|
313
|
+
"X-Retry-Count": String(retryCount),
|
|
314
|
+
"Access-Control-Allow-Origin": "*",
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const htmlWithBase = html.replace(
|
|
320
|
+
/<head[^>]*>/i,
|
|
321
|
+
`<head><base href='${normalizedUrl.split("/").slice(0, 3).join("/")}/' >`,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
return new Response(htmlWithBase, {
|
|
325
|
+
headers: {
|
|
326
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
327
|
+
"X-Load-Time": String(loadTime),
|
|
328
|
+
"X-Session-Id": params.sessionId,
|
|
329
|
+
"X-Final-URL": finalUrl,
|
|
330
|
+
"X-Challenge-Bypassed": String(challengeBypassed),
|
|
331
|
+
"X-Retry-Count": String(retryCount),
|
|
332
|
+
"Access-Control-Allow-Origin": "*",
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.error("Error rendering page:", error);
|
|
337
|
+
await this.closeBrowser();
|
|
338
|
+
|
|
339
|
+
return new Response(
|
|
340
|
+
JSON.stringify({
|
|
341
|
+
error: `Failed to render page: ${(error as Error).message}`,
|
|
342
|
+
timestamp: new Date().toISOString(),
|
|
343
|
+
}),
|
|
344
|
+
{
|
|
345
|
+
status: 500,
|
|
346
|
+
headers: { "Content-Type": "application/json" },
|
|
347
|
+
},
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Handles automated Google login via the persistent browser session.
|
|
354
|
+
* Navigates to Google login, enters credentials, and returns cookies.
|
|
355
|
+
*/
|
|
356
|
+
private async handleLoginAction(
|
|
357
|
+
body: Record<string, unknown>,
|
|
358
|
+
): Promise<Response> {
|
|
359
|
+
const { email, password, targetUrl } = body as {
|
|
360
|
+
email: string;
|
|
361
|
+
password: string;
|
|
362
|
+
targetUrl: string;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
await this.ensureBrowser();
|
|
367
|
+
const page: Page = await this.browser!.newPage();
|
|
368
|
+
await applyStealthEvasions(page);
|
|
369
|
+
|
|
370
|
+
await page.setUserAgent(
|
|
371
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
|
|
375
|
+
|
|
376
|
+
await page.setExtraHTTPHeaders({
|
|
377
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
378
|
+
"sec-ch-ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
|
|
379
|
+
"sec-ch-ua-mobile": "?0",
|
|
380
|
+
"sec-ch-ua-platform": '"Windows"',
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const result = await executeGoogleLogin(page, email, password, targetUrl);
|
|
384
|
+
|
|
385
|
+
if (result.success && result.cookies) {
|
|
386
|
+
const sessionId = (body.sessionId as string) || "login";
|
|
387
|
+
await this.saveCookies(sessionId, result.cookies);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await page.close();
|
|
391
|
+
this.lastUsed = Date.now();
|
|
392
|
+
await this.scheduleCleanup();
|
|
393
|
+
|
|
394
|
+
return new Response(JSON.stringify(result), {
|
|
395
|
+
status: result.success ? 200 : 422,
|
|
396
|
+
headers: {
|
|
397
|
+
"Content-Type": "application/json",
|
|
398
|
+
"Access-Control-Allow-Origin": "*",
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
} catch (err) {
|
|
402
|
+
console.error("Login action error:", err);
|
|
403
|
+
await this.closeBrowser();
|
|
404
|
+
return new Response(
|
|
405
|
+
JSON.stringify({
|
|
406
|
+
success: false,
|
|
407
|
+
error: `Login error: ${(err as Error).message}`,
|
|
408
|
+
}),
|
|
409
|
+
{
|
|
410
|
+
status: 500,
|
|
411
|
+
headers: { "Content-Type": "application/json" },
|
|
412
|
+
},
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Loads cookies from Durable Object storage and the `cookies` request param,
|
|
419
|
+
* then injects them all into the given page.
|
|
420
|
+
*
|
|
421
|
+
* @param page - Puppeteer page to inject cookies into.
|
|
422
|
+
* @param sessionId - Session key used to look up persisted cookies.
|
|
423
|
+
* @param cookiesParam - Optional JSON-serialised cookie array from the caller.
|
|
424
|
+
*/
|
|
425
|
+
private async loadCookies(
|
|
426
|
+
page: Page,
|
|
427
|
+
sessionId: string,
|
|
428
|
+
cookiesParam: string | undefined,
|
|
429
|
+
): Promise<void> {
|
|
430
|
+
const storedCookies = await this.storage.get<Cookie[]>(
|
|
431
|
+
`cookies:${sessionId}`,
|
|
432
|
+
);
|
|
433
|
+
let allCookies: Cookie[] = storedCookies ? [...storedCookies] : [];
|
|
434
|
+
|
|
435
|
+
if (cookiesParam) {
|
|
436
|
+
try {
|
|
437
|
+
const paramCookies: Cookie[] = JSON.parse(cookiesParam);
|
|
438
|
+
allCookies = allCookies.concat(paramCookies);
|
|
439
|
+
} catch (e) {
|
|
440
|
+
console.error("Invalid cookies parameter:", e);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (allCookies.length > 0) {
|
|
445
|
+
await page.setCookie(...allCookies);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Persists the current page cookies to Durable Object storage under the
|
|
451
|
+
* given session key, overwriting any previously stored value.
|
|
452
|
+
*
|
|
453
|
+
* @param sessionId - Storage key prefix for this session.
|
|
454
|
+
* @param cookies - Array of cookies returned by `page.cookies()`.
|
|
455
|
+
*/
|
|
456
|
+
private async saveCookies(
|
|
457
|
+
sessionId: string,
|
|
458
|
+
cookies: Cookie[],
|
|
459
|
+
): Promise<void> {
|
|
460
|
+
if (cookies.length > 0) {
|
|
461
|
+
await this.storage.put(`cookies:${sessionId}`, cookies);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Ensures a connected browser instance is available, launching one if
|
|
467
|
+
* necessary. Applies the `PROXY_URL` env var as a `--proxy-server` arg
|
|
468
|
+
* and keeps the browser alive for up to 10 minutes between requests.
|
|
469
|
+
*/
|
|
470
|
+
private async ensureBrowser(): Promise<void> {
|
|
471
|
+
if (this.browser?.isConnected()) {
|
|
472
|
+
try {
|
|
473
|
+
await this.browser.version();
|
|
474
|
+
return;
|
|
475
|
+
} catch {
|
|
476
|
+
this.browser = null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const launchOptions: Record<string, unknown> = {
|
|
481
|
+
keep_alive: 10 * 60 * 1000,
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const browserArgs: string[] = [];
|
|
485
|
+
if (this.env.PROXY_URL) {
|
|
486
|
+
browserArgs.push(`--proxy-server=${this.env.PROXY_URL}`);
|
|
487
|
+
}
|
|
488
|
+
if (browserArgs.length > 0) {
|
|
489
|
+
launchOptions.args = browserArgs;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
this.browser = await puppeteer.launch(this.env.MYBROWSER, launchOptions);
|
|
493
|
+
this.lastUsed = Date.now();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Gracefully closes the browser and clears the instance reference.
|
|
498
|
+
* Called on error or when the inactivity alarm fires.
|
|
499
|
+
*/
|
|
500
|
+
private async closeBrowser(): Promise<void> {
|
|
501
|
+
if (this.browser) {
|
|
502
|
+
try {
|
|
503
|
+
await this.browser.close();
|
|
504
|
+
} catch (error) {
|
|
505
|
+
console.error("Error closing browser:", error);
|
|
506
|
+
}
|
|
507
|
+
this.browser = null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Resets the Durable Object alarm to fire {@link BROWSER_TIMEOUT} ms from
|
|
513
|
+
* now, replacing any previously scheduled alarm.
|
|
514
|
+
*/
|
|
515
|
+
private async scheduleCleanup(): Promise<void> {
|
|
516
|
+
const existingAlarm = await this.storage.getAlarm();
|
|
517
|
+
if (existingAlarm) await this.storage.deleteAlarm();
|
|
518
|
+
await this.storage.setAlarm(Date.now() + this.BROWSER_TIMEOUT);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Cloudflare Durable Object alarm handler. Closes the browser when it has
|
|
523
|
+
* been idle longer than {@link BROWSER_TIMEOUT}; otherwise reschedules the
|
|
524
|
+
* alarm for the remaining idle period.
|
|
525
|
+
*/
|
|
526
|
+
async alarm(): Promise<void> {
|
|
527
|
+
const timeSinceLastUse = Date.now() - this.lastUsed;
|
|
528
|
+
if (timeSinceLastUse >= this.BROWSER_TIMEOUT) {
|
|
529
|
+
await this.closeBrowser();
|
|
530
|
+
} else {
|
|
531
|
+
await this.scheduleCleanup();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|