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.
@@ -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
+ }