devspy-tool 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,622 @@
1
+ /**
2
+ * SessionEngine — Full-session API capture orchestrator.
3
+ *
4
+ * Architecture:
5
+ * 1. Launch browser (headless or visible for manual mode)
6
+ * 2. Start CDP network capture (runs continuously until stop)
7
+ * 3. Load target URL
8
+ * 4. Optional: run login flow
9
+ * 5. Optional: navigate to post-login URL (SPA-safe)
10
+ * 6. Run exploration engine (routes, interactions, scroll, data patterns)
11
+ * 7. Collect final results
12
+ *
13
+ * Manual mode:
14
+ * - Browser opens visually (headless: false)
15
+ * - Network capture streams results via EventEmitter
16
+ * - Session stays alive until explicitly stopped
17
+ *
18
+ * The network collector runs CONTINUOUSLY from step 2 → 7, capturing:
19
+ * - Page load APIs
20
+ * - Login APIs
21
+ * - Post-redirect APIs
22
+ * - Interaction-triggered APIs
23
+ * - Lazy-loaded APIs
24
+ * - Background/polling APIs
25
+ */
26
+
27
+ const puppeteer = require("puppeteer-core");
28
+ const EventEmitter = require("events");
29
+ const { CHROME_PATH, RESPONSE_PREVIEW_LIMIT } = require("../config");
30
+ const { NetworkCollector } = require("./network");
31
+ const { explore, intelligentScroll, isDangerous, sleep } = require("./explorer");
32
+
33
+ // ─── Login Handler ──────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Try the primary selector first, then fall back through alternatives.
37
+ * Includes "smart" heuristics to detect inputs by type, label, or placeholder.
38
+ */
39
+ async function resolveSelector(page, primary, fallbacks = [], type = "any") {
40
+ // 1. Try user-provided selector
41
+ if (primary) {
42
+ try {
43
+ const el = await page.waitForSelector(primary, { timeout: 3000 });
44
+ if (el) return el;
45
+ } catch { /* ignore */ }
46
+ }
47
+
48
+ // 2. Try fallbacks
49
+ for (const selector of fallbacks) {
50
+ try {
51
+ if (selector.includes(":has-text(")) {
52
+ const textMatch = selector.match(/:has-text\('(.+?)'\)/);
53
+ if (textMatch) {
54
+ const tag = selector.split(":")[0] || "*";
55
+ const text = textMatch[1].toLowerCase();
56
+ const el = await page.evaluateHandle((t, txt) => {
57
+ return Array.from(document.querySelectorAll(t)).find(e =>
58
+ e.innerText?.toLowerCase().includes(txt) || e.value?.toLowerCase().includes(txt)
59
+ ) || null;
60
+ }, tag, text);
61
+ const element = el.asElement();
62
+ if (element) return element;
63
+ }
64
+ continue;
65
+ }
66
+ const el = await page.$(selector);
67
+ if (el) return el;
68
+ } catch { continue; }
69
+ }
70
+
71
+ // 3. Smart Heuristics based on "type"
72
+ return await page.evaluateHandle((t) => {
73
+ const isVisible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
74
+ const inputs = Array.from(document.querySelectorAll("input, button, [role='button']")).filter(isVisible);
75
+
76
+ if (t === "email") {
77
+ return inputs.find(i =>
78
+ i.type === "email" ||
79
+ i.name?.toLowerCase().includes("email") ||
80
+ i.id?.toLowerCase().includes("email") ||
81
+ i.placeholder?.toLowerCase().includes("email")
82
+ ) || inputs.find(i => i.type === "text" && !i.name?.toLowerCase().includes("search")) || null;
83
+ }
84
+
85
+ if (t === "password") {
86
+ return inputs.find(i => i.type === "password" || i.name?.toLowerCase().includes("password")) || null;
87
+ }
88
+
89
+ if (t === "submit") {
90
+ return inputs.find(i =>
91
+ i.type === "submit" ||
92
+ ["login", "sign in", "submit", "continue"].some(k => i.innerText?.toLowerCase().includes(k) || i.value?.toLowerCase().includes(k))
93
+ ) || null;
94
+ }
95
+
96
+ return null;
97
+ }, type).then(h => h.asElement());
98
+ }
99
+
100
+ /**
101
+ * Execute the Smart Login flow (9-step system):
102
+ * 1. Open page (handled by caller)
103
+ * 2. Check login form presence
104
+ * 3. If not → click login button keywords
105
+ * 4. Wait for inputs to appear
106
+ * 5. Smartly detect email/password
107
+ * 6. Fill credentials
108
+ * 7. Click submit
109
+ * 8. Wait for session stability / navigation
110
+ * 9. Verify success
111
+ */
112
+ async function performLogin(page, network, loginConfig) {
113
+ console.log("[SmartLogin] Starting flow...");
114
+
115
+ // Step 2 & 3: Check for form or click navigation
116
+ const hasInputs = await page.evaluate(() => {
117
+ return !!document.querySelector("input[type='password'], input[name*='password']");
118
+ });
119
+
120
+ if (!hasInputs) {
121
+ console.log("[SmartLogin] No form visible. Searching for login trigger...");
122
+ const clicked = await page.evaluate(() => {
123
+ const primaryKeywords = ["login", "sign in", "log in", "enter portal", "access", "sign-in"];
124
+ const secondaryKeywords = ["get started", "start now", "join", "try", "go to app", "dashboard"];
125
+
126
+ const links = Array.from(document.querySelectorAll("a, button, [role='button']"));
127
+
128
+ const findMatch = (keys) => links.find(el => {
129
+ const txt = el.innerText?.toLowerCase() || "";
130
+ return keys.some(k => txt.includes(k));
131
+ });
132
+
133
+ // Try primary (Login) first, then secondary (Get Started)
134
+ const trigger = findMatch(primaryKeywords) || findMatch(secondaryKeywords);
135
+
136
+ if (trigger) {
137
+ trigger.click();
138
+ return { text: trigger.innerText?.trim(), id: trigger.id || "no-id" };
139
+ }
140
+ return null;
141
+ });
142
+
143
+ if (clicked) {
144
+ console.log(`[SmartLogin] Trigger clicked: "${clicked.text}" (${clicked.id}). Waiting for form...`);
145
+ await sleep(2000);
146
+ await network.waitForSilence(1000, 5000);
147
+ }
148
+ }
149
+
150
+ // Step 5: Detect Email
151
+ console.log("[SmartLogin] Detecting email field...");
152
+ const emailEl = await resolveSelector(page, loginConfig.emailSelector, [
153
+ "input[type='email']", "input[name='email']", "input[id*='email' i]"
154
+ ], "email");
155
+
156
+ // Step 5: Detect Password
157
+ console.log("[SmartLogin] Detecting password field...");
158
+ const passwordEl = await resolveSelector(page, loginConfig.passwordSelector, [
159
+ "input[type='password']", "input[name='password']"
160
+ ], "password");
161
+
162
+ if (!emailEl || !passwordEl) {
163
+ throw new Error(`Login fields not found. Email: ${!!emailEl}, Password: ${!!passwordEl}`);
164
+ }
165
+
166
+ // Step 6: Fill credentials
167
+ console.log("[SmartLogin] Filling form...");
168
+ await emailEl.click({ clickCount: 3 });
169
+ await emailEl.type(loginConfig.email, { delay: 30 });
170
+ await passwordEl.click({ clickCount: 3 });
171
+ await passwordEl.type(loginConfig.password, { delay: 30 });
172
+
173
+ // Step 7: Submit
174
+ console.log("[SmartLogin] Submitting...");
175
+ const submitEl = await resolveSelector(page, loginConfig.submitSelector, [
176
+ "button[type='submit']", "input[type='submit']", "button:has-text('Login')"
177
+ ], "submit");
178
+
179
+ if (submitEl) {
180
+ await submitEl.click();
181
+ } else {
182
+ await passwordEl.press("Enter");
183
+ }
184
+
185
+ // Step 8: Wait for stability
186
+ console.log("[SmartLogin] Waiting for navigation/session setup...");
187
+ try {
188
+ await Promise.race([
189
+ page.waitForNavigation({ waitUntil: "networkidle2", timeout: 10000 }),
190
+ network.waitForSilence(3000, 10000)
191
+ ]);
192
+ } catch { /* session might be SPA-based */ }
193
+
194
+ const finalUrl = page.url();
195
+ console.log(`[SmartLogin] Flow finished. Current URL: ${finalUrl}`);
196
+
197
+ // Step 9: Simple success check
198
+ if (finalUrl.includes("login") || finalUrl.includes("signin")) {
199
+ console.warn("[SmartLogin] ⚠ Might still be on login page. Check credentials.");
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Navigate to the post-login URL using SPA-safe method.
205
+ * Tries clicking a matching <a> first, falls back to page.goto().
206
+ */
207
+ async function navigatePostLogin(page, network, targetUrl) {
208
+ console.log(`[Session] Navigating to post-login URL: ${targetUrl}`);
209
+
210
+ try {
211
+ const targetPath = new URL(targetUrl).pathname;
212
+
213
+ // Try SPA-safe click first
214
+ const clicked = await page.evaluate((path) => {
215
+ const link = Array.from(document.querySelectorAll("a[href]")).find((a) => {
216
+ try { return new URL(a.href).pathname === path; } catch { return false; }
217
+ });
218
+ if (link) { link.click(); return true; }
219
+ return false;
220
+ }, targetPath);
221
+
222
+ if (clicked) {
223
+ console.log(`[Session] Clicked SPA link for ${targetPath}`);
224
+ await sleep(2000);
225
+ } else {
226
+ // Fallback: direct navigation (Firebase auth persists in IndexedDB)
227
+ await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 20000 });
228
+ }
229
+
230
+ await network.waitForSilence(3000, 12000);
231
+ await intelligentScroll(page);
232
+ await network.waitForSilence(2000, 5000);
233
+
234
+ // Verify we're authenticated
235
+ const currentPath = new URL(page.url()).pathname;
236
+ if (currentPath.includes("login") || currentPath.includes("signin")) {
237
+ console.warn("[Session] ⚠ Post-login URL redirected to login page");
238
+ } else {
239
+ console.log(`[Session] Post-login URL loaded: ${page.url()}`);
240
+ }
241
+ console.log(`[Session] Captured: ${network.count} APIs`);
242
+ } catch (err) {
243
+ console.warn("[Session] Post-login navigation failed:", err.message);
244
+ }
245
+ }
246
+
247
+ // ─── SessionEngine Class ────────────────────────────────────────────────────
248
+
249
+ class SessionEngine extends EventEmitter {
250
+ constructor() {
251
+ super();
252
+ this.browser = null;
253
+ this.page = null;
254
+ this.network = null;
255
+ this._running = false;
256
+ }
257
+
258
+ /**
259
+ * Run an automated scan: load page, login, explore, return results.
260
+ *
261
+ * @param {string} url - Target URL to scan
262
+ * @param {object} options
263
+ * @param {number} [options.waitTime=10000] - Max wait time for network silence after page load
264
+ * @param {object|null} [options.login] - Login credentials & selectors
265
+ * @param {boolean} [options.deepExplore=false] - Run full deep exploration
266
+ * @param {string|null} [options.postLoginUrl] - URL to navigate after login
267
+ * @returns {Promise<object[]>} Array of captured API requests
268
+ */
269
+ async runAutoScan(url, options = {}) {
270
+ const {
271
+ waitTime = 10000,
272
+ login = null,
273
+ deepExplore = false,
274
+ postLoginUrl = null,
275
+ keepAlive = false,
276
+ } = options;
277
+
278
+ // Auto-enable exploration when login is provided
279
+ const shouldExplore = !!login;
280
+ const mode = deepExplore ? "deep" : "basic";
281
+
282
+ console.log(`\n${"═".repeat(60)}`);
283
+ console.log(`[Session] Auto scan: ${url}`);
284
+ console.log(`[Session] login=${!!login} explore=${shouldExplore} deep=${deepExplore} wait=${waitTime}ms keepAlive=${keepAlive}`);
285
+ console.log(`${"═".repeat(60)}\n`);
286
+
287
+ try {
288
+ // ── Step 1: Launch browser ──
289
+ await this._launchBrowser(true);
290
+
291
+ // ── Step 2: Start continuous network capture ──
292
+ this.network = new NetworkCollector(this.page, {
293
+ responsePreviewLimit: RESPONSE_PREVIEW_LIMIT,
294
+ });
295
+ await this.network.start();
296
+ this._running = true;
297
+
298
+ // Forward network events for logging
299
+ this.network.on("request", (entry) => {
300
+ this.emit("request", entry);
301
+ });
302
+
303
+ // ── Step 3: Load target URL ──
304
+ console.log("[Session] Loading target URL...");
305
+ try {
306
+ await this.page.goto(url, { waitUntil: "networkidle2", timeout: 30000 });
307
+ } catch (err) {
308
+ console.warn("[Session] Page load warning:", err.message);
309
+ }
310
+
311
+ // Wait for initial API burst
312
+ console.log("[Session] Waiting for initial APIs...");
313
+ await this.network.waitForSilence(3000, 12000);
314
+ console.log(`[Session] Initial load: ${this.network.count} APIs captured`);
315
+
316
+ // ── Step 4: Login flow ──
317
+ if (login) {
318
+ await performLogin(this.page, this.network, login);
319
+ }
320
+
321
+ // ── Step 5: Post-login URL ──
322
+ if (postLoginUrl) {
323
+ await navigatePostLogin(this.page, this.network, postLoginUrl);
324
+ }
325
+
326
+ // ── Step 6: Scroll the current page ──
327
+ await intelligentScroll(this.page);
328
+ await this.network.waitForSilence(2000, 5000);
329
+ console.log(`[Session] After scroll: ${this.network.count} APIs captured`);
330
+
331
+ // ── Step 7: Exploration ──
332
+ if (shouldExplore) {
333
+ const maxRoutes = deepExplore ? 10 : 6;
334
+ await explore(this.page, this.network, mode, maxRoutes);
335
+ } else {
336
+ // Even without login, wait the full waitTime for any delayed APIs
337
+ await this.network.waitForSilence(3000, waitTime);
338
+ }
339
+
340
+ // ── Step 8: Final wait for trailing responses ──
341
+ await sleep(2000);
342
+ await this.network.waitForSilence(2000, 4000);
343
+
344
+ // ── Step 9: Collect results ──
345
+ const results = this.network.getResults();
346
+ console.log(`\n${"═".repeat(60)}`);
347
+ console.log(`[Session] DONE. Total: ${results.length} API calls captured.`);
348
+ console.log(`${"═".repeat(60)}\n`);
349
+
350
+ return results;
351
+ } finally {
352
+ // If keepAlive, stop the network collector but keep the browser open
353
+ if (keepAlive) {
354
+ console.log(`[Session] keepAlive=true — browser stays open for replay`);
355
+ if (this.network) {
356
+ await this.network.stop();
357
+ }
358
+ // Do NOT close browser or page
359
+ } else {
360
+ await this._cleanup();
361
+ }
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Start a manual session: browser opens visually, user interacts,
367
+ * APIs are captured until stopManualSession() is called.
368
+ *
369
+ * @param {string} url - Target URL
370
+ * @param {object} options
371
+ * @param {object|null} [options.login] - Auto-login before handing control to user
372
+ * @param {string|null} [options.postLoginUrl] - Navigate here after login
373
+ * @returns {string} Session ID (used to stream results / stop)
374
+ */
375
+ async startManualSession(url, options = {}) {
376
+ const { login = null, postLoginUrl = null } = options;
377
+
378
+ console.log(`\n${"═".repeat(60)}`);
379
+ console.log(`[Session] Manual mode: ${url}`);
380
+ console.log(`${"═".repeat(60)}\n`);
381
+
382
+ // ── Launch visible browser ──
383
+ await this._launchBrowser(false);
384
+
385
+ // ── Start network capture ──
386
+ this.network = new NetworkCollector(this.page, {
387
+ responsePreviewLimit: RESPONSE_PREVIEW_LIMIT,
388
+ });
389
+ await this.network.start();
390
+ this._running = true;
391
+
392
+ // Forward events for SSE streaming
393
+ this.network.on("complete", (entry) => this.emit("apiCaptured", entry));
394
+ this.network.on("failed", (entry) => this.emit("apiCaptured", entry));
395
+
396
+ // ── Load page ──
397
+ try {
398
+ await this.page.goto(url, { waitUntil: "networkidle2", timeout: 30000 });
399
+ } catch (err) {
400
+ console.warn("[Session] Manual: page load warning:", err.message);
401
+ }
402
+
403
+ await this.network.waitForSilence(3000, 10000);
404
+ console.log(`[Session] Manual: page loaded, ${this.network.count} initial APIs`);
405
+
406
+ // ── Optional auto-login ──
407
+ if (login) {
408
+ await performLogin(this.page, this.network, login);
409
+ if (postLoginUrl) {
410
+ await navigatePostLogin(this.page, this.network, postLoginUrl);
411
+ }
412
+ }
413
+
414
+ console.log("[Session] Manual mode active — interact with the browser.");
415
+ console.log("[Session] APIs are being captured in real-time.");
416
+ }
417
+
418
+ /**
419
+ * Stop a manual session. If keepAlive is true, the browser stays
420
+ * open for replay; otherwise it is closed.
421
+ *
422
+ * @param {boolean} keepAlive - Keep browser open for replay
423
+ */
424
+ async stopManualSession(keepAlive = false) {
425
+ console.log("[Session] Stopping manual session...");
426
+ const results = this.network ? this.network.getResults() : [];
427
+
428
+ if (keepAlive) {
429
+ console.log(`[Session] keepAlive=true — browser stays open for replay`);
430
+ if (this.network) {
431
+ await this.network.stop();
432
+ }
433
+ this._running = false;
434
+ } else {
435
+ await this._cleanup();
436
+ }
437
+
438
+ console.log(`[Session] Manual session ended. Total: ${results.length} APIs captured.`);
439
+ return results;
440
+ }
441
+
442
+ /**
443
+ * Execute a fetch request INSIDE the Puppeteer page context.
444
+ * This carries the page's cookies, auth tokens, localStorage, IndexedDB,
445
+ * and service-worker state — making it work for authenticated APIs.
446
+ *
447
+ * @param {object} config
448
+ * @param {string} config.url
449
+ * @param {string} config.method
450
+ * @param {object} [config.headers]
451
+ * @param {string} [config.body]
452
+ * @returns {Promise<{status, statusText, headers, body}>}
453
+ */
454
+ async replayInContext(config) {
455
+ if (!this.page) {
456
+ throw new Error("No active browser session for replay");
457
+ }
458
+
459
+ // Check whether the page is still alive
460
+ try {
461
+ await this.page.evaluate(() => document.readyState);
462
+ } catch {
463
+ throw new Error("Browser session has been closed or crashed");
464
+ }
465
+
466
+ console.log(`[Replay] In-context: ${config.method} ${config.url}`);
467
+
468
+ const result = await this.page.evaluate(async (cfg) => {
469
+ try {
470
+ const fetchOpts = {
471
+ method: cfg.method || "GET",
472
+ headers: cfg.headers || {},
473
+ credentials: "include", // ← sends cookies
474
+ };
475
+
476
+ // Attach body for non-GET/HEAD
477
+ if (cfg.body && cfg.method !== "GET" && cfg.method !== "HEAD") {
478
+ fetchOpts.body = cfg.body;
479
+ }
480
+
481
+ const response = await fetch(cfg.url, fetchOpts);
482
+
483
+ // Collect response headers
484
+ const respHeaders = {};
485
+ response.headers.forEach((value, key) => {
486
+ respHeaders[key] = value;
487
+ });
488
+
489
+ // Read body (limit to ~50KB to avoid memory issues)
490
+ let body = "";
491
+ try {
492
+ body = await response.text();
493
+ if (body.length > 50000) {
494
+ body = body.substring(0, 50000) + "\n... [truncated]";
495
+ }
496
+ } catch {
497
+ body = "[Could not read response body]";
498
+ }
499
+
500
+ return {
501
+ success: true,
502
+ status: response.status,
503
+ statusText: response.statusText,
504
+ headers: respHeaders,
505
+ body,
506
+ };
507
+ } catch (err) {
508
+ return {
509
+ success: false,
510
+ error: err.message || "Fetch failed inside browser context",
511
+ };
512
+ }
513
+ }, config);
514
+
515
+ if (!result.success) {
516
+ throw new Error(result.error);
517
+ }
518
+
519
+ console.log(`[Replay] Response: ${result.status} ${result.statusText}`);
520
+ return result;
521
+ }
522
+
523
+ /** Check if session is running. */
524
+ get isRunning() {
525
+ return this._running;
526
+ }
527
+
528
+ /** Get current capture count. */
529
+ get captureCount() {
530
+ return this.network ? this.network.count : 0;
531
+ }
532
+
533
+ /**
534
+ * Check if the browser/page is still usable for replay.
535
+ */
536
+ get isAlive() {
537
+ return !!(this.browser && this.page);
538
+ }
539
+
540
+ /**
541
+ * Explicitly close the browser session.
542
+ * Used by sessionStore cleanup.
543
+ */
544
+ async close() {
545
+ await this._cleanup();
546
+ }
547
+
548
+ // ─── Private ────────────────────────────────────────────────────────────
549
+
550
+ async _launchBrowser(headless = true) {
551
+ this.browser = await puppeteer.launch({
552
+ executablePath: CHROME_PATH,
553
+ headless: headless ? "new" : false,
554
+ args: [
555
+ "--no-sandbox",
556
+ "--disable-setuid-sandbox",
557
+ "--disable-blink-features=AutomationControlled",
558
+ "--disable-web-security",
559
+ "--disable-features=VizDisplayCompositor",
560
+ "--allow-insecure-localhost",
561
+ "--ignore-certificate-errors",
562
+ ],
563
+ });
564
+
565
+ this.page = await this.browser.newPage();
566
+
567
+ // Anti-detection
568
+ await this.page.evaluateOnNewDocument(() => {
569
+ Object.defineProperty(navigator, "webdriver", { get: () => false });
570
+ });
571
+
572
+ await this.page.setUserAgent(
573
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
574
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
575
+ );
576
+
577
+ await this.page.setViewport({ width: 1280, height: 800 });
578
+
579
+ // Handle unexpected dialogs
580
+ this.page.on("dialog", async (dialog) => {
581
+ try { await dialog.dismiss(); } catch {}
582
+ });
583
+ }
584
+
585
+ async _cleanup() {
586
+ this._running = false;
587
+ if (this.network) {
588
+ await this.network.stop();
589
+ this.network = null;
590
+ }
591
+ if (this.browser) {
592
+ try { await this.browser.close(); } catch {}
593
+ this.browser = null;
594
+ this.page = null;
595
+ }
596
+ }
597
+ }
598
+
599
+ // ─── Convenience Function (backward compatible) ─────────────────────────────
600
+
601
+ /**
602
+ * Drop-in replacement for the old interceptRequests function.
603
+ * Creates a SessionEngine, runs an auto scan, returns results.
604
+ */
605
+ async function interceptRequests(url, options = {}) {
606
+ const {
607
+ waitTime = 10000,
608
+ login = null,
609
+ explorePages = false,
610
+ postLoginUrl = null,
611
+ } = options;
612
+
613
+ const engine = new SessionEngine();
614
+ return engine.runAutoScan(url, {
615
+ waitTime,
616
+ login,
617
+ deepExplore: explorePages,
618
+ postLoginUrl,
619
+ });
620
+ }
621
+
622
+ module.exports = { SessionEngine, interceptRequests };
@@ -0,0 +1,30 @@
1
+ const puppeteer = require("puppeteer-core");
2
+
3
+ const CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
4
+
5
+ async function launchAndVisit(url) {
6
+ console.log(`[Puppeteer] Launching browser for: ${url}`);
7
+
8
+ const browser = await puppeteer.launch({
9
+ executablePath: CHROME_PATH, // ← point to your local Chrome
10
+ headless: "new",
11
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
12
+ });
13
+
14
+ const page = await browser.newPage();
15
+
16
+ await page.goto(url, {
17
+ waitUntil: "networkidle2",
18
+ timeout: 30000,
19
+ });
20
+
21
+ const title = await page.title();
22
+ console.log(`[Puppeteer] Page title: "${title}"`);
23
+
24
+ await browser.close();
25
+ console.log("[Puppeteer] Browser closed.");
26
+
27
+ return { title };
28
+ }
29
+
30
+ launchAndVisit("https://example.com").then(console.log).catch(console.error);