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.
- package/bin/cli.js +56 -0
- package/config.js +6 -0
- package/dist/assets/index-BPZbQMS8.js +12 -0
- package/dist/assets/index-BeP94nNh.css +1 -0
- package/dist/favicon.svg +1 -0
- package/dist/icons.svg +24 -0
- package/dist/index.html +24 -0
- package/package.json +36 -0
- package/puppeteer/debug.js +82 -0
- package/puppeteer/explorer.js +507 -0
- package/puppeteer/interceptor.js +622 -0
- package/puppeteer/launcher.js +30 -0
- package/puppeteer/network.js +253 -0
- package/puppeteer/sessionStore.js +140 -0
- package/routes/scan.js +334 -0
- package/server.js +44 -0
|
@@ -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);
|