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,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InteractionEngine — Systematic UI exploration.
|
|
3
|
+
*
|
|
4
|
+
* Designed to trigger every API call a real user might cause by:
|
|
5
|
+
* Phase 1: Navigate SPA routes (sidebar links, nav tabs, menu items)
|
|
6
|
+
* Phase 2: Interact with page content (tabs, accordions, cards, dropdowns)
|
|
7
|
+
* Phase 3: Scroll to trigger lazy-loaded content
|
|
8
|
+
* Phase 4: Trigger data-fetching patterns (search, filters, pagination)
|
|
9
|
+
*
|
|
10
|
+
* Safety: never clicks logout, delete, or destructive elements.
|
|
11
|
+
* Stability: every interaction is wrapped in try/catch with timeouts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ─── Safety ─────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const DANGEROUS_KEYWORDS = [
|
|
17
|
+
"logout", "log out", "log-out",
|
|
18
|
+
"sign out", "sign-out", "signout",
|
|
19
|
+
"delete", "remove", "destroy",
|
|
20
|
+
"cancel subscription", "unsubscribe",
|
|
21
|
+
"deactivate", "close account", "terminate",
|
|
22
|
+
"reset password", "change password",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function isDangerous(text) {
|
|
26
|
+
const lower = (text || "").toLowerCase().trim();
|
|
27
|
+
return DANGEROUS_KEYWORDS.some((kw) => lower.includes(kw));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isHidden(el) {
|
|
31
|
+
const style = window.getComputedStyle(el);
|
|
32
|
+
return (
|
|
33
|
+
style.display === "none" ||
|
|
34
|
+
style.visibility === "hidden" ||
|
|
35
|
+
style.opacity === "0" ||
|
|
36
|
+
el.offsetWidth === 0 ||
|
|
37
|
+
el.offsetHeight === 0
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Scroll Engine ──────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Scroll the page in steps to trigger lazy-loaded content.
|
|
45
|
+
* Returns early if scroll height stops changing.
|
|
46
|
+
*/
|
|
47
|
+
async function intelligentScroll(page) {
|
|
48
|
+
try {
|
|
49
|
+
await page.evaluate(async () => {
|
|
50
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
51
|
+
let previousHeight = 0;
|
|
52
|
+
let currentHeight = document.body.scrollHeight;
|
|
53
|
+
const step = Math.max(300, Math.floor(currentHeight / 6));
|
|
54
|
+
|
|
55
|
+
// Scroll down in steps
|
|
56
|
+
for (let y = 0; y < currentHeight; y += step) {
|
|
57
|
+
window.scrollTo({ top: y, behavior: "smooth" });
|
|
58
|
+
await delay(350);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Scroll to absolute bottom
|
|
62
|
+
window.scrollTo({ top: currentHeight, behavior: "smooth" });
|
|
63
|
+
await delay(600);
|
|
64
|
+
|
|
65
|
+
// Check if page grew (infinite scroll)
|
|
66
|
+
const newHeight = document.body.scrollHeight;
|
|
67
|
+
if (newHeight > currentHeight) {
|
|
68
|
+
// One more round for the new content
|
|
69
|
+
for (let y = currentHeight; y < newHeight; y += step) {
|
|
70
|
+
window.scrollTo({ top: y, behavior: "smooth" });
|
|
71
|
+
await delay(350);
|
|
72
|
+
}
|
|
73
|
+
window.scrollTo({ top: newHeight, behavior: "smooth" });
|
|
74
|
+
await delay(400);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Back to top
|
|
78
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
79
|
+
await delay(300);
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
// Page may have navigated during scroll — safe to ignore
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Route Explorer (Phase 1) ───────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Discover and click all navigation-like elements: <a>, sidebar <button>,
|
|
90
|
+
* tab controls, menu items, React Router <Link> components.
|
|
91
|
+
* Clicks each via the DOM (SPA-safe — no page.goto).
|
|
92
|
+
*/
|
|
93
|
+
async function exploreRoutes(page, network, maxPages = 8) {
|
|
94
|
+
console.log("[Explorer] Phase 1: Navigating SPA routes...");
|
|
95
|
+
const startCount = network.count;
|
|
96
|
+
const visitedPaths = new Set();
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
visitedPaths.add(new URL(page.url()).pathname);
|
|
100
|
+
} catch {}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const origin = await page.evaluate(() => window.location.origin);
|
|
104
|
+
|
|
105
|
+
// Gather every plausible navigation element
|
|
106
|
+
const navItems = await page.evaluate((orig) => {
|
|
107
|
+
const items = [];
|
|
108
|
+
const seen = new Set();
|
|
109
|
+
|
|
110
|
+
const selectors = [
|
|
111
|
+
"a[href]",
|
|
112
|
+
"nav button", "nav [role='button']",
|
|
113
|
+
"[class*='sidebar'] a", "[class*='sidebar'] button",
|
|
114
|
+
"[class*='Sidebar'] a", "[class*='Sidebar'] button",
|
|
115
|
+
"[class*='nav'] a", "[class*='nav'] button",
|
|
116
|
+
"[class*='Nav'] a", "[class*='Nav'] button",
|
|
117
|
+
"[role='navigation'] a", "[role='navigation'] button",
|
|
118
|
+
"[role='tab']", "[role='menuitem']",
|
|
119
|
+
"[class*='menu'] a", "[class*='menu'] button",
|
|
120
|
+
"[class*='Menu'] a", "[class*='Menu'] button",
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
for (const sel of selectors) {
|
|
124
|
+
try {
|
|
125
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
126
|
+
const text = (el.innerText || "").trim().slice(0, 80);
|
|
127
|
+
if (!text) continue;
|
|
128
|
+
const href = el.href || "";
|
|
129
|
+
|
|
130
|
+
// Skip external, javascript:, and hash-only links
|
|
131
|
+
if (href && !href.startsWith(orig) && !href.startsWith("/") && !href.startsWith("#")) continue;
|
|
132
|
+
if (href.startsWith("javascript:")) continue;
|
|
133
|
+
|
|
134
|
+
// Derive a unique key from the pathname or text
|
|
135
|
+
let key;
|
|
136
|
+
try {
|
|
137
|
+
key = href ? new URL(href, orig).pathname : `__text__${text}`;
|
|
138
|
+
} catch {
|
|
139
|
+
key = `__text__${text}`;
|
|
140
|
+
}
|
|
141
|
+
if (seen.has(key)) continue;
|
|
142
|
+
seen.add(key);
|
|
143
|
+
|
|
144
|
+
// Build a stable re-find strategy: id > unique selector > text
|
|
145
|
+
let findBy = null;
|
|
146
|
+
if (el.id) {
|
|
147
|
+
findBy = { type: "id", value: el.id };
|
|
148
|
+
} else {
|
|
149
|
+
findBy = { type: "text", value: text, tag: el.tagName.toLowerCase() };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
items.push({ text, href, key, findBy });
|
|
153
|
+
}
|
|
154
|
+
} catch { /* selector failed — skip */ }
|
|
155
|
+
}
|
|
156
|
+
return items;
|
|
157
|
+
}, origin);
|
|
158
|
+
|
|
159
|
+
// Filter out dangerous and already-visited
|
|
160
|
+
const safe = navItems.filter((item) => {
|
|
161
|
+
if (isDangerous(item.text)) return false;
|
|
162
|
+
if (visitedPaths.has(item.key)) return false;
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const toVisit = safe.slice(0, maxPages);
|
|
167
|
+
console.log(`[Explorer] Found ${navItems.length} nav elements → visiting ${toVisit.length}`);
|
|
168
|
+
|
|
169
|
+
for (const item of toVisit) {
|
|
170
|
+
try {
|
|
171
|
+
console.log(`[Explorer] → "${item.text}" (${item.key})`);
|
|
172
|
+
|
|
173
|
+
const clicked = await page.evaluate((findBy, txt) => {
|
|
174
|
+
let el = null;
|
|
175
|
+
if (findBy.type === "id") {
|
|
176
|
+
el = document.getElementById(findBy.value);
|
|
177
|
+
}
|
|
178
|
+
if (!el) {
|
|
179
|
+
// Text-based fallback
|
|
180
|
+
const tag = findBy.tag || "*";
|
|
181
|
+
el = Array.from(document.querySelectorAll(`${tag}, a, button, [role='tab'], [role='menuitem']`))
|
|
182
|
+
.find((e) => (e.innerText || "").trim() === txt);
|
|
183
|
+
}
|
|
184
|
+
if (el) { el.click(); return true; }
|
|
185
|
+
return false;
|
|
186
|
+
}, item.findBy, item.text);
|
|
187
|
+
|
|
188
|
+
if (!clicked) continue;
|
|
189
|
+
|
|
190
|
+
await sleep(1200);
|
|
191
|
+
await network.waitForSilence(2000, 8000);
|
|
192
|
+
await intelligentScroll(page);
|
|
193
|
+
await network.waitForSilence(1500, 4000);
|
|
194
|
+
|
|
195
|
+
visitedPaths.add(item.key);
|
|
196
|
+
try { visitedPaths.add(new URL(page.url()).pathname); } catch {}
|
|
197
|
+
|
|
198
|
+
console.log(`[Explorer] ✓ ${page.url()} | +${network.count - startCount} new APIs`);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.warn(`[Explorer] ✗ "${item.text}" failed: ${err.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.warn("[Explorer] Route exploration error:", err.message);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return visitedPaths;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Content Interactor (Phase 2) ───────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* On the current page, interact with tabs, accordions, cards, dropdowns,
|
|
214
|
+
* modals, "load more" buttons — anything that might trigger API calls.
|
|
215
|
+
*/
|
|
216
|
+
async function interactWithContent(page, network) {
|
|
217
|
+
console.log("[Explorer] Phase 2: Interacting with page content...");
|
|
218
|
+
const startCount = network.count;
|
|
219
|
+
|
|
220
|
+
// --- 2a: Click tab-like elements ---
|
|
221
|
+
await clickElements(page, network, "tabs", [
|
|
222
|
+
"[role='tab']:not([aria-selected='true'])",
|
|
223
|
+
"button[data-tab]", "a[data-tab]",
|
|
224
|
+
".tab:not(.active)", ".tab-item:not(.active)",
|
|
225
|
+
], 6);
|
|
226
|
+
|
|
227
|
+
// --- 2b: Expand accordions / collapsed sections ---
|
|
228
|
+
await clickElements(page, network, "accordions", [
|
|
229
|
+
"[aria-expanded='false']",
|
|
230
|
+
"details > summary",
|
|
231
|
+
"[class*='accordion'] button",
|
|
232
|
+
"[class*='Accordion'] button",
|
|
233
|
+
"[class*='collapse'] button",
|
|
234
|
+
"[class*='Collapse'] button",
|
|
235
|
+
], 5);
|
|
236
|
+
|
|
237
|
+
// --- 2c: Click data cards / list rows that look interactive ---
|
|
238
|
+
await clickElements(page, network, "cards", [
|
|
239
|
+
"[class*='card'][role='button']", "[class*='Card'][role='button']",
|
|
240
|
+
"tr[role='button']", "tr[class*='click']",
|
|
241
|
+
"[class*='list-item'][role='button']",
|
|
242
|
+
"[class*='row'][role='button']",
|
|
243
|
+
], 4);
|
|
244
|
+
|
|
245
|
+
// --- 2d: Click "Load more" / "Show more" / pagination ---
|
|
246
|
+
try {
|
|
247
|
+
const loadMoreCount = await page.evaluate(() => {
|
|
248
|
+
const keywords = ["load more", "show more", "view all", "see all", "next page", "show all", "read more"];
|
|
249
|
+
let count = 0;
|
|
250
|
+
for (const el of document.querySelectorAll("button, a, [role='button']")) {
|
|
251
|
+
const text = (el.innerText || "").toLowerCase().trim();
|
|
252
|
+
if (keywords.some((kw) => text.includes(kw))) {
|
|
253
|
+
el.click();
|
|
254
|
+
count++;
|
|
255
|
+
if (count >= 4) break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return count;
|
|
259
|
+
});
|
|
260
|
+
if (loadMoreCount > 0) {
|
|
261
|
+
console.log(`[Explorer] "Load more" × ${loadMoreCount}`);
|
|
262
|
+
await sleep(800);
|
|
263
|
+
await network.waitForSilence(2000, 5000);
|
|
264
|
+
}
|
|
265
|
+
} catch { /* no load-more buttons */ }
|
|
266
|
+
|
|
267
|
+
// --- 2e: Open dropdown / select menus ---
|
|
268
|
+
await clickElements(page, network, "dropdowns", [
|
|
269
|
+
"[class*='dropdown'] button", "[class*='Dropdown'] button",
|
|
270
|
+
"[role='combobox']",
|
|
271
|
+
"[class*='filter'] button", "[class*='Filter'] button",
|
|
272
|
+
], 3, async (page) => {
|
|
273
|
+
// After opening a dropdown, click the first option
|
|
274
|
+
try {
|
|
275
|
+
await page.evaluate(() => {
|
|
276
|
+
const opt = document.querySelector(
|
|
277
|
+
"[role='option'], [role='menuitem'], [class*='dropdown'] li, [class*='option']"
|
|
278
|
+
);
|
|
279
|
+
if (opt) opt.click();
|
|
280
|
+
});
|
|
281
|
+
await sleep(400);
|
|
282
|
+
} catch {}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// --- 2f: Click generic interactive buttons (not in nav) ---
|
|
286
|
+
try {
|
|
287
|
+
const buttons = await page.evaluate(() => {
|
|
288
|
+
const results = [];
|
|
289
|
+
for (const btn of document.querySelectorAll("main button, [class*='content'] button, section button")) {
|
|
290
|
+
const text = (btn.innerText || "").trim();
|
|
291
|
+
if (!text || text.length > 40) continue;
|
|
292
|
+
// Skip buttons we already handled (tabs, accordions, dropdowns, nav)
|
|
293
|
+
if (btn.closest("nav, [role='navigation'], [role='tablist'], [class*='dropdown']")) continue;
|
|
294
|
+
const style = window.getComputedStyle(btn);
|
|
295
|
+
if (style.display === "none" || style.visibility === "hidden") continue;
|
|
296
|
+
if (btn.id) results.push({ findBy: "id", value: btn.id, text });
|
|
297
|
+
else results.push({ findBy: "text", value: text, text });
|
|
298
|
+
}
|
|
299
|
+
return results.slice(0, 6);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
for (const btn of buttons) {
|
|
303
|
+
if (isDangerous(btn.text)) continue;
|
|
304
|
+
try {
|
|
305
|
+
console.log(`[Explorer] Button: "${btn.text.slice(0, 40)}"`);
|
|
306
|
+
await page.evaluate((b) => {
|
|
307
|
+
let el = b.findBy === "id" ? document.getElementById(b.value) : null;
|
|
308
|
+
if (!el) {
|
|
309
|
+
el = Array.from(document.querySelectorAll("button"))
|
|
310
|
+
.find((e) => (e.innerText || "").trim() === b.value);
|
|
311
|
+
}
|
|
312
|
+
if (el) el.click();
|
|
313
|
+
}, btn);
|
|
314
|
+
await sleep(600);
|
|
315
|
+
await network.waitForSilence(1500, 4000);
|
|
316
|
+
} catch {}
|
|
317
|
+
}
|
|
318
|
+
} catch {}
|
|
319
|
+
|
|
320
|
+
console.log(`[Explorer] Phase 2 done: +${network.count - startCount} new APIs`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── Data Pattern Triggers (Phase 3) ────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Type into search inputs, toggle filters, click refresh buttons
|
|
327
|
+
* to trigger data-fetching API calls.
|
|
328
|
+
*/
|
|
329
|
+
async function triggerDataPatterns(page, network) {
|
|
330
|
+
console.log("[Explorer] Phase 3: Triggering data patterns...");
|
|
331
|
+
const startCount = network.count;
|
|
332
|
+
|
|
333
|
+
// --- 3a: Type into search inputs ---
|
|
334
|
+
try {
|
|
335
|
+
const searchInputs = await page.$$(
|
|
336
|
+
"input[type='search'], input[placeholder*='search' i], " +
|
|
337
|
+
"input[name*='search' i], input[class*='search' i], " +
|
|
338
|
+
"input[aria-label*='search' i]"
|
|
339
|
+
);
|
|
340
|
+
for (const input of searchInputs.slice(0, 2)) {
|
|
341
|
+
console.log("[Explorer] Typing in search...");
|
|
342
|
+
await input.click();
|
|
343
|
+
await input.type("a", { delay: 120 });
|
|
344
|
+
await sleep(600);
|
|
345
|
+
await network.waitForSilence(1500, 4000);
|
|
346
|
+
// Clear and try another character
|
|
347
|
+
await input.click({ clickCount: 3 });
|
|
348
|
+
await input.press("Backspace");
|
|
349
|
+
await sleep(400);
|
|
350
|
+
}
|
|
351
|
+
} catch {}
|
|
352
|
+
|
|
353
|
+
// --- 3b: Click refresh / reload / sync buttons ---
|
|
354
|
+
try {
|
|
355
|
+
const clicked = await page.evaluate(() => {
|
|
356
|
+
const keywords = ["refresh", "reload", "retry", "sync"];
|
|
357
|
+
for (const el of document.querySelectorAll("button, [role='button']")) {
|
|
358
|
+
const text = (el.innerText || "").toLowerCase().trim();
|
|
359
|
+
const label = (el.getAttribute("aria-label") || "").toLowerCase();
|
|
360
|
+
if (keywords.some((kw) => text.includes(kw) || label.includes(kw))) {
|
|
361
|
+
el.click();
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return false;
|
|
366
|
+
});
|
|
367
|
+
if (clicked) {
|
|
368
|
+
console.log("[Explorer] Clicked refresh");
|
|
369
|
+
await network.waitForSilence(2000, 5000);
|
|
370
|
+
}
|
|
371
|
+
} catch {}
|
|
372
|
+
|
|
373
|
+
// --- 3c: Toggle filter checkboxes ---
|
|
374
|
+
try {
|
|
375
|
+
const checkboxes = await page.$$(
|
|
376
|
+
"[class*='filter'] input[type='checkbox'], " +
|
|
377
|
+
"[class*='Filter'] input[type='checkbox']"
|
|
378
|
+
);
|
|
379
|
+
for (const cb of checkboxes.slice(0, 3)) {
|
|
380
|
+
console.log("[Explorer] Toggling filter checkbox");
|
|
381
|
+
await cb.click();
|
|
382
|
+
await sleep(400);
|
|
383
|
+
await network.waitForSilence(1500, 3000);
|
|
384
|
+
}
|
|
385
|
+
} catch {}
|
|
386
|
+
|
|
387
|
+
// --- 3d: Click pagination next/prev ---
|
|
388
|
+
try {
|
|
389
|
+
const pagClicked = await page.evaluate(() => {
|
|
390
|
+
for (const el of document.querySelectorAll("button, a, [role='button']")) {
|
|
391
|
+
const text = (el.innerText || "").trim().toLowerCase();
|
|
392
|
+
const label = (el.getAttribute("aria-label") || "").toLowerCase();
|
|
393
|
+
if (text === "next" || text === ">" || text === "›" || label.includes("next page")) {
|
|
394
|
+
el.click();
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
});
|
|
400
|
+
if (pagClicked) {
|
|
401
|
+
console.log("[Explorer] Clicked pagination next");
|
|
402
|
+
await sleep(600);
|
|
403
|
+
await network.waitForSilence(2000, 5000);
|
|
404
|
+
}
|
|
405
|
+
} catch {}
|
|
406
|
+
|
|
407
|
+
console.log(`[Explorer] Phase 3 done: +${network.count - startCount} new APIs`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ─── Main Orchestrator ──────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Run full exploration: routes → content interactions → scroll → data patterns.
|
|
414
|
+
*
|
|
415
|
+
* @param {import('puppeteer-core').Page} page
|
|
416
|
+
* @param {import('./network').NetworkCollector} network
|
|
417
|
+
* @param {'basic'|'deep'} mode - 'basic' = Phase 1 only, 'deep' = all phases
|
|
418
|
+
* @param {number} maxRoutes - Maximum number of routes to explore
|
|
419
|
+
*/
|
|
420
|
+
async function explore(page, network, mode = "basic", maxRoutes = 8) {
|
|
421
|
+
const beforeCount = network.count;
|
|
422
|
+
console.log(`[Explorer] Starting ${mode} exploration...`);
|
|
423
|
+
|
|
424
|
+
// Phase 1: Always run — navigate SPA routes
|
|
425
|
+
const visitedPaths = await exploreRoutes(page, network, maxRoutes);
|
|
426
|
+
console.log(`[Explorer] After routes: ${network.count} total (was ${beforeCount})`);
|
|
427
|
+
|
|
428
|
+
// Phase 2: Deep only — interact with content on current page
|
|
429
|
+
if (mode === "deep") {
|
|
430
|
+
await interactWithContent(page, network);
|
|
431
|
+
|
|
432
|
+
// Go back to main page and interact there too
|
|
433
|
+
if (visitedPaths.size > 1) {
|
|
434
|
+
try {
|
|
435
|
+
await page.goBack();
|
|
436
|
+
await sleep(1000);
|
|
437
|
+
await network.waitForSilence(1500, 4000);
|
|
438
|
+
await interactWithContent(page, network);
|
|
439
|
+
} catch {}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Phase 3: Always run — scroll for lazy-loaded content
|
|
444
|
+
await intelligentScroll(page);
|
|
445
|
+
await network.waitForSilence(2000, 5000);
|
|
446
|
+
|
|
447
|
+
// Phase 4: Deep only — trigger data patterns
|
|
448
|
+
if (mode === "deep") {
|
|
449
|
+
await triggerDataPatterns(page, network);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const newCount = network.count - beforeCount;
|
|
453
|
+
console.log(`[Explorer] Exploration complete: discovered ${newCount} new API calls`);
|
|
454
|
+
return newCount;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ─── Utility Functions ──────────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Click elements matching the given selectors, safely.
|
|
461
|
+
* @param {number} maxPerSelector - Max elements to click per selector group
|
|
462
|
+
* @param {Function} [afterClick] - Optional callback after each click
|
|
463
|
+
*/
|
|
464
|
+
async function clickElements(page, network, label, selectors, maxTotal = 5, afterClick = null) {
|
|
465
|
+
let clicked = 0;
|
|
466
|
+
for (const sel of selectors) {
|
|
467
|
+
if (clicked >= maxTotal) break;
|
|
468
|
+
try {
|
|
469
|
+
const elements = await page.$$(sel);
|
|
470
|
+
for (const el of elements) {
|
|
471
|
+
if (clicked >= maxTotal) break;
|
|
472
|
+
try {
|
|
473
|
+
const text = await page.evaluate((e) => {
|
|
474
|
+
const style = window.getComputedStyle(e);
|
|
475
|
+
if (style.display === "none" || style.visibility === "hidden") return null;
|
|
476
|
+
return (e.innerText || "").trim().slice(0, 60);
|
|
477
|
+
}, el);
|
|
478
|
+
|
|
479
|
+
if (text === null) continue; // hidden
|
|
480
|
+
if (isDangerous(text)) continue;
|
|
481
|
+
|
|
482
|
+
console.log(`[Explorer] ${label}: "${text.slice(0, 40)}"`);
|
|
483
|
+
await el.click().catch(() => {});
|
|
484
|
+
clicked++;
|
|
485
|
+
await sleep(600);
|
|
486
|
+
await network.waitForSilence(1500, 3500);
|
|
487
|
+
if (afterClick) await afterClick(page);
|
|
488
|
+
} catch { /* element stale or detached */ }
|
|
489
|
+
}
|
|
490
|
+
} catch { /* selector invalid or not found */ }
|
|
491
|
+
}
|
|
492
|
+
return clicked;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function sleep(ms) {
|
|
496
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
module.exports = {
|
|
500
|
+
explore,
|
|
501
|
+
exploreRoutes,
|
|
502
|
+
interactWithContent,
|
|
503
|
+
triggerDataPatterns,
|
|
504
|
+
intelligentScroll,
|
|
505
|
+
isDangerous,
|
|
506
|
+
sleep,
|
|
507
|
+
};
|