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,271 @@
1
+ /**
2
+ * @file scraper-login.ts
3
+ * @description Google login automation via the BrowserDurableObject.
4
+ * Handles the multi-step Google sign-in flow (email → password → redirect)
5
+ * and returns the resulting auth cookies for NotebookLM access.
6
+ *
7
+ * Also provides a fetch-as-session endpoint that executes HTTP requests
8
+ * with the session's stored cookies.
9
+ */
10
+
11
+ import puppeteer from "@cloudflare/puppeteer";
12
+ import type { Browser, Page, Cookie } from "@cloudflare/puppeteer";
13
+ import { applyStealthEvasions } from "./scraper-stealth.js";
14
+ import type { Env } from "./scraper-utils.js";
15
+
16
+ export interface LoginRequest {
17
+ action: "google-login";
18
+ sessionId: string;
19
+ email: string;
20
+ password: string;
21
+ targetUrl: string;
22
+ }
23
+
24
+ export interface LoginResponse {
25
+ success: boolean;
26
+ cookies?: Cookie[];
27
+ finalUrl?: string;
28
+ error?: string;
29
+ }
30
+
31
+ export interface FetchAsSessionRequest {
32
+ sessionId: string;
33
+ url: string;
34
+ method?: string;
35
+ body?: string;
36
+ cookies?: Cookie[];
37
+ headers?: Record<string, string>;
38
+ }
39
+
40
+ /**
41
+ * Handles POST /api/login — automates Google OAuth login flow via Puppeteer.
42
+ *
43
+ * Flow:
44
+ * 1. Navigate to targetUrl (e.g. NotebookLM) which redirects to Google login
45
+ * 2. Enter email, click Next
46
+ * 3. Enter password, click Next
47
+ * 4. Wait for redirect back to targetUrl
48
+ * 5. Return all cookies from the authenticated session
49
+ */
50
+ export async function handleLogin(
51
+ request: Request,
52
+ env: Env,
53
+ ): Promise<Response> {
54
+ let body: LoginRequest;
55
+ try {
56
+ body = await request.json();
57
+ } catch {
58
+ return jsonResponse({ success: false, error: "Invalid JSON body" }, 400);
59
+ }
60
+
61
+ if (!body.email || !body.password || !body.targetUrl) {
62
+ return jsonResponse(
63
+ { success: false, error: "email, password, and targetUrl are required" },
64
+ 400,
65
+ );
66
+ }
67
+
68
+ const id = env.BROWSER_DO.idFromName(`browser-${body.sessionId || "login"}`);
69
+ const browserDO = env.BROWSER_DO.get(id);
70
+
71
+ // Forward the login request to the Durable Object
72
+ const doRequest = new Request(request.url, {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json" },
75
+ body: JSON.stringify({ ...body, _action: "login" }),
76
+ });
77
+
78
+ return browserDO.fetch(doRequest);
79
+ }
80
+
81
+ /**
82
+ * Handles POST /api/fetch — execute an HTTP request using the session's
83
+ * stored browser cookies. This allows the qwksearch-web backend to make
84
+ * authenticated requests to NotebookLM's internal APIs.
85
+ */
86
+ export async function handleFetchAsSession(
87
+ request: Request,
88
+ env: Env,
89
+ ): Promise<Response> {
90
+ let body: FetchAsSessionRequest;
91
+ try {
92
+ body = await request.json();
93
+ } catch {
94
+ return jsonResponse({ error: "Invalid JSON body" }, 400);
95
+ }
96
+
97
+ if (!body.url || !body.sessionId) {
98
+ return jsonResponse({ error: "url and sessionId are required" }, 400);
99
+ }
100
+
101
+ // Build the proxied request with the user's cookies
102
+ const cookieHeader = body.cookies
103
+ ?.map((c) => `${c.name}=${c.value}`)
104
+ .join("; ");
105
+
106
+ const headers: Record<string, string> = {
107
+ ...body.headers,
108
+ "User-Agent":
109
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
110
+ };
111
+
112
+ if (cookieHeader) {
113
+ headers["Cookie"] = cookieHeader;
114
+ }
115
+
116
+ try {
117
+ const response = await fetch(body.url, {
118
+ method: body.method || "GET",
119
+ headers,
120
+ body: body.body,
121
+ });
122
+
123
+ const responseHeaders = new Headers();
124
+ responseHeaders.set(
125
+ "Content-Type",
126
+ response.headers.get("Content-Type") || "application/json",
127
+ );
128
+ responseHeaders.set("Access-Control-Allow-Origin", "*");
129
+
130
+ return new Response(response.body, {
131
+ status: response.status,
132
+ headers: responseHeaders,
133
+ });
134
+ } catch (err) {
135
+ return jsonResponse(
136
+ { error: `Fetch failed: ${(err as Error).message}` },
137
+ 502,
138
+ );
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Google login flow executed inside a Durable Object's persistent browser.
144
+ * Called from BrowserDurableObject when it receives a login action request.
145
+ */
146
+ export async function executeGoogleLogin(
147
+ page: Page,
148
+ email: string,
149
+ password: string,
150
+ targetUrl: string,
151
+ ): Promise<LoginResponse> {
152
+ try {
153
+ // Navigate to the target (NotebookLM) which will redirect to Google login
154
+ await page.goto(targetUrl, {
155
+ waitUntil: "networkidle2",
156
+ timeout: 30000,
157
+ });
158
+
159
+ // Check if already logged in
160
+ if (page.url().includes("notebooklm.google.com") && !page.url().includes("accounts.google.com")) {
161
+ const cookies = await page.cookies();
162
+ return { success: true, cookies, finalUrl: page.url() };
163
+ }
164
+
165
+ // Wait for the email input field
166
+ await page.waitForSelector('input[type="email"]', { timeout: 10000 });
167
+ await randomDelay(500, 1000);
168
+
169
+ // Type email with human-like delays
170
+ await page.type('input[type="email"]', email, { delay: 50 + Math.random() * 80 });
171
+ await randomDelay(300, 700);
172
+
173
+ // Click "Next" button
174
+ const nextButton = await page.$(
175
+ '#identifierNext, button[type="submit"], [data-idom-class*="next"]',
176
+ );
177
+ if (nextButton) {
178
+ await nextButton.click();
179
+ } else {
180
+ await page.keyboard.press("Enter");
181
+ }
182
+
183
+ // Wait for password field to appear
184
+ await page.waitForSelector('input[type="password"]', {
185
+ visible: true,
186
+ timeout: 10000,
187
+ });
188
+ await randomDelay(800, 1500);
189
+
190
+ // Type password
191
+ await page.type('input[type="password"]', password, { delay: 40 + Math.random() * 60 });
192
+ await randomDelay(300, 700);
193
+
194
+ // Click "Next" / "Sign in"
195
+ const signInButton = await page.$(
196
+ '#passwordNext, button[type="submit"], [data-idom-class*="next"]',
197
+ );
198
+ if (signInButton) {
199
+ await signInButton.click();
200
+ } else {
201
+ await page.keyboard.press("Enter");
202
+ }
203
+
204
+ // Wait for navigation to complete (either to target or 2FA/consent)
205
+ await page.waitForNavigation({
206
+ waitUntil: "networkidle2",
207
+ timeout: 30000,
208
+ });
209
+
210
+ // Check for 2FA challenge
211
+ const currentUrl = page.url();
212
+ if (
213
+ currentUrl.includes("challenge") ||
214
+ currentUrl.includes("signin/v2/challenge")
215
+ ) {
216
+ return {
217
+ success: false,
218
+ error:
219
+ "Two-factor authentication required. Please use an app password or disable 2FA temporarily.",
220
+ finalUrl: currentUrl,
221
+ };
222
+ }
223
+
224
+ // Check for consent screen
225
+ if (currentUrl.includes("consent") || currentUrl.includes("oauthchooseaccount")) {
226
+ // Try to click "Allow" or "Continue"
227
+ const allowBtn = await page.$(
228
+ 'button[id="submit_approve_access"], [data-value="true"], button:has-text("Allow"), button:has-text("Continue")',
229
+ );
230
+ if (allowBtn) {
231
+ await allowBtn.click();
232
+ await page.waitForNavigation({ waitUntil: "networkidle2", timeout: 15000 });
233
+ }
234
+ }
235
+
236
+ // Give the page a moment to settle after login
237
+ await randomDelay(2000, 3000);
238
+
239
+ const finalUrl = page.url();
240
+ const cookies = await page.cookies();
241
+
242
+ return {
243
+ success: finalUrl.includes("notebooklm.google.com"),
244
+ cookies,
245
+ finalUrl,
246
+ error: finalUrl.includes("notebooklm.google.com")
247
+ ? undefined
248
+ : "Did not reach NotebookLM after login",
249
+ };
250
+ } catch (err) {
251
+ return {
252
+ success: false,
253
+ error: `Login flow error: ${(err as Error).message}`,
254
+ finalUrl: page.url(),
255
+ };
256
+ }
257
+ }
258
+
259
+ function randomDelay(min: number, max: number): Promise<void> {
260
+ return new Promise((r) => setTimeout(r, min + Math.random() * (max - min)));
261
+ }
262
+
263
+ function jsonResponse(data: unknown, status = 200): Response {
264
+ return new Response(JSON.stringify(data), {
265
+ status,
266
+ headers: {
267
+ "Content-Type": "application/json",
268
+ "Access-Control-Allow-Origin": "*",
269
+ },
270
+ });
271
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * @file scraper-openapi.ts
3
+ * @description OpenAPI 3.0 specification and Swagger UI endpoints for the
4
+ * Puppeteer rendering API. `serveSwagger` returns the interactive Swagger UI
5
+ * page; `serveOpenAPI` returns the raw JSON spec consumed by that UI.
6
+ */
7
+
8
+ /**
9
+ * Returns an HTML page that renders the Swagger UI pointed at `./openapi.json`.
10
+ *
11
+ * @returns HTTP response with `Content-Type: text/html`.
12
+ */
13
+ export function serveSwagger(): Response {
14
+ const html = `
15
+ <!DOCTYPE html>
16
+ <html>
17
+ <head>
18
+ <title>Puppeteer API Documentation</title>
19
+ <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@4.15.5/swagger-ui.css" />
20
+ <style>
21
+ html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
22
+ *, *:before, *:after { box-sizing: inherit; }
23
+ body { margin:0; background: #fafafa; }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <div id="swagger-ui"></div>
28
+ <script src="https://unpkg.com/swagger-ui-dist@4.15.5/swagger-ui-bundle.js"></script>
29
+ <script>
30
+ SwaggerUIBundle({
31
+ url: './openapi.json',
32
+ dom_id: '#swagger-ui',
33
+ deepLinking: true,
34
+ presets: [
35
+ SwaggerUIBundle.presets.apis,
36
+ SwaggerUIBundle.presets.standalone
37
+ ],
38
+ plugins: [
39
+ SwaggerUIBundle.plugins.DownloadUrl
40
+ ]
41
+ });
42
+ </script>
43
+ </body>
44
+ </html>`;
45
+
46
+ return new Response(html, {
47
+ headers: { "Content-Type": "text/html" },
48
+ });
49
+ }
50
+
51
+ /** Minimal OpenAPI 3.0 schema type used for the spec object below. */
52
+ interface OpenAPISpec {
53
+ openapi: string;
54
+ info: {
55
+ title: string;
56
+ description: string;
57
+ version: string;
58
+ contact: { name: string };
59
+ };
60
+ servers: Array<{ url: string; description: string }>;
61
+ security: Array<Record<string, string[]>>;
62
+ paths: Record<string, unknown>;
63
+ components: {
64
+ securitySchemes: Record<string, unknown>;
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Returns the OpenAPI 3.0.3 JSON specification for the rendering API.
70
+ * Documents both the GET and POST `/render` endpoints including all
71
+ * parameters, request body fields, and response schemas.
72
+ *
73
+ * @returns HTTP response with `Content-Type: application/json`.
74
+ */
75
+ export function serveOpenAPI(): Response {
76
+ const spec: OpenAPISpec = {
77
+ openapi: "3.0.3",
78
+ info: {
79
+ title: "Puppeteer Rendering API",
80
+ description:
81
+ "A powerful web scraping and rendering API using Puppeteer with Cloudflare Workers and Durable Objects. Includes automatic Cloudflare challenge bypass.",
82
+ version: "2.1.0",
83
+ contact: { name: "API Support" },
84
+ },
85
+ servers: [{ url: "/api", description: "API Server" }],
86
+ security: [{ bearerAuth: [] }, { passwordAuth: [] }],
87
+ paths: {
88
+ "/render": {
89
+ get: {
90
+ summary: "Render webpage (GET)",
91
+ description:
92
+ "Render a webpage using Puppeteer and return the HTML content. Automatically bypasses Cloudflare challenges.",
93
+ parameters: [
94
+ {
95
+ name: "url",
96
+ in: "query",
97
+ required: true,
98
+ schema: { type: "string", format: "uri" },
99
+ description: "The URL to render",
100
+ },
101
+ {
102
+ name: "SCRAPER_API_KEY",
103
+ in: "query",
104
+ required: false,
105
+ schema: { type: "string" },
106
+ description: "API key (if required)",
107
+ },
108
+ {
109
+ name: "wait",
110
+ in: "query",
111
+ required: false,
112
+ schema: { type: "integer", minimum: 0, maximum: 30000 },
113
+ description: "Additional wait time in milliseconds",
114
+ },
115
+ {
116
+ name: "blockImages",
117
+ in: "query",
118
+ required: false,
119
+ schema: { type: "boolean" },
120
+ description: "Block image loading to save bandwidth",
121
+ },
122
+ {
123
+ name: "sessionId",
124
+ in: "query",
125
+ required: false,
126
+ schema: { type: "string" },
127
+ description:
128
+ "Session ID for browser reuse and cookie persistence",
129
+ },
130
+ {
131
+ name: "timeout",
132
+ in: "query",
133
+ required: false,
134
+ schema: { type: "integer", minimum: 5000, maximum: 60000 },
135
+ description: "Page load timeout in milliseconds",
136
+ },
137
+ {
138
+ name: "waitUntil",
139
+ in: "query",
140
+ required: false,
141
+ schema: {
142
+ type: "string",
143
+ enum: [
144
+ "load",
145
+ "domcontentloaded",
146
+ "networkidle0",
147
+ "networkidle2",
148
+ ],
149
+ },
150
+ description: "When to consider navigation succeeded",
151
+ },
152
+ {
153
+ name: "cookies",
154
+ in: "query",
155
+ required: false,
156
+ schema: { type: "string" },
157
+ description: "JSON string of cookies to set",
158
+ },
159
+ {
160
+ name: "format",
161
+ in: "query",
162
+ required: false,
163
+ schema: { type: "string", enum: ["html", "json"] },
164
+ description: "Response format",
165
+ },
166
+ {
167
+ name: "proxyUrl",
168
+ in: "query",
169
+ required: false,
170
+ schema: { type: "string", format: "uri" },
171
+ description:
172
+ "Proxy server URL (e.g., http://proxy.example.com:8080)",
173
+ },
174
+ {
175
+ name: "proxyUser",
176
+ in: "query",
177
+ required: false,
178
+ schema: { type: "string" },
179
+ description: "Proxy username (if proxy requires authentication)",
180
+ },
181
+ {
182
+ name: "proxyPass",
183
+ in: "query",
184
+ required: false,
185
+ schema: { type: "string" },
186
+ description: "Proxy password (if proxy requires authentication)",
187
+ },
188
+ {
189
+ name: "bypassCaptcha",
190
+ in: "query",
191
+ required: false,
192
+ schema: { type: "boolean", default: true },
193
+ description:
194
+ "Enable Cloudflare challenge bypass (enabled by default)",
195
+ },
196
+ {
197
+ name: "challengeMatch",
198
+ in: "query",
199
+ required: false,
200
+ schema: { type: "string" },
201
+ description:
202
+ "Custom string to detect challenge pages (default: 'challenge-platform')",
203
+ },
204
+ {
205
+ name: "maxRetries",
206
+ in: "query",
207
+ required: false,
208
+ schema: { type: "integer", minimum: 1, maximum: 20, default: 10 },
209
+ description: "Maximum retries for challenge bypass",
210
+ },
211
+ {
212
+ name: "challengeTimeout",
213
+ in: "query",
214
+ required: false,
215
+ schema: {
216
+ type: "integer",
217
+ minimum: 1000,
218
+ maximum: 30000,
219
+ default: 5000,
220
+ },
221
+ description: "Timeout for each challenge retry in milliseconds",
222
+ },
223
+ {
224
+ name: "twoCaptchaKey",
225
+ in: "query",
226
+ required: false,
227
+ schema: { type: "string" },
228
+ description:
229
+ "2captcha API key for solving reCAPTCHA/Turnstile challenges (optional, for sites with harder protection)",
230
+ },
231
+ ],
232
+ responses: {
233
+ "200": {
234
+ description: "Successfully rendered webpage",
235
+ content: {
236
+ "text/html": { schema: { type: "string" } },
237
+ "application/json": {
238
+ schema: {
239
+ type: "object",
240
+ properties: {
241
+ html: { type: "string" },
242
+ url: { type: "string" },
243
+ title: { type: "string" },
244
+ cookies: { type: "array" },
245
+ performance: { type: "object" },
246
+ challengeBypassed: { type: "boolean" },
247
+ retryCount: { type: "integer" },
248
+ },
249
+ },
250
+ },
251
+ },
252
+ },
253
+ "400": { description: "Bad request - missing URL or invalid parameters" },
254
+ "401": { description: "Unauthorized - invalid or missing password" },
255
+ "500": { description: "Internal server error" },
256
+ },
257
+ },
258
+ post: {
259
+ summary: "Render webpage (POST)",
260
+ description:
261
+ "Render a webpage using Puppeteer with advanced options via POST body. Automatically bypasses Cloudflare challenges.",
262
+ requestBody: {
263
+ required: true,
264
+ content: {
265
+ "application/json": {
266
+ schema: {
267
+ type: "object",
268
+ required: ["url"],
269
+ properties: {
270
+ url: { type: "string", format: "uri" },
271
+ SCRAPER_API_KEY: { type: "string" },
272
+ wait: { type: "integer", minimum: 0, maximum: 30000 },
273
+ blockImages: { type: "boolean" },
274
+ sessionId: { type: "string" },
275
+ timeout: {
276
+ type: "integer",
277
+ minimum: 5000,
278
+ maximum: 60000,
279
+ },
280
+ waitUntil: {
281
+ type: "string",
282
+ enum: [
283
+ "load",
284
+ "domcontentloaded",
285
+ "networkidle0",
286
+ "networkidle2",
287
+ ],
288
+ },
289
+ cookies: { type: "string" },
290
+ headers: {
291
+ type: "object",
292
+ additionalProperties: { type: "string" },
293
+ },
294
+ format: { type: "string", enum: ["html", "json"] },
295
+ proxyUrl: { type: "string", format: "uri" },
296
+ proxyUser: { type: "string" },
297
+ proxyPass: { type: "string" },
298
+ bypassCaptcha: { type: "boolean", default: true },
299
+ challengeMatch: { type: "string" },
300
+ maxRetries: {
301
+ type: "integer",
302
+ minimum: 1,
303
+ maximum: 20,
304
+ default: 10,
305
+ },
306
+ challengeTimeout: {
307
+ type: "integer",
308
+ minimum: 1000,
309
+ maximum: 30000,
310
+ default: 5000,
311
+ },
312
+ twoCaptchaKey: { type: "string" },
313
+ },
314
+ },
315
+ },
316
+ },
317
+ },
318
+ responses: {
319
+ "200": {
320
+ description: "Successfully rendered webpage",
321
+ content: {
322
+ "text/html": { schema: { type: "string" } },
323
+ "application/json": {
324
+ schema: {
325
+ type: "object",
326
+ properties: {
327
+ html: { type: "string" },
328
+ url: { type: "string" },
329
+ title: { type: "string" },
330
+ cookies: { type: "array" },
331
+ performance: { type: "object" },
332
+ challengeBypassed: { type: "boolean" },
333
+ retryCount: { type: "integer" },
334
+ },
335
+ },
336
+ },
337
+ },
338
+ },
339
+ },
340
+ },
341
+ },
342
+ },
343
+ components: {
344
+ securitySchemes: {
345
+ bearerAuth: {
346
+ type: "http",
347
+ scheme: "bearer",
348
+ description: "Use your API key as the bearer token",
349
+ },
350
+ apiKeyAuth: {
351
+ type: "apiKey",
352
+ in: "query",
353
+ name: "SCRAPER_API_KEY",
354
+ description: "API key as query parameter",
355
+ },
356
+ },
357
+ },
358
+ };
359
+
360
+ return new Response(JSON.stringify(spec, null, 2), {
361
+ headers: { "Content-Type": "application/json" },
362
+ });
363
+ }