glidercli 0.2.0 → 0.3.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/README.md +70 -9
- package/bin/glider.js +615 -68
- package/lib/bexplore.js +815 -0
- package/lib/bextract.js +236 -0
- package/lib/bfetch.js +274 -0
- package/lib/bserve.js +43 -7
- package/lib/bspawn.js +154 -0
- package/lib/bwindow.js +335 -0
- package/lib/cdp-direct.js +305 -0
- package/lib/glider-daemon.sh +31 -0
- package/package.json +1 -1
package/lib/bexplore.js
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bexplore.js - Ruthless site exploration and HAR capture
|
|
4
|
+
* Clicks around, captures everything, maps the entire site
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node bexplore.js <url> [--depth N] [--output dir] [--har file.har]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const WebSocket = require('ws');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const RELAY_URL = process.env.RELAY_URL || 'ws://127.0.0.1:19988/cdp';
|
|
15
|
+
|
|
16
|
+
class SiteExplorer {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.ws = null;
|
|
19
|
+
this.messageId = 0;
|
|
20
|
+
this.pending = new Map();
|
|
21
|
+
this.sessionId = null;
|
|
22
|
+
this.eventHandlers = new Map();
|
|
23
|
+
|
|
24
|
+
// Exploration state
|
|
25
|
+
this.visited = new Set();
|
|
26
|
+
this.toVisit = [];
|
|
27
|
+
this.depth = options.depth || 3;
|
|
28
|
+
this.outputDir = options.outputDir || '/tmp/explore';
|
|
29
|
+
this.harFile = options.harFile;
|
|
30
|
+
|
|
31
|
+
// Captured data
|
|
32
|
+
this.requests = [];
|
|
33
|
+
this.responses = [];
|
|
34
|
+
this.scripts = [];
|
|
35
|
+
this.stylesheets = [];
|
|
36
|
+
this.websockets = [];
|
|
37
|
+
this.consoleMessages = [];
|
|
38
|
+
this.errors = [];
|
|
39
|
+
|
|
40
|
+
// Site map
|
|
41
|
+
this.siteMap = {
|
|
42
|
+
url: null,
|
|
43
|
+
title: null,
|
|
44
|
+
tabs: [],
|
|
45
|
+
buttons: [],
|
|
46
|
+
links: [],
|
|
47
|
+
forms: [],
|
|
48
|
+
tables: [],
|
|
49
|
+
modals: [],
|
|
50
|
+
filters: [],
|
|
51
|
+
pagination: null,
|
|
52
|
+
infiniteScroll: false,
|
|
53
|
+
sidePanels: [],
|
|
54
|
+
dropdowns: [],
|
|
55
|
+
checkboxes: [],
|
|
56
|
+
screenshots: []
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async connect() {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
this.ws = new WebSocket(RELAY_URL);
|
|
63
|
+
this.ws.on('open', resolve);
|
|
64
|
+
this.ws.on('error', reject);
|
|
65
|
+
this.ws.on('message', (data) => this._handleMessage(JSON.parse(data.toString())));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_handleMessage(msg) {
|
|
70
|
+
if (msg.id !== undefined) {
|
|
71
|
+
const pending = this.pending.get(msg.id);
|
|
72
|
+
if (pending) {
|
|
73
|
+
this.pending.delete(msg.id);
|
|
74
|
+
msg.error ? pending.reject(new Error(msg.error.message)) : pending.resolve(msg.result);
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (msg.method === 'Target.attachedToTarget') {
|
|
80
|
+
if (!this.sessionId) {
|
|
81
|
+
this.sessionId = msg.params.sessionId;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Capture EVERYTHING
|
|
86
|
+
if (msg.method === 'Network.requestWillBeSent') {
|
|
87
|
+
this.requests.push({
|
|
88
|
+
id: msg.params.requestId,
|
|
89
|
+
url: msg.params.request.url,
|
|
90
|
+
method: msg.params.request.method,
|
|
91
|
+
headers: msg.params.request.headers,
|
|
92
|
+
postData: msg.params.request.postData,
|
|
93
|
+
type: msg.params.type,
|
|
94
|
+
timestamp: msg.params.timestamp,
|
|
95
|
+
initiator: msg.params.initiator
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (msg.method === 'Network.responseReceived') {
|
|
100
|
+
this.responses.push({
|
|
101
|
+
id: msg.params.requestId,
|
|
102
|
+
url: msg.params.response.url,
|
|
103
|
+
status: msg.params.response.status,
|
|
104
|
+
headers: msg.params.response.headers,
|
|
105
|
+
mimeType: msg.params.response.mimeType,
|
|
106
|
+
timestamp: msg.params.timestamp
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (msg.method === 'Network.webSocketCreated') {
|
|
111
|
+
this.websockets.push({
|
|
112
|
+
id: msg.params.requestId,
|
|
113
|
+
url: msg.params.url,
|
|
114
|
+
timestamp: Date.now()
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (msg.method === 'Debugger.scriptParsed') {
|
|
119
|
+
if (msg.params.url && !msg.params.url.startsWith('chrome')) {
|
|
120
|
+
this.scripts.push({
|
|
121
|
+
id: msg.params.scriptId,
|
|
122
|
+
url: msg.params.url,
|
|
123
|
+
length: msg.params.length
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (msg.method === 'CSS.styleSheetAdded') {
|
|
129
|
+
this.stylesheets.push({
|
|
130
|
+
id: msg.params.header.styleSheetId,
|
|
131
|
+
url: msg.params.header.sourceURL,
|
|
132
|
+
length: msg.params.header.length
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (msg.method === 'Runtime.consoleAPICalled') {
|
|
137
|
+
this.consoleMessages.push({
|
|
138
|
+
type: msg.params.type,
|
|
139
|
+
args: msg.params.args.map(a => a.value || a.description),
|
|
140
|
+
timestamp: msg.params.timestamp
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (msg.method === 'Runtime.exceptionThrown') {
|
|
145
|
+
this.errors.push({
|
|
146
|
+
text: msg.params.exceptionDetails.text,
|
|
147
|
+
url: msg.params.exceptionDetails.url,
|
|
148
|
+
line: msg.params.exceptionDetails.lineNumber,
|
|
149
|
+
timestamp: msg.params.timestamp
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const handlers = this.eventHandlers.get(msg.method);
|
|
154
|
+
if (handlers) handlers.forEach(h => h(msg.params));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
on(event, handler) {
|
|
158
|
+
if (!this.eventHandlers.has(event)) this.eventHandlers.set(event, new Set());
|
|
159
|
+
this.eventHandlers.get(event).add(handler);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async send(method, params = {}) {
|
|
163
|
+
const id = ++this.messageId;
|
|
164
|
+
const msg = { id, method, params };
|
|
165
|
+
if (this.sessionId) msg.sessionId = this.sessionId;
|
|
166
|
+
this.ws.send(JSON.stringify(msg));
|
|
167
|
+
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
this.pending.delete(id);
|
|
171
|
+
reject(new Error(`Timeout: ${method}`));
|
|
172
|
+
}, 30000);
|
|
173
|
+
this.pending.set(id, {
|
|
174
|
+
resolve: (r) => { clearTimeout(timer); resolve(r); },
|
|
175
|
+
reject: (e) => { clearTimeout(timer); reject(e); }
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async init() {
|
|
181
|
+
await this.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
182
|
+
await new Promise(r => setTimeout(r, 500));
|
|
183
|
+
if (!this.sessionId) throw new Error('No browser tab connected');
|
|
184
|
+
|
|
185
|
+
// Enable ALL the things
|
|
186
|
+
await Promise.all([
|
|
187
|
+
this.send('Runtime.enable'),
|
|
188
|
+
this.send('Page.enable'),
|
|
189
|
+
this.send('DOM.enable'),
|
|
190
|
+
this.send('CSS.enable'),
|
|
191
|
+
this.send('Network.enable'),
|
|
192
|
+
this.send('Debugger.enable'),
|
|
193
|
+
this.send('Log.enable'),
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
// Preserve log - don't clear on navigation
|
|
197
|
+
await this.send('Network.setCacheDisabled', { cacheDisabled: false });
|
|
198
|
+
|
|
199
|
+
console.error('[explore] All CDP domains enabled');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async evaluate(expression) {
|
|
203
|
+
const result = await this.send('Runtime.evaluate', {
|
|
204
|
+
expression,
|
|
205
|
+
returnByValue: true,
|
|
206
|
+
awaitPromise: true
|
|
207
|
+
});
|
|
208
|
+
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text);
|
|
209
|
+
return result.result.value;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async screenshot(name) {
|
|
213
|
+
const result = await this.send('Page.captureScreenshot', { format: 'png' });
|
|
214
|
+
const filename = `${name}-${Date.now()}.png`;
|
|
215
|
+
const filepath = path.join(this.outputDir, filename);
|
|
216
|
+
fs.writeFileSync(filepath, Buffer.from(result.data, 'base64'));
|
|
217
|
+
this.siteMap.screenshots.push({ name, path: filepath, timestamp: Date.now() });
|
|
218
|
+
console.error(`[explore] Screenshot: ${filename}`);
|
|
219
|
+
return filepath;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async discoverElements() {
|
|
223
|
+
console.error('[explore] Discovering page elements...');
|
|
224
|
+
|
|
225
|
+
const elements = await this.evaluate(`
|
|
226
|
+
(() => {
|
|
227
|
+
const result = {
|
|
228
|
+
tabs: [],
|
|
229
|
+
buttons: [],
|
|
230
|
+
links: [],
|
|
231
|
+
forms: [],
|
|
232
|
+
tables: [],
|
|
233
|
+
modals: [],
|
|
234
|
+
filters: [],
|
|
235
|
+
pagination: null,
|
|
236
|
+
infiniteScroll: false,
|
|
237
|
+
sidePanels: [],
|
|
238
|
+
dropdowns: [],
|
|
239
|
+
checkboxes: [],
|
|
240
|
+
inputs: []
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Helper: generate best selector for element
|
|
244
|
+
const getSelector = (el) => {
|
|
245
|
+
// Priority: data-qa > aria-label > id > unique class > nth-child path
|
|
246
|
+
const qa = el.getAttribute('data-qa');
|
|
247
|
+
if (qa) return '[data-qa="' + qa + '"]';
|
|
248
|
+
|
|
249
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
250
|
+
if (ariaLabel) return '[aria-label="' + ariaLabel.replace(/"/g, '\\"') + '"]';
|
|
251
|
+
|
|
252
|
+
if (el.id) return '#' + el.id;
|
|
253
|
+
|
|
254
|
+
// Try unique class
|
|
255
|
+
const classes = Array.from(el.classList).filter(c => !c.includes('--') && c.length > 2);
|
|
256
|
+
for (const cls of classes) {
|
|
257
|
+
if (document.querySelectorAll('.' + cls).length === 1) {
|
|
258
|
+
return '.' + cls;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Build nth-child path (last resort)
|
|
263
|
+
const path = [];
|
|
264
|
+
let current = el;
|
|
265
|
+
while (current && current !== document.body) {
|
|
266
|
+
const parent = current.parentElement;
|
|
267
|
+
if (!parent) break;
|
|
268
|
+
const siblings = Array.from(parent.children);
|
|
269
|
+
const index = siblings.indexOf(current) + 1;
|
|
270
|
+
const tag = current.tagName.toLowerCase();
|
|
271
|
+
path.unshift(tag + ':nth-child(' + index + ')');
|
|
272
|
+
current = parent;
|
|
273
|
+
if (path.length > 4) break; // limit depth
|
|
274
|
+
}
|
|
275
|
+
return path.join(' > ') || null;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Tabs (role=tab, .tab, [data-tab], nav items)
|
|
279
|
+
document.querySelectorAll('[role="tab"], .tab, [data-tab], .nav-item, .nav-link, [class*="tab"]').forEach(el => {
|
|
280
|
+
result.tabs.push({
|
|
281
|
+
text: el.textContent?.trim().slice(0, 50),
|
|
282
|
+
selector: getSelector(el),
|
|
283
|
+
active: el.classList.contains('active') || el.getAttribute('aria-selected') === 'true'
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Buttons
|
|
288
|
+
document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"], .btn, [class*="button"]').forEach(el => {
|
|
289
|
+
if (el.offsetParent !== null) { // visible
|
|
290
|
+
result.buttons.push({
|
|
291
|
+
text: el.textContent?.trim().slice(0, 50) || el.value || el.getAttribute('aria-label'),
|
|
292
|
+
selector: getSelector(el),
|
|
293
|
+
label: el.getAttribute('aria-label'),
|
|
294
|
+
qa: el.getAttribute('data-qa'),
|
|
295
|
+
type: el.type || 'button'
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Links (internal only)
|
|
301
|
+
const baseUrl = window.location.origin;
|
|
302
|
+
document.querySelectorAll('a[href]').forEach(el => {
|
|
303
|
+
const href = el.href;
|
|
304
|
+
if (href && href.startsWith(baseUrl) && el.offsetParent !== null) {
|
|
305
|
+
result.links.push({
|
|
306
|
+
text: el.textContent?.trim().slice(0, 50),
|
|
307
|
+
href: href,
|
|
308
|
+
selector: getSelector(el)
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Forms
|
|
314
|
+
document.querySelectorAll('form').forEach(el => {
|
|
315
|
+
result.forms.push({
|
|
316
|
+
action: el.action,
|
|
317
|
+
method: el.method,
|
|
318
|
+
inputs: Array.from(el.querySelectorAll('input, select, textarea')).map(i => ({
|
|
319
|
+
name: i.name,
|
|
320
|
+
type: i.type,
|
|
321
|
+
placeholder: i.placeholder
|
|
322
|
+
}))
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Tables
|
|
327
|
+
document.querySelectorAll('table, [role="grid"], [class*="table"]').forEach(el => {
|
|
328
|
+
const headers = Array.from(el.querySelectorAll('th, [role="columnheader"]')).map(h => h.textContent?.trim());
|
|
329
|
+
const rows = el.querySelectorAll('tr, [role="row"]').length;
|
|
330
|
+
result.tables.push({ headers, rowCount: rows });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// Pagination
|
|
334
|
+
const paginationEl = document.querySelector('[class*="pagination"], [role="navigation"][aria-label*="page"], .pager');
|
|
335
|
+
if (paginationEl) {
|
|
336
|
+
result.pagination = {
|
|
337
|
+
type: 'numbered',
|
|
338
|
+
pages: Array.from(paginationEl.querySelectorAll('a, button')).map(p => p.textContent?.trim()).filter(t => /\\d+/.test(t))
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Infinite scroll detection
|
|
343
|
+
result.infiniteScroll = !!document.querySelector('[class*="infinite"], [data-infinite], [class*="load-more"]');
|
|
344
|
+
|
|
345
|
+
// Dropdowns
|
|
346
|
+
document.querySelectorAll('select, [role="listbox"], [role="combobox"], [class*="dropdown"], [class*="select"]').forEach(el => {
|
|
347
|
+
result.dropdowns.push({
|
|
348
|
+
text: el.textContent?.trim().slice(0, 30),
|
|
349
|
+
options: Array.from(el.querySelectorAll('option')).map(o => o.textContent?.trim()).slice(0, 10)
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Checkboxes
|
|
354
|
+
document.querySelectorAll('input[type="checkbox"], [role="checkbox"]').forEach(el => {
|
|
355
|
+
result.checkboxes.push({
|
|
356
|
+
label: el.labels?.[0]?.textContent?.trim() || el.getAttribute('aria-label'),
|
|
357
|
+
checked: el.checked
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Inputs
|
|
362
|
+
document.querySelectorAll('input[type="text"], input[type="search"], textarea').forEach(el => {
|
|
363
|
+
if (el.offsetParent !== null) {
|
|
364
|
+
result.inputs.push({
|
|
365
|
+
name: el.name,
|
|
366
|
+
placeholder: el.placeholder,
|
|
367
|
+
type: el.type
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Side panels / drawers
|
|
373
|
+
document.querySelectorAll('[class*="sidebar"], [class*="drawer"], [class*="panel"], aside').forEach(el => {
|
|
374
|
+
result.sidePanels.push({
|
|
375
|
+
visible: el.offsetParent !== null,
|
|
376
|
+
class: el.className
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Modals (hidden ones too)
|
|
381
|
+
document.querySelectorAll('[role="dialog"], .modal, [class*="modal"]').forEach(el => {
|
|
382
|
+
result.modals.push({
|
|
383
|
+
visible: el.offsetParent !== null || el.style.display !== 'none',
|
|
384
|
+
id: el.id
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Filters
|
|
389
|
+
document.querySelectorAll('[class*="filter"], [data-filter], [role="search"]').forEach(el => {
|
|
390
|
+
result.filters.push({
|
|
391
|
+
text: el.textContent?.trim().slice(0, 50)
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
return result;
|
|
396
|
+
})()
|
|
397
|
+
`);
|
|
398
|
+
|
|
399
|
+
Object.assign(this.siteMap, elements);
|
|
400
|
+
return elements;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async clickElement(selector) {
|
|
404
|
+
try {
|
|
405
|
+
const box = await this.evaluate(`
|
|
406
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
407
|
+
if (!el) return null;
|
|
408
|
+
const rect = el.getBoundingClientRect();
|
|
409
|
+
({ x: rect.x + rect.width/2, y: rect.y + rect.height/2, visible: el.offsetParent !== null })
|
|
410
|
+
`);
|
|
411
|
+
|
|
412
|
+
if (!box || !box.visible) return false;
|
|
413
|
+
|
|
414
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: box.x, y: box.y, button: 'left', clickCount: 1 });
|
|
415
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: box.x, y: box.y, button: 'left', clickCount: 1 });
|
|
416
|
+
await new Promise(r => setTimeout(r, 500)); // Wait for any XHR
|
|
417
|
+
return true;
|
|
418
|
+
} catch (e) {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async clickByText(text) {
|
|
424
|
+
try {
|
|
425
|
+
// First try to find and get bounding box
|
|
426
|
+
const box = await this.evaluate(`
|
|
427
|
+
const el = Array.from(document.querySelectorAll('button, a, [role="button"], [role="tab"], [class*="btn"]'))
|
|
428
|
+
.find(e => e.textContent?.trim().toLowerCase().includes(${JSON.stringify(text.toLowerCase())}));
|
|
429
|
+
if (!el) return null;
|
|
430
|
+
const rect = el.getBoundingClientRect();
|
|
431
|
+
if (rect.width === 0 || rect.height === 0) return null;
|
|
432
|
+
({ x: rect.x + rect.width/2, y: rect.y + rect.height/2 })
|
|
433
|
+
`);
|
|
434
|
+
|
|
435
|
+
if (!box) return false;
|
|
436
|
+
|
|
437
|
+
// Use CDP mouse events for proper click
|
|
438
|
+
await this.send('Input.dispatchMouseEvent', {
|
|
439
|
+
type: 'mousePressed',
|
|
440
|
+
x: box.x,
|
|
441
|
+
y: box.y,
|
|
442
|
+
button: 'left',
|
|
443
|
+
clickCount: 1
|
|
444
|
+
});
|
|
445
|
+
await this.send('Input.dispatchMouseEvent', {
|
|
446
|
+
type: 'mouseReleased',
|
|
447
|
+
x: box.x,
|
|
448
|
+
y: box.y,
|
|
449
|
+
button: 'left',
|
|
450
|
+
clickCount: 1
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
return true;
|
|
454
|
+
} catch (e) {
|
|
455
|
+
console.error(`[explore] Click failed: ${e.message}`);
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async scroll(direction = 'down', amount = 500) {
|
|
461
|
+
const delta = direction === 'down' ? amount : -amount;
|
|
462
|
+
await this.evaluate(`window.scrollBy(0, ${delta})`);
|
|
463
|
+
await new Promise(r => setTimeout(r, 300));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async scrollToBottom() {
|
|
467
|
+
let lastHeight = 0;
|
|
468
|
+
let attempts = 0;
|
|
469
|
+
while (attempts < 10) {
|
|
470
|
+
const height = await this.evaluate('document.body.scrollHeight');
|
|
471
|
+
if (height === lastHeight) break;
|
|
472
|
+
lastHeight = height;
|
|
473
|
+
await this.evaluate('window.scrollTo(0, document.body.scrollHeight)');
|
|
474
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
475
|
+
attempts++;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async explore(startUrl, options = {}) {
|
|
480
|
+
console.error(`[explore] Starting exploration of ${startUrl}`);
|
|
481
|
+
fs.mkdirSync(this.outputDir, { recursive: true });
|
|
482
|
+
|
|
483
|
+
// If we want fresh network capture, reload the page
|
|
484
|
+
if (options.reload !== false) {
|
|
485
|
+
console.error('[explore] Reloading page to capture all network traffic...');
|
|
486
|
+
await this.send('Page.reload');
|
|
487
|
+
await new Promise(r => setTimeout(r, 3000)); // Wait for page load
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
this.siteMap.url = await this.evaluate('window.location.href');
|
|
491
|
+
this.siteMap.title = await this.evaluate('document.title');
|
|
492
|
+
|
|
493
|
+
// Initial screenshot
|
|
494
|
+
await this.screenshot('initial');
|
|
495
|
+
|
|
496
|
+
// Discover all elements
|
|
497
|
+
const elements = await this.discoverElements();
|
|
498
|
+
console.error(`[explore] Found: ${elements.tabs.length} tabs, ${elements.buttons.length} buttons, ${elements.links.length} links`);
|
|
499
|
+
|
|
500
|
+
// Click through tabs
|
|
501
|
+
for (const tab of elements.tabs.slice(0, 10)) {
|
|
502
|
+
if (tab.text) {
|
|
503
|
+
console.error(`[explore] Clicking tab: ${tab.text}`);
|
|
504
|
+
try {
|
|
505
|
+
await this.clickByText(tab.text);
|
|
506
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
507
|
+
await this.screenshot(`tab-${tab.text.replace(/[^a-z0-9]/gi, '-').slice(0, 20)}`);
|
|
508
|
+
await this.discoverElements(); // Re-discover after tab change
|
|
509
|
+
} catch (e) {
|
|
510
|
+
console.error(`[explore] Tab click failed: ${e.message}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Click through buttons (non-destructive ones)
|
|
516
|
+
const safeButtons = elements.buttons.filter(b => {
|
|
517
|
+
const text = (b.text || '').toLowerCase();
|
|
518
|
+
return !text.includes('delete') && !text.includes('remove') && !text.includes('submit') && !text.includes('save') && !text.includes('menu');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
for (const btn of safeButtons.slice(0, 10)) {
|
|
522
|
+
if (btn.text) {
|
|
523
|
+
console.error(`[explore] Clicking button: ${btn.text}`);
|
|
524
|
+
try {
|
|
525
|
+
await this.clickByText(btn.text);
|
|
526
|
+
await new Promise(r => setTimeout(r, 500));
|
|
527
|
+
} catch (e) {
|
|
528
|
+
console.error(`[explore] Button click failed: ${e.message}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Expand dropdowns
|
|
534
|
+
for (const dropdown of elements.dropdowns.slice(0, 5)) {
|
|
535
|
+
console.error(`[explore] Opening dropdown`);
|
|
536
|
+
// Click to open, then click away
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Scroll to trigger lazy loading
|
|
540
|
+
console.error('[explore] Scrolling to trigger lazy loading...');
|
|
541
|
+
await this.scrollToBottom();
|
|
542
|
+
await this.screenshot('scrolled');
|
|
543
|
+
|
|
544
|
+
// Check all checkboxes (to see what filters do)
|
|
545
|
+
for (const cb of elements.checkboxes.slice(0, 5)) {
|
|
546
|
+
if (cb.label) {
|
|
547
|
+
console.error(`[explore] Toggling checkbox: ${cb.label}`);
|
|
548
|
+
try {
|
|
549
|
+
await this.evaluate(`
|
|
550
|
+
const cb = Array.from(document.querySelectorAll('input[type="checkbox"]'))
|
|
551
|
+
.find(e => e.labels?.[0]?.textContent?.includes(${JSON.stringify(cb.label)}));
|
|
552
|
+
if (cb) cb.click();
|
|
553
|
+
`);
|
|
554
|
+
await new Promise(r => setTimeout(r, 500));
|
|
555
|
+
} catch (e) {
|
|
556
|
+
console.error(`[explore] Checkbox toggle failed: ${e.message}`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Final screenshot
|
|
562
|
+
await this.screenshot('final');
|
|
563
|
+
|
|
564
|
+
return this.generateReport();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
generateReport() {
|
|
568
|
+
const report = {
|
|
569
|
+
url: this.siteMap.url,
|
|
570
|
+
title: this.siteMap.title,
|
|
571
|
+
timestamp: new Date().toISOString(),
|
|
572
|
+
|
|
573
|
+
// Site structure
|
|
574
|
+
structure: {
|
|
575
|
+
tabs: this.siteMap.tabs.length,
|
|
576
|
+
buttons: this.siteMap.buttons.length,
|
|
577
|
+
links: this.siteMap.links.length,
|
|
578
|
+
forms: this.siteMap.forms.length,
|
|
579
|
+
tables: this.siteMap.tables.length,
|
|
580
|
+
dropdowns: this.siteMap.dropdowns.length,
|
|
581
|
+
checkboxes: this.siteMap.checkboxes.length,
|
|
582
|
+
pagination: this.siteMap.pagination,
|
|
583
|
+
infiniteScroll: this.siteMap.infiniteScroll
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
// Network activity
|
|
587
|
+
network: {
|
|
588
|
+
totalRequests: this.requests.length,
|
|
589
|
+
byType: this.requests.reduce((acc, r) => {
|
|
590
|
+
acc[r.type] = (acc[r.type] || 0) + 1;
|
|
591
|
+
return acc;
|
|
592
|
+
}, {}),
|
|
593
|
+
apis: this.requests.filter(r =>
|
|
594
|
+
r.url.includes('/api/') ||
|
|
595
|
+
r.url.includes('.json') ||
|
|
596
|
+
r.type === 'XHR' ||
|
|
597
|
+
r.type === 'Fetch'
|
|
598
|
+
).map(r => ({
|
|
599
|
+
method: r.method,
|
|
600
|
+
url: r.url,
|
|
601
|
+
hasBody: !!r.postData
|
|
602
|
+
})),
|
|
603
|
+
websockets: this.websockets
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
// Resources
|
|
607
|
+
resources: {
|
|
608
|
+
scripts: this.scripts.length,
|
|
609
|
+
stylesheets: this.stylesheets.length,
|
|
610
|
+
scriptUrls: this.scripts.map(s => s.url).filter(u => u)
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
// Console/errors
|
|
614
|
+
console: {
|
|
615
|
+
messages: this.consoleMessages.length,
|
|
616
|
+
errors: this.errors.length
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
// Screenshots
|
|
620
|
+
screenshots: this.siteMap.screenshots,
|
|
621
|
+
|
|
622
|
+
// Raw data for deep analysis
|
|
623
|
+
raw: {
|
|
624
|
+
tabs: this.siteMap.tabs,
|
|
625
|
+
buttons: this.siteMap.buttons.slice(0, 50),
|
|
626
|
+
links: this.siteMap.links.slice(0, 100),
|
|
627
|
+
forms: this.siteMap.forms,
|
|
628
|
+
tables: this.siteMap.tables,
|
|
629
|
+
requests: this.requests,
|
|
630
|
+
responses: this.responses
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// Save report
|
|
635
|
+
const reportPath = path.join(this.outputDir, 'report.json');
|
|
636
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
637
|
+
console.error(`[explore] Report saved: ${reportPath}`);
|
|
638
|
+
|
|
639
|
+
// Generate YAML task file for ralph mode
|
|
640
|
+
this.generateTaskFile(report);
|
|
641
|
+
|
|
642
|
+
// Save HAR if requested
|
|
643
|
+
if (this.harFile) {
|
|
644
|
+
this.saveHAR();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return report;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
generateTaskFile(report) {
|
|
651
|
+
const yaml = require('yaml');
|
|
652
|
+
|
|
653
|
+
// Build task steps from discovered elements
|
|
654
|
+
const steps = [];
|
|
655
|
+
|
|
656
|
+
// Start with navigation
|
|
657
|
+
steps.push({ goto: report.url });
|
|
658
|
+
steps.push({ wait: 2 });
|
|
659
|
+
|
|
660
|
+
// Add clicks for key buttons (with selectors)
|
|
661
|
+
const actionButtons = (report.raw.buttons || [])
|
|
662
|
+
.filter(b => b.selector && b.qa && !b.qa.includes('history') && !b.qa.includes('search'))
|
|
663
|
+
.slice(0, 10);
|
|
664
|
+
|
|
665
|
+
for (const btn of actionButtons) {
|
|
666
|
+
steps.push({ log: `Click: ${btn.text || btn.label}` });
|
|
667
|
+
steps.push({ click: btn.selector });
|
|
668
|
+
steps.push({ wait: 0.5 });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Add screenshot at end
|
|
672
|
+
steps.push({ screenshot: path.join(this.outputDir, 'task-result.png') });
|
|
673
|
+
steps.push({ log: 'LOOP_COMPLETE' });
|
|
674
|
+
|
|
675
|
+
const task = {
|
|
676
|
+
name: `Explore ${new URL(report.url).hostname}`,
|
|
677
|
+
generated: new Date().toISOString(),
|
|
678
|
+
source: 'glider explore',
|
|
679
|
+
steps
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const taskPath = path.join(this.outputDir, 'task.yaml');
|
|
683
|
+
fs.writeFileSync(taskPath, yaml.stringify(task));
|
|
684
|
+
console.error(`[explore] Task file saved: ${taskPath}`);
|
|
685
|
+
console.error(`[explore] Run with: glider ralph ${taskPath}`);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
saveHAR() {
|
|
689
|
+
const har = {
|
|
690
|
+
log: {
|
|
691
|
+
version: '1.2',
|
|
692
|
+
creator: { name: 'bexplore', version: '1.0.0' },
|
|
693
|
+
entries: this.requests.map(req => {
|
|
694
|
+
const resp = this.responses.find(r => r.id === req.id);
|
|
695
|
+
return {
|
|
696
|
+
startedDateTime: new Date(req.timestamp * 1000).toISOString(),
|
|
697
|
+
request: {
|
|
698
|
+
method: req.method,
|
|
699
|
+
url: req.url,
|
|
700
|
+
headers: Object.entries(req.headers || {}).map(([name, value]) => ({ name, value })),
|
|
701
|
+
postData: req.postData ? { text: req.postData } : undefined
|
|
702
|
+
},
|
|
703
|
+
response: resp ? {
|
|
704
|
+
status: resp.status,
|
|
705
|
+
headers: Object.entries(resp.headers || {}).map(([name, value]) => ({ name, value })),
|
|
706
|
+
content: { mimeType: resp.mimeType }
|
|
707
|
+
} : { status: 0, headers: [], content: {} }
|
|
708
|
+
};
|
|
709
|
+
})
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
fs.writeFileSync(this.harFile, JSON.stringify(har, null, 2));
|
|
714
|
+
console.error(`[explore] HAR saved: ${this.harFile}`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
close() {
|
|
718
|
+
if (this.ws) this.ws.close();
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// CLI
|
|
723
|
+
async function main() {
|
|
724
|
+
const args = process.argv.slice(2);
|
|
725
|
+
|
|
726
|
+
if (args.length === 0 || args.includes('--help')) {
|
|
727
|
+
console.log(`
|
|
728
|
+
bexplore - Ruthless site exploration and HAR capture
|
|
729
|
+
|
|
730
|
+
Usage:
|
|
731
|
+
node bexplore.js [options]
|
|
732
|
+
|
|
733
|
+
Options:
|
|
734
|
+
--depth N Exploration depth (default: 3)
|
|
735
|
+
--output DIR Output directory (default: /tmp/explore)
|
|
736
|
+
--har FILE Save HAR file
|
|
737
|
+
--help Show this help
|
|
738
|
+
|
|
739
|
+
The tool will:
|
|
740
|
+
1. Discover all tabs, buttons, links, forms, tables
|
|
741
|
+
2. Click through tabs to reveal content
|
|
742
|
+
3. Click safe buttons to trigger XHR
|
|
743
|
+
4. Scroll to trigger lazy loading
|
|
744
|
+
5. Toggle checkboxes/filters
|
|
745
|
+
6. Capture all network requests
|
|
746
|
+
7. Save screenshots at each step
|
|
747
|
+
8. Generate a comprehensive report
|
|
748
|
+
|
|
749
|
+
Examples:
|
|
750
|
+
node bexplore.js --output /tmp/sage-explore --har /tmp/sage.har
|
|
751
|
+
node bexplore.js --depth 5 --output ~/explore-results
|
|
752
|
+
`);
|
|
753
|
+
process.exit(0);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
let depth = 3;
|
|
757
|
+
let outputDir = '/tmp/explore';
|
|
758
|
+
let harFile = null;
|
|
759
|
+
|
|
760
|
+
for (let i = 0; i < args.length; i++) {
|
|
761
|
+
if (args[i] === '--depth') depth = parseInt(args[++i]);
|
|
762
|
+
else if (args[i] === '--output') outputDir = args[++i];
|
|
763
|
+
else if (args[i] === '--har') harFile = args[++i];
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const explorer = new SiteExplorer({ depth, outputDir, harFile });
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
await explorer.connect();
|
|
770
|
+
await explorer.init();
|
|
771
|
+
|
|
772
|
+
const url = await explorer.evaluate('window.location.href');
|
|
773
|
+
const report = await explorer.explore(url, { reload: true });
|
|
774
|
+
|
|
775
|
+
// Print summary
|
|
776
|
+
console.log('\n' + '═'.repeat(50));
|
|
777
|
+
console.log('EXPLORATION COMPLETE');
|
|
778
|
+
console.log('═'.repeat(50));
|
|
779
|
+
console.log(`URL: ${report.url}`);
|
|
780
|
+
console.log(`Title: ${report.title}`);
|
|
781
|
+
console.log('');
|
|
782
|
+
console.log('Structure:');
|
|
783
|
+
console.log(` Tabs: ${report.structure.tabs}`);
|
|
784
|
+
console.log(` Buttons: ${report.structure.buttons}`);
|
|
785
|
+
console.log(` Links: ${report.structure.links}`);
|
|
786
|
+
console.log(` Forms: ${report.structure.forms}`);
|
|
787
|
+
console.log(` Tables: ${report.structure.tables}`);
|
|
788
|
+
console.log('');
|
|
789
|
+
console.log('Network:');
|
|
790
|
+
console.log(` Total requests: ${report.network.totalRequests}`);
|
|
791
|
+
console.log(` API endpoints: ${report.network.apis.length}`);
|
|
792
|
+
console.log(` WebSockets: ${report.network.websockets.length}`);
|
|
793
|
+
console.log('');
|
|
794
|
+
console.log('Resources:');
|
|
795
|
+
console.log(` Scripts: ${report.resources.scripts}`);
|
|
796
|
+
console.log(` Stylesheets: ${report.resources.stylesheets}`);
|
|
797
|
+
console.log('');
|
|
798
|
+
console.log(`Screenshots: ${report.screenshots.length}`);
|
|
799
|
+
console.log(`Report: ${outputDir}/report.json`);
|
|
800
|
+
if (harFile) console.log(`HAR: ${harFile}`);
|
|
801
|
+
console.log('═'.repeat(50));
|
|
802
|
+
|
|
803
|
+
} catch (err) {
|
|
804
|
+
console.error('Error:', err.message);
|
|
805
|
+
process.exit(1);
|
|
806
|
+
} finally {
|
|
807
|
+
explorer.close();
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
module.exports = { SiteExplorer };
|
|
812
|
+
|
|
813
|
+
if (require.main === module) {
|
|
814
|
+
main();
|
|
815
|
+
}
|