glidercli 0.3.7 → 0.3.9
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 +19 -23
- package/bin/glider.js +236 -20
- package/lib/bexplore.js +174 -88
- package/lib/bsnapshot.js +69 -0
- package/lib/glider-daemon.sh +48 -2
- package/lib/guard.js +73 -0
- package/package.json +1 -1
- package/docs/BROWSERS.md +0 -7
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<h1 align="center">glider CLI</h1>
|
|
9
9
|
<p align="center"><i><b>Browser automation CLI with autonomous loop execution</b></i></p>
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
<a href="https://github.com/vdutts7/glidercli"><img src="./assets/badges/github.badge.svg" alt="GitHub" height="34" /></a> <a href="https://www.npmjs.com/package/glidercli"><img src="./assets/badges/npm.badge.svg" alt="npm install" height="34" /></a>
|
|
12
|
+
|
|
13
13
|
</div>
|
|
14
14
|
|
|
15
15
|
<br/>
|
|
@@ -53,17 +53,16 @@
|
|
|
53
53
|
|
|
54
54
|
### Browser support
|
|
55
55
|
|
|
56
|
-
Icons: `https://raw.githubusercontent.com/vdutts7/squircle/main/webp/{slug}.webp` ([squircle](https://github.com/vdutts7/squircle)).
|
|
57
|
-
|
|
58
56
|
**Extension:** Install [Glider](https://chromewebstore.google.com/detail/glider/njbidokkffhgpofcejgcfcgcinmeoalj) from the Chrome Web Store in each browser/profile you plan to automate
|
|
59
57
|
|
|
60
58
|
| | Browser | Config |
|
|
61
59
|
|:---:|--------|--------|
|
|
62
|
-
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/chrome.webp" width="16" alt=""> | Google Chrome | Default for `glider connect
|
|
60
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/chrome.webp" width="16" alt=""> | Google Chrome | Default for `glider connect`|
|
|
61
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/chrome-canary.webp" width="16" alt=""> | Chrome Canary | n/a |
|
|
63
62
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/arc.webp" width="16" alt=""> | Arc | [browser.json](config/browser.json.example) (`{ "use": "arc" }`) |
|
|
64
63
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/microsoft/microsoft-edge.webp" width="16" alt=""> | Microsoft Edge | n/a |
|
|
65
64
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/brave.webp" width="16" alt=""> | Brave | n/a |
|
|
66
|
-
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/
|
|
65
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/operagx.webp" width="16" alt=""> | Opera | n/a |
|
|
67
66
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/vivaldi.webp" width="16" alt=""> | Vivaldi | n/a |
|
|
68
67
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/chromium.webp" width="16" alt=""> | Other Chromium | Must support installing extensions from the Chrome Web Store. |
|
|
69
68
|
|
|
@@ -86,10 +85,14 @@ Icons: `https://raw.githubusercontent.com/vdutts7/squircle/main/webp/{slug}.webp
|
|
|
86
85
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/tor-browser.webp" width="16" alt=""> | Tor Browser | Gecko- same constraints as Firefox |
|
|
87
86
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/mullvad-browser.webp" width="16" alt=""> | Mullvad Browser | Gecko- same constraints as Firefox |
|
|
88
87
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/icecat.webp" width="16" alt=""> | IceCat | Gecko- same constraints as Firefox |
|
|
89
|
-
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/safari.webp" width="16" alt="">
|
|
88
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/safari.webp" width="16" alt=""> | Safari | WebKit (Apple desktop). Not Chromium |
|
|
89
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/safari-technology-preview.webp" width="16" alt=""> | Safari Technology Preview | WebKit preview channel (Apple desktop). Not Chromium |
|
|
90
90
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/orion.webp" width="16" alt=""> | Orion | WebKit-based desktop browser. Not Chromium |
|
|
91
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/dia-browser.webp" width="16" alt=""> | Dia | AI-first browser, not in Glider’s supported Chromium + CWS model today |
|
|
91
92
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/chatgpt-atlas.webp" width="16" alt=""> | ChatGPT Atlas | AI-first browser, not in Glider’s supported Chromium + CWS model today |
|
|
92
93
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/perplexity-comet.webp" width="16" alt=""> | Perplexity Comet | AI-first browser, not in Glider’s supported Chromium + CWS model today |
|
|
94
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/whale-browser.webp" width="16" alt=""> | Whale Browser | Chromium-based, support not implemented in Glider today |
|
|
95
|
+
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/yandex-browser.webp" width="16" alt=""> | Yandex Browser | Chromium-based, support not implemented in Glider today |
|
|
93
96
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/bromite.webp" width="16" alt=""> | Bromite | Chromium-derived, no practical Chrome Web Store path for Glider |
|
|
94
97
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/chromium.webp" width="16" alt=""> | Chromite | Chromium-derived, no practical Chrome Web Store path for Glider |
|
|
95
98
|
| <img src="https://raw.githubusercontent.com/vdutts7/squircle/main/webp/grapheneos.webp" width="16" alt=""> | Vanadium | Chromium-derived (GrapheneOS), no practical Chrome Web Store path for Glider |
|
|
@@ -113,7 +116,7 @@ Set browser by key from the browsers registry. Run `glider use <key>` to write t
|
|
|
113
116
|
}
|
|
114
117
|
```
|
|
115
118
|
|
|
116
|
-
Registry is loaded from
|
|
119
|
+
Registry is loaded from: `$HOME/.glider/config/browsers-registry.json`. Keys are predefined (e.g. `arc`, `brave`, `chrome`, `edge`, `opera`, `vivaldi`, `chromium`). Edit the registry to add or change paths
|
|
117
120
|
|
|
118
121
|
**Option B- Explicit name/path:**
|
|
119
122
|
|
|
@@ -147,11 +150,10 @@ Check app name in Finder/Spotlight, process name: `ps aux | grep -i <name>`
|
|
|
147
150
|
|
|
148
151
|
### Browser registry (keymap)
|
|
149
152
|
|
|
150
|
-
A single registry file can define all Chromium browsers, Glider picks one by key
|
|
153
|
+
A single registry file can define all Chromium browsers, Glider picks one by **key**
|
|
151
154
|
|
|
152
|
-
**Registry location
|
|
155
|
+
**Registry location:**
|
|
153
156
|
|
|
154
|
-
- `$HOME/.cursor/registry/browsers.json`
|
|
155
157
|
- `$HOME/.glider/config/browsers-registry.json`
|
|
156
158
|
|
|
157
159
|
**Registry format:**
|
|
@@ -192,7 +194,7 @@ Add or edit entries in the registry to match your machine (e.g. custom install p
|
|
|
192
194
|
|-------|--------|
|
|
193
195
|
| Supported | Chromium-based + Chrome Web Store extension (see table above). |
|
|
194
196
|
| Not supported | See **Future** in this README. |
|
|
195
|
-
| Configure | `$HOME/.glider/config/browser.json` (use key or name/path). Registry: `$HOME/.
|
|
197
|
+
| Configure | `$HOME/.glider/config/browser.json` (use key or name/path). Registry: `$HOME/.glider/config/browsers-registry.json` |
|
|
196
198
|
| Switch | `glider use <key>` (e.g. `glider use arc`, `glider use brave`). |
|
|
197
199
|
| Path | Optional, use when app is not in default location |
|
|
198
200
|
|
|
@@ -260,20 +262,14 @@ Full list: `glider --help`
|
|
|
260
262
|
|
|
261
263
|
## Roadmap
|
|
262
264
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
- [x] CDP relay + extension, YAML tasks, loop, daemon, multi-tab
|
|
266
|
+
- [ ] Linux / Windows
|
|
267
|
+
- [ ] headless (cloud)
|
|
268
|
+
- [ ] task chaining
|
|
269
|
+
- [ ] crawling templates
|
|
267
270
|
|
|
268
271
|
---
|
|
269
272
|
|
|
270
273
|
## Contact
|
|
271
274
|
|
|
272
275
|
<a href="https://vd7.io"><img src="https://res.cloudinary.com/ddyc1es5v/image/upload/v1773910810/readme-badges/readme-badge-vd7.png" alt="vd7.io" height="40" /></a> <a href="https://x.com/vdutts7"><img src="https://res.cloudinary.com/ddyc1es5v/image/upload/v1773910817/readme-badges/readme-badge-x.png" alt="/vdutts7" height="40" /></a>
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
<!-- BADGES -->
|
|
276
|
-
[github]: https://img.shields.io/badge/glidercli-000000?style=for-the-badge&logo=github
|
|
277
|
-
[github-url]: https://github.com/vdutts7/glidercli
|
|
278
|
-
[npm]: https://img.shields.io/badge/npm%20i%20--g%20glidercli-CB3837?style=for-the-badge&logo=npm
|
|
279
|
-
[npm-url]: https://www.npmjs.com/package/glidercli
|
package/bin/glider.js
CHANGED
|
@@ -39,6 +39,14 @@ const STATE_FILE = '/tmp/glider-state.json';
|
|
|
39
39
|
const LOG_FILE = '/tmp/glider.log';
|
|
40
40
|
const REGISTRY_FILE = path.join(LIB_DIR, 'registry.json');
|
|
41
41
|
|
|
42
|
+
// Active CDP session (multi-tab). Env or --session / --session-id on CLI.
|
|
43
|
+
let activeSessionId = process.env.GLIDER_SESSION_ID || null;
|
|
44
|
+
let jsonOutput = false;
|
|
45
|
+
let allowedDomainList = null;
|
|
46
|
+
const SESSION_STORE = path.join(os.homedir(), '.glider', 'config', 'active-session.json');
|
|
47
|
+
const { resolveAllowedDomains, assertUrlAllowed, urlAllowed } = require(path.join(LIB_DIR, 'guard.js'));
|
|
48
|
+
const { buildSnapshotExpression, formatSnapshotText } = require(path.join(LIB_DIR, 'bsnapshot.js'));
|
|
49
|
+
|
|
42
50
|
// Load pattern registry
|
|
43
51
|
let REGISTRY = {};
|
|
44
52
|
if (fs.existsSync(REGISTRY_FILE)) {
|
|
@@ -65,7 +73,7 @@ for (const cfgPath of DOMAIN_CONFIG_PATHS) {
|
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
75
|
|
|
68
|
-
// Browser config
|
|
76
|
+
// Browser config- which browser to launch/use (must be Chromium-based, see README.md#Browsers)
|
|
69
77
|
const BROWSER_CONFIG_PATHS = [
|
|
70
78
|
path.join(os.homedir(), '.glider', 'config', 'browser.json'),
|
|
71
79
|
path.join(os.homedir(), '.glider', 'browser.json'),
|
|
@@ -197,10 +205,63 @@ function httpGet(urlPath) {
|
|
|
197
205
|
});
|
|
198
206
|
}
|
|
199
207
|
|
|
208
|
+
function loadPersistedSession() {
|
|
209
|
+
if (activeSessionId) return;
|
|
210
|
+
if (!fs.existsSync(SESSION_STORE)) return;
|
|
211
|
+
try {
|
|
212
|
+
const data = JSON.parse(fs.readFileSync(SESSION_STORE, 'utf8'));
|
|
213
|
+
if (data.sessionId) activeSessionId = data.sessionId;
|
|
214
|
+
} catch { /* ignore */ }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function persistSession(sessionId) {
|
|
218
|
+
activeSessionId = sessionId;
|
|
219
|
+
fs.mkdirSync(path.dirname(SESSION_STORE), { recursive: true });
|
|
220
|
+
fs.writeFileSync(SESSION_STORE, JSON.stringify({ sessionId, updated: new Date().toISOString() }, null, 2));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function emitJson(ok, observation, error = null, warnings = []) {
|
|
224
|
+
console.log(JSON.stringify({ ok, observation, error, warnings }, null, 2));
|
|
225
|
+
if (!ok) process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function assertCurrentUrlAllowed(action) {
|
|
229
|
+
if (!allowedDomainList) return;
|
|
230
|
+
const result = await httpPost('/cdp', {
|
|
231
|
+
method: 'Runtime.evaluate',
|
|
232
|
+
params: { expression: 'location.href', returnByValue: true },
|
|
233
|
+
});
|
|
234
|
+
const url = result?.result?.value;
|
|
235
|
+
if (url) assertUrlAllowed(url, allowedDomainList, action);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseGlobalFlags(argv) {
|
|
239
|
+
const rest = [];
|
|
240
|
+
const cliDomains = [];
|
|
241
|
+
for (let i = 0; i < argv.length; i++) {
|
|
242
|
+
const a = argv[i];
|
|
243
|
+
if ((a === '--session' || a === '--session-id') && argv[i + 1]) {
|
|
244
|
+
activeSessionId = argv[++i];
|
|
245
|
+
} else if (a === '--json') {
|
|
246
|
+
jsonOutput = true;
|
|
247
|
+
} else if (a === '--allowed-domains' && argv[i + 1]) {
|
|
248
|
+
cliDomains.push(...String(argv[++i]).split(',').map((s) => s.trim()).filter(Boolean));
|
|
249
|
+
} else {
|
|
250
|
+
rest.push(a);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
allowedDomainList = resolveAllowedDomains(cliDomains);
|
|
254
|
+
return rest;
|
|
255
|
+
}
|
|
256
|
+
|
|
200
257
|
function httpPost(urlPath, body) {
|
|
258
|
+
const payload = { ...body };
|
|
259
|
+
if (urlPath === '/cdp' && activeSessionId && payload.sessionId == null) {
|
|
260
|
+
payload.sessionId = activeSessionId;
|
|
261
|
+
}
|
|
201
262
|
return new Promise((resolve, reject) => {
|
|
202
263
|
const url = new URL(urlPath, SERVER_URL);
|
|
203
|
-
const data = JSON.stringify(
|
|
264
|
+
const data = JSON.stringify(payload);
|
|
204
265
|
const req = http.request(url, {
|
|
205
266
|
method: 'POST',
|
|
206
267
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -421,21 +482,33 @@ async function cmdGoto(url) {
|
|
|
421
482
|
process.exit(1);
|
|
422
483
|
}
|
|
423
484
|
|
|
424
|
-
// Auto-connect if not connected
|
|
425
485
|
if (!await ensureConnected()) {
|
|
426
486
|
process.exit(1);
|
|
427
487
|
}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
assertUrlAllowed(url, allowedDomainList, 'goto');
|
|
491
|
+
} catch (e) {
|
|
492
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
493
|
+
log.fail(e.message);
|
|
494
|
+
process.exit(1);
|
|
495
|
+
}
|
|
428
496
|
|
|
429
|
-
log.info(`Navigating to: ${url}`);
|
|
497
|
+
if (!jsonOutput) log.info(`Navigating to: ${url}`);
|
|
430
498
|
|
|
431
499
|
try {
|
|
432
500
|
const result = await httpPost('/cdp', {
|
|
433
501
|
method: 'Page.navigate',
|
|
434
502
|
params: { url }
|
|
435
503
|
});
|
|
436
|
-
|
|
437
|
-
|
|
504
|
+
if (jsonOutput) {
|
|
505
|
+
emitJson(true, { url, navigate: result });
|
|
506
|
+
} else {
|
|
507
|
+
console.log(JSON.stringify(result));
|
|
508
|
+
log.ok('Navigated');
|
|
509
|
+
}
|
|
438
510
|
} catch (e) {
|
|
511
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
439
512
|
log.fail(`Navigation failed: ${e.message}`);
|
|
440
513
|
process.exit(1);
|
|
441
514
|
}
|
|
@@ -462,14 +535,18 @@ async function cmdEval(js) {
|
|
|
462
535
|
}
|
|
463
536
|
});
|
|
464
537
|
|
|
465
|
-
|
|
466
|
-
|
|
538
|
+
const value = result.result?.value;
|
|
539
|
+
if (jsonOutput) {
|
|
540
|
+
emitJson(true, value !== undefined ? value : result.result);
|
|
541
|
+
} else if (value !== undefined) {
|
|
542
|
+
console.log(JSON.stringify(value));
|
|
467
543
|
} else if (result.result?.description) {
|
|
468
544
|
console.log(result.result.description);
|
|
469
545
|
} else {
|
|
470
546
|
console.log(JSON.stringify(result));
|
|
471
547
|
}
|
|
472
548
|
} catch (e) {
|
|
549
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
473
550
|
log.fail(`Eval failed: ${e.message}`);
|
|
474
551
|
process.exit(1);
|
|
475
552
|
}
|
|
@@ -481,10 +558,17 @@ async function cmdClick(selector) {
|
|
|
481
558
|
process.exit(1);
|
|
482
559
|
}
|
|
483
560
|
|
|
484
|
-
// Auto-connect if not connected
|
|
485
561
|
if (!await ensureConnected()) {
|
|
486
562
|
process.exit(1);
|
|
487
563
|
}
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
await assertCurrentUrlAllowed('click');
|
|
567
|
+
} catch (e) {
|
|
568
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
569
|
+
log.fail(e.message);
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
488
572
|
|
|
489
573
|
const js = `
|
|
490
574
|
(() => {
|
|
@@ -502,11 +586,17 @@ async function cmdClick(selector) {
|
|
|
502
586
|
});
|
|
503
587
|
|
|
504
588
|
if (result.result?.value?.error) {
|
|
589
|
+
if (jsonOutput) emitJson(false, null, result.result.value.error);
|
|
505
590
|
log.fail(result.result.value.error);
|
|
506
591
|
process.exit(1);
|
|
507
592
|
}
|
|
508
|
-
|
|
593
|
+
if (jsonOutput) {
|
|
594
|
+
emitJson(true, { selector, clicked: true });
|
|
595
|
+
} else {
|
|
596
|
+
log.ok(`Clicked: ${selector}`);
|
|
597
|
+
}
|
|
509
598
|
} catch (e) {
|
|
599
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
510
600
|
log.fail(`Click failed: ${e.message}`);
|
|
511
601
|
process.exit(1);
|
|
512
602
|
}
|
|
@@ -678,7 +768,7 @@ async function cmdConnect() {
|
|
|
678
768
|
await new Promise(r => setTimeout(r, 1000));
|
|
679
769
|
}
|
|
680
770
|
|
|
681
|
-
// 2. Ensure browser is running (see getBrowserConfig +
|
|
771
|
+
// 2. Ensure browser is running (see getBrowserConfig + README.md#Browsers)
|
|
682
772
|
const browser = getBrowserConfig();
|
|
683
773
|
try {
|
|
684
774
|
execSync(`pgrep -x "${browser.processName}"`, { stdio: 'ignore' });
|
|
@@ -700,7 +790,7 @@ async function cmdConnect() {
|
|
|
700
790
|
|
|
701
791
|
if (!await checkExtension()) {
|
|
702
792
|
log.fail('Extension not connected to relay');
|
|
703
|
-
log.info(`Make sure Glider extension is installed in ${browser.name} (Chromium-based only; see glider
|
|
793
|
+
log.info(`Make sure Glider extension is installed in ${browser.name} (Chromium-based only; see glider README.md#Browsers)`);
|
|
704
794
|
process.exit(1);
|
|
705
795
|
}
|
|
706
796
|
log.ok('Extension connected');
|
|
@@ -1055,7 +1145,6 @@ async function cmdTitle() {
|
|
|
1055
1145
|
}
|
|
1056
1146
|
|
|
1057
1147
|
async function cmdUrl() {
|
|
1058
|
-
// Auto-connect if not connected
|
|
1059
1148
|
if (!await ensureConnected()) {
|
|
1060
1149
|
process.exit(1);
|
|
1061
1150
|
}
|
|
@@ -1065,13 +1154,106 @@ async function cmdUrl() {
|
|
|
1065
1154
|
method: 'Runtime.evaluate',
|
|
1066
1155
|
params: { expression: 'window.location.href', returnByValue: true }
|
|
1067
1156
|
});
|
|
1068
|
-
|
|
1157
|
+
const url = result.result?.value || '';
|
|
1158
|
+
if (jsonOutput) emitJson(true, { url });
|
|
1159
|
+
else console.log(url);
|
|
1069
1160
|
} catch (e) {
|
|
1161
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
1070
1162
|
log.fail(`URL extraction failed: ${e.message}`);
|
|
1071
1163
|
process.exit(1);
|
|
1072
1164
|
}
|
|
1073
1165
|
}
|
|
1074
1166
|
|
|
1167
|
+
async function cmdSnapshot(opts = []) {
|
|
1168
|
+
let interactiveOnly = false;
|
|
1169
|
+
for (const o of opts) {
|
|
1170
|
+
if (o === '--interactive-only' || o === '-i') interactiveOnly = true;
|
|
1171
|
+
}
|
|
1172
|
+
if (!await ensureConnected()) process.exit(1);
|
|
1173
|
+
try {
|
|
1174
|
+
const result = await httpPost('/cdp', {
|
|
1175
|
+
method: 'Runtime.evaluate',
|
|
1176
|
+
params: {
|
|
1177
|
+
expression: buildSnapshotExpression(interactiveOnly),
|
|
1178
|
+
returnByValue: true,
|
|
1179
|
+
awaitPromise: true,
|
|
1180
|
+
},
|
|
1181
|
+
});
|
|
1182
|
+
const data = result?.result?.value;
|
|
1183
|
+
if (!data || data.error) {
|
|
1184
|
+
const err = data?.error || 'snapshot failed';
|
|
1185
|
+
if (jsonOutput) emitJson(false, null, err);
|
|
1186
|
+
log.fail(err);
|
|
1187
|
+
process.exit(1);
|
|
1188
|
+
}
|
|
1189
|
+
if (jsonOutput) {
|
|
1190
|
+
emitJson(true, data);
|
|
1191
|
+
} else {
|
|
1192
|
+
console.log(formatSnapshotText(data));
|
|
1193
|
+
}
|
|
1194
|
+
} catch (e) {
|
|
1195
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
1196
|
+
log.fail(`Snapshot failed: ${e.message}`);
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async function cmdTargets() {
|
|
1202
|
+
const raw = await httpGet('/targets');
|
|
1203
|
+
const targets = (Array.isArray(raw) ? raw : []).map((t) => ({
|
|
1204
|
+
sessionId: t.sessionId,
|
|
1205
|
+
targetId: t.targetId,
|
|
1206
|
+
title: t.title || t.targetInfo?.title || '',
|
|
1207
|
+
url: t.url || t.targetInfo?.url || '',
|
|
1208
|
+
}));
|
|
1209
|
+
if (jsonOutput) {
|
|
1210
|
+
emitJson(true, { targets, activeSessionId });
|
|
1211
|
+
} else {
|
|
1212
|
+
if (!targets.length) {
|
|
1213
|
+
log.warn('No targets');
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
console.log(`${GREEN}${targets.length}${NC} target(s):\n`);
|
|
1217
|
+
for (const t of targets) {
|
|
1218
|
+
const mark = t.sessionId === activeSessionId ? `${GREEN}*${NC} ` : ' ';
|
|
1219
|
+
console.log(`${mark}${CYAN}${t.sessionId}${NC} ${t.title}`);
|
|
1220
|
+
console.log(` ${DIM}${t.url}${NC}`);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async function cmdUseSession(arg, opts = []) {
|
|
1226
|
+
let sessionId = arg;
|
|
1227
|
+
const urlIdx = opts.indexOf('--url');
|
|
1228
|
+
if (urlIdx >= 0 && opts[urlIdx + 1]) {
|
|
1229
|
+
const needle = opts[urlIdx + 1];
|
|
1230
|
+
const raw = await httpGet('/targets');
|
|
1231
|
+
const targets = Array.isArray(raw) ? raw : [];
|
|
1232
|
+
const hit = targets.find((t) => {
|
|
1233
|
+
const u = t.url || t.targetInfo?.url || '';
|
|
1234
|
+
return u.includes(needle);
|
|
1235
|
+
});
|
|
1236
|
+
if (!hit) {
|
|
1237
|
+
const msg = `no target matching --url ${needle}`;
|
|
1238
|
+
if (jsonOutput) emitJson(false, null, msg);
|
|
1239
|
+
log.fail(msg);
|
|
1240
|
+
process.exit(1);
|
|
1241
|
+
}
|
|
1242
|
+
sessionId = hit.sessionId;
|
|
1243
|
+
}
|
|
1244
|
+
if (!sessionId) {
|
|
1245
|
+
log.fail('Usage: glider use-session <sessionId> | glider use-session --url <host-fragment>');
|
|
1246
|
+
process.exit(1);
|
|
1247
|
+
}
|
|
1248
|
+
persistSession(sessionId);
|
|
1249
|
+
if (jsonOutput) {
|
|
1250
|
+
emitJson(true, { sessionId, persisted: SESSION_STORE });
|
|
1251
|
+
} else {
|
|
1252
|
+
log.ok(`Active session: ${sessionId}`);
|
|
1253
|
+
console.log(SESSION_STORE);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1075
1257
|
// Fetch URL using browser session (authenticated)
|
|
1076
1258
|
async function cmdFetch(url, opts = []) {
|
|
1077
1259
|
if (!url) {
|
|
@@ -1079,7 +1261,15 @@ async function cmdFetch(url, opts = []) {
|
|
|
1079
1261
|
process.exit(1);
|
|
1080
1262
|
}
|
|
1081
1263
|
|
|
1082
|
-
|
|
1264
|
+
try {
|
|
1265
|
+
assertUrlAllowed(url, allowedDomainList, 'fetch');
|
|
1266
|
+
} catch (e) {
|
|
1267
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
1268
|
+
log.fail(e.message);
|
|
1269
|
+
process.exit(1);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (!jsonOutput) log.info(`Fetching: ${url}`);
|
|
1083
1273
|
|
|
1084
1274
|
let outputFile = null;
|
|
1085
1275
|
for (let i = 0; i < opts.length; i++) {
|
|
@@ -1107,13 +1297,16 @@ async function cmdFetch(url, opts = []) {
|
|
|
1107
1297
|
const data = result?.result?.value;
|
|
1108
1298
|
const output = typeof data === 'object' ? JSON.stringify(data, null, 2) : data;
|
|
1109
1299
|
|
|
1110
|
-
if (
|
|
1300
|
+
if (jsonOutput) {
|
|
1301
|
+
emitJson(true, { url, data });
|
|
1302
|
+
} else if (outputFile) {
|
|
1111
1303
|
fs.writeFileSync(outputFile, output);
|
|
1112
1304
|
log.ok(`Saved to ${outputFile}`);
|
|
1113
1305
|
} else {
|
|
1114
1306
|
console.log(output);
|
|
1115
1307
|
}
|
|
1116
1308
|
} catch (e) {
|
|
1309
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
1117
1310
|
log.fail(`Fetch failed: ${e.message}`);
|
|
1118
1311
|
process.exit(1);
|
|
1119
1312
|
}
|
|
@@ -1422,7 +1615,7 @@ async function cmdRegistry(patternName, opts = []) {
|
|
|
1422
1615
|
// Explore site (clicks around, captures network)
|
|
1423
1616
|
async function cmdExplore(url, opts = []) {
|
|
1424
1617
|
if (!url) {
|
|
1425
|
-
log.fail('Usage: glider explore <url> [--depth N] [--output dir] [--har file]');
|
|
1618
|
+
log.fail('Usage: glider explore <url> [--depth N] [--output dir] [--har file] [--session-id id]');
|
|
1426
1619
|
process.exit(1);
|
|
1427
1620
|
}
|
|
1428
1621
|
|
|
@@ -1435,7 +1628,16 @@ async function cmdExplore(url, opts = []) {
|
|
|
1435
1628
|
if (opts[i] === '--depth' || opts[i] === '-d') depth = parseInt(opts[++i], 10);
|
|
1436
1629
|
else if (opts[i] === '--output' || opts[i] === '-o') outputDir = opts[++i];
|
|
1437
1630
|
else if (opts[i] === '--har') harFile = opts[++i];
|
|
1438
|
-
else if (opts[i] === '--session-id') sessionId = opts[++i];
|
|
1631
|
+
else if (opts[i] === '--session-id' || opts[i] === '--session') sessionId = opts[++i];
|
|
1632
|
+
}
|
|
1633
|
+
if (!sessionId && activeSessionId) sessionId = activeSessionId;
|
|
1634
|
+
|
|
1635
|
+
try {
|
|
1636
|
+
assertUrlAllowed(url, allowedDomainList, 'explore');
|
|
1637
|
+
} catch (e) {
|
|
1638
|
+
if (jsonOutput) emitJson(false, null, e.message);
|
|
1639
|
+
log.fail(e.message);
|
|
1640
|
+
process.exit(1);
|
|
1439
1641
|
}
|
|
1440
1642
|
|
|
1441
1643
|
log.info(`Exploring: ${url} (depth: ${depth})`);
|
|
@@ -1739,7 +1941,8 @@ function showHelp() {
|
|
|
1739
1941
|
showBanner();
|
|
1740
1942
|
console.log(`
|
|
1741
1943
|
${B5}USAGE${NC}
|
|
1742
|
-
glider <command> [args]
|
|
1944
|
+
glider [--session <id>] [--json] [--allowed-domains 'host,*'] <command> [args]
|
|
1945
|
+
${DIM}GLIDER_SESSION_ID=session-N${NC} ${DIM}pin tab for all /cdp commands${NC}
|
|
1743
1946
|
|
|
1744
1947
|
${B5}SETUP${NC}
|
|
1745
1948
|
${BW}install${NC} Install daemon ${DIM}(runs at login, auto-restarts)${NC}
|
|
@@ -1761,6 +1964,7 @@ ${B5}NAVIGATION${NC}
|
|
|
1761
1964
|
${BW}screenshot${NC} [path] Take screenshot
|
|
1762
1965
|
|
|
1763
1966
|
${B5}PAGE INFO${NC}
|
|
1967
|
+
${BW}snapshot${NC} [opts] Page index for agents ${DIM}(--json, --interactive-only)${NC}
|
|
1764
1968
|
${BW}text${NC} Get page text
|
|
1765
1969
|
${BW}html${NC} [selector] Get HTML
|
|
1766
1970
|
${BW}title${NC} Get page title
|
|
@@ -1776,6 +1980,8 @@ ${B5}MULTI-WINDOW${NC}
|
|
|
1776
1980
|
${BW}window list${NC} List all windows/tabs
|
|
1777
1981
|
|
|
1778
1982
|
${B5}MULTI-TAB${NC}
|
|
1983
|
+
${BW}targets${NC} List targets ${DIM}(sessionId, title, url)${NC}
|
|
1984
|
+
${BW}use-session${NC} <id> Pin session ${DIM}(or --url host-fragment)${NC}
|
|
1779
1985
|
${BW}fetch${NC} <url> Fetch URL with browser session ${DIM}(auth)${NC}
|
|
1780
1986
|
${BW}spawn${NC} <urls...> Open multiple tabs
|
|
1781
1987
|
${BW}extract${NC} [opts] Extract from all tabs
|
|
@@ -1923,7 +2129,8 @@ async function cmdUpdate() {
|
|
|
1923
2129
|
|
|
1924
2130
|
// Main
|
|
1925
2131
|
async function main() {
|
|
1926
|
-
const args = process.argv.slice(2);
|
|
2132
|
+
const args = parseGlobalFlags(process.argv.slice(2));
|
|
2133
|
+
loadPersistedSession();
|
|
1927
2134
|
const cmd = args[0];
|
|
1928
2135
|
|
|
1929
2136
|
if (!cmd || cmd === '--help' || cmd === '-h') {
|
|
@@ -1991,6 +2198,15 @@ async function main() {
|
|
|
1991
2198
|
case 'tabs':
|
|
1992
2199
|
await cmdTabs();
|
|
1993
2200
|
break;
|
|
2201
|
+
case 'targets':
|
|
2202
|
+
await cmdTargets();
|
|
2203
|
+
break;
|
|
2204
|
+
case 'use-session':
|
|
2205
|
+
await cmdUseSession(args[1], args.slice(2));
|
|
2206
|
+
break;
|
|
2207
|
+
case 'snapshot':
|
|
2208
|
+
await cmdSnapshot(args.slice(1));
|
|
2209
|
+
break;
|
|
1994
2210
|
case 'window':
|
|
1995
2211
|
case 'win':
|
|
1996
2212
|
await cmdWindow(args.slice(1));
|
package/lib/bexplore.js
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
const WebSocket = require('ws');
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
|
+
const http = require('http');
|
|
13
14
|
|
|
14
15
|
const RELAY_URL = process.env.RELAY_URL || 'ws://127.0.0.1:19988/cdp';
|
|
16
|
+
const RELAY_HTTP = process.env.RELAY_HTTP || 'http://127.0.0.1:19988';
|
|
15
17
|
|
|
16
18
|
class SiteExplorer {
|
|
17
19
|
constructor(options = {}) {
|
|
@@ -24,7 +26,7 @@ class SiteExplorer {
|
|
|
24
26
|
// Exploration state
|
|
25
27
|
this.visited = new Set();
|
|
26
28
|
this.toVisit = [];
|
|
27
|
-
this.depth = options.depth
|
|
29
|
+
this.depth = Number.isFinite(options.depth) ? options.depth : 3;
|
|
28
30
|
this.outputDir = options.outputDir || '/tmp/explore';
|
|
29
31
|
this.harFile = options.harFile;
|
|
30
32
|
this.wantedSessionId = options.sessionId || null;
|
|
@@ -168,17 +170,18 @@ class SiteExplorer {
|
|
|
168
170
|
this.eventHandlers.get(event).add(handler);
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
async send(method, params = {}) {
|
|
173
|
+
async send(method, params = {}, options = {}) {
|
|
172
174
|
const id = ++this.messageId;
|
|
173
175
|
const msg = { id, method, params };
|
|
174
176
|
if (this.sessionId) msg.sessionId = this.sessionId;
|
|
175
177
|
this.ws.send(JSON.stringify(msg));
|
|
176
|
-
|
|
178
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
179
|
+
|
|
177
180
|
return new Promise((resolve, reject) => {
|
|
178
181
|
const timer = setTimeout(() => {
|
|
179
182
|
this.pending.delete(id);
|
|
180
183
|
reject(new Error(`Timeout: ${method}`));
|
|
181
|
-
},
|
|
184
|
+
}, timeoutMs);
|
|
182
185
|
this.pending.set(id, {
|
|
183
186
|
resolve: (r) => { clearTimeout(timer); resolve(r); },
|
|
184
187
|
reject: (e) => { clearTimeout(timer); reject(e); }
|
|
@@ -186,29 +189,92 @@ class SiteExplorer {
|
|
|
186
189
|
});
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
async sendWithRetry(method, params = {}, options = {}) {
|
|
193
|
+
const retries = options.retries ?? 3;
|
|
194
|
+
const delayMs = options.delayMs ?? 400;
|
|
195
|
+
let lastErr;
|
|
196
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
197
|
+
try {
|
|
198
|
+
return await this.send(method, params, options);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
lastErr = err;
|
|
201
|
+
if (attempt < retries) {
|
|
202
|
+
console.error(`[explore] ${method} attempt ${attempt}/${retries} failed: ${err.message}`);
|
|
203
|
+
await new Promise((r) => setTimeout(r, delayMs * attempt));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw lastErr;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async fetchTargets() {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
const url = new URL('/targets', RELAY_HTTP);
|
|
213
|
+
http.get(url, (res) => {
|
|
214
|
+
let body = '';
|
|
215
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
216
|
+
res.on('end', () => {
|
|
217
|
+
try {
|
|
218
|
+
resolve(JSON.parse(body));
|
|
219
|
+
} catch (e) {
|
|
220
|
+
reject(new Error(`Invalid /targets JSON: ${e.message}`));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}).on('error', reject);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async prefetchSessionFromTargets() {
|
|
228
|
+
if (!this.wantedSessionId) return false;
|
|
229
|
+
const targets = await this.fetchTargets();
|
|
230
|
+
if (!Array.isArray(targets)) {
|
|
231
|
+
throw new Error('/targets did not return an array');
|
|
232
|
+
}
|
|
233
|
+
const hit = targets.find((t) => t.sessionId === this.wantedSessionId);
|
|
234
|
+
if (!hit) {
|
|
235
|
+
const available = targets.map((t) => t.sessionId).filter(Boolean).join(', ') || 'none';
|
|
236
|
+
throw new Error(`Session ${this.wantedSessionId} not in /targets (available: ${available})`);
|
|
237
|
+
}
|
|
238
|
+
this.sessionId = this.wantedSessionId;
|
|
239
|
+
console.error(`[explore] Session pinned from /targets: ${this.sessionId} (${hit.url || hit.title || 'tab'})`);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
189
243
|
async init() {
|
|
244
|
+
if (this.wantedSessionId) {
|
|
245
|
+
await this.prefetchSessionFromTargets();
|
|
246
|
+
}
|
|
247
|
+
|
|
190
248
|
await this.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
249
|
+
|
|
250
|
+
if (!this.sessionId) {
|
|
251
|
+
await new Promise((resolve, reject) => {
|
|
252
|
+
this.sessionResolved = resolve;
|
|
253
|
+
setTimeout(() => {
|
|
254
|
+
if (!this.sessionId) reject(new Error('Timeout waiting for tab attachment'));
|
|
255
|
+
else resolve();
|
|
256
|
+
}, 10000);
|
|
257
|
+
});
|
|
258
|
+
} else if (this.sessionResolved) {
|
|
259
|
+
this.sessionResolved();
|
|
260
|
+
}
|
|
261
|
+
|
|
198
262
|
if (!this.sessionId) throw new Error('No browser tab connected');
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
])
|
|
210
|
-
|
|
211
|
-
|
|
263
|
+
|
|
264
|
+
const domainEnables = [
|
|
265
|
+
['Runtime.enable', {}],
|
|
266
|
+
['Page.enable', {}],
|
|
267
|
+
['DOM.enable', {}],
|
|
268
|
+
['CSS.enable', {}],
|
|
269
|
+
['Network.enable', {}],
|
|
270
|
+
['Debugger.enable', {}],
|
|
271
|
+
['Log.enable', {}],
|
|
272
|
+
];
|
|
273
|
+
for (const [method, params] of domainEnables) {
|
|
274
|
+
const retries = method === 'CSS.enable' ? 4 : 2;
|
|
275
|
+
await this.sendWithRetry(method, params, { retries, delayMs: 500 });
|
|
276
|
+
}
|
|
277
|
+
|
|
212
278
|
await this.send('Network.setCacheDisabled', { cacheDisabled: false });
|
|
213
279
|
|
|
214
280
|
console.error('[explore] All CDP domains enabled');
|
|
@@ -492,7 +558,9 @@ class SiteExplorer {
|
|
|
492
558
|
}
|
|
493
559
|
|
|
494
560
|
async explore(startUrl, options = {}) {
|
|
495
|
-
|
|
561
|
+
const depth = this.depth;
|
|
562
|
+
this.clicksPerformed = 0;
|
|
563
|
+
console.error(`[explore] Starting exploration of ${startUrl} (depth=${depth})`);
|
|
496
564
|
fs.mkdirSync(this.outputDir, { recursive: true });
|
|
497
565
|
|
|
498
566
|
// If we want fresh network capture, reload the page
|
|
@@ -512,63 +580,70 @@ class SiteExplorer {
|
|
|
512
580
|
const elements = await this.discoverElements();
|
|
513
581
|
console.error(`[explore] Found: ${elements.tabs.length} tabs, ${elements.buttons.length} buttons, ${elements.links.length} links`);
|
|
514
582
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
583
|
+
if (depth === 0) {
|
|
584
|
+
console.error('[explore] depth=0 passive — no clicks, scroll, or toggles');
|
|
585
|
+
} else {
|
|
586
|
+
// Click through tabs
|
|
587
|
+
for (const tab of elements.tabs.slice(0, depth)) {
|
|
588
|
+
if (tab.text) {
|
|
589
|
+
console.error(`[explore] Clicking tab: ${tab.text}`);
|
|
590
|
+
try {
|
|
591
|
+
await this.clickByText(tab.text);
|
|
592
|
+
this.clicksPerformed++;
|
|
593
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
594
|
+
await this.screenshot(`tab-${tab.text.replace(/[^a-z0-9]/gi, '-').slice(0, 20)}`);
|
|
595
|
+
await this.discoverElements(); // Re-discover after tab change
|
|
596
|
+
} catch (e) {
|
|
597
|
+
console.error(`[explore] Tab click failed: ${e.message}`);
|
|
598
|
+
}
|
|
526
599
|
}
|
|
527
600
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
601
|
+
|
|
602
|
+
// Click through buttons (non-destructive ones)
|
|
603
|
+
const safeButtons = elements.buttons.filter(b => {
|
|
604
|
+
const text = (b.text || '').toLowerCase();
|
|
605
|
+
return !text.includes('delete') && !text.includes('remove') && !text.includes('submit') && !text.includes('save') && !text.includes('menu');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
for (const btn of safeButtons.slice(0, depth)) {
|
|
609
|
+
if (btn.text) {
|
|
610
|
+
console.error(`[explore] Clicking button: ${btn.text}`);
|
|
611
|
+
try {
|
|
612
|
+
await this.clickByText(btn.text);
|
|
613
|
+
this.clicksPerformed++;
|
|
614
|
+
await new Promise(r => setTimeout(r, 500));
|
|
615
|
+
} catch (e) {
|
|
616
|
+
console.error(`[explore] Button click failed: ${e.message}`);
|
|
617
|
+
}
|
|
544
618
|
}
|
|
545
619
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
await this.evaluate(`
|
|
620
|
+
|
|
621
|
+
// Expand dropdowns
|
|
622
|
+
for (const dropdown of elements.dropdowns.slice(0, Math.min(depth, 5))) {
|
|
623
|
+
console.error(`[explore] Opening dropdown`);
|
|
624
|
+
// Click to open, then click away
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Scroll to trigger lazy loading
|
|
628
|
+
console.error('[explore] Scrolling to trigger lazy loading...');
|
|
629
|
+
await this.scrollToBottom();
|
|
630
|
+
await this.screenshot('scrolled');
|
|
631
|
+
|
|
632
|
+
// Check all checkboxes (to see what filters do)
|
|
633
|
+
for (const cb of elements.checkboxes.slice(0, Math.min(depth, 5))) {
|
|
634
|
+
if (cb.label) {
|
|
635
|
+
console.error(`[explore] Toggling checkbox: ${cb.label}`);
|
|
636
|
+
try {
|
|
637
|
+
await this.evaluate(`
|
|
565
638
|
const cb = Array.from(document.querySelectorAll('input[type="checkbox"]'))
|
|
566
639
|
.find(e => e.labels?.[0]?.textContent?.includes(${JSON.stringify(cb.label)}));
|
|
567
640
|
if (cb) cb.click();
|
|
568
641
|
`);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
642
|
+
this.clicksPerformed++;
|
|
643
|
+
await new Promise(r => setTimeout(r, 500));
|
|
644
|
+
} catch (e) {
|
|
645
|
+
console.error(`[explore] Checkbox toggle failed: ${e.message}`);
|
|
646
|
+
}
|
|
572
647
|
}
|
|
573
648
|
}
|
|
574
649
|
}
|
|
@@ -584,6 +659,10 @@ class SiteExplorer {
|
|
|
584
659
|
url: this.siteMap.url,
|
|
585
660
|
title: this.siteMap.title,
|
|
586
661
|
timestamp: new Date().toISOString(),
|
|
662
|
+
depth: this.depth,
|
|
663
|
+
clicksPerformed: this.clicksPerformed ?? 0,
|
|
664
|
+
passive: this.depth === 0,
|
|
665
|
+
sessionId: this.sessionId,
|
|
587
666
|
|
|
588
667
|
// Site structure
|
|
589
668
|
structure: {
|
|
@@ -673,14 +752,16 @@ class SiteExplorer {
|
|
|
673
752
|
steps.push({ wait: 2 });
|
|
674
753
|
|
|
675
754
|
// Add clicks for key buttons (with selectors)
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
755
|
+
if (this.depth > 0) {
|
|
756
|
+
const actionButtons = (report.raw.buttons || [])
|
|
757
|
+
.filter(b => b.selector && b.qa && !b.qa.includes('history') && !b.qa.includes('search'))
|
|
758
|
+
.slice(0, this.depth);
|
|
759
|
+
|
|
760
|
+
for (const btn of actionButtons) {
|
|
761
|
+
steps.push({ log: `Click: ${btn.text || btn.label}` });
|
|
762
|
+
steps.push({ click: btn.selector });
|
|
763
|
+
steps.push({ wait: 0.5 });
|
|
764
|
+
}
|
|
684
765
|
}
|
|
685
766
|
|
|
686
767
|
// Add screenshot at end
|
|
@@ -746,22 +827,27 @@ Usage:
|
|
|
746
827
|
node bexplore.js [options]
|
|
747
828
|
|
|
748
829
|
Options:
|
|
749
|
-
--depth N Exploration depth (default: 3)
|
|
830
|
+
--depth N Exploration depth (default: 3; 0 = passive, no clicks)
|
|
750
831
|
--output DIR Output directory (default: /tmp/explore)
|
|
751
832
|
--har FILE Save HAR file
|
|
833
|
+
--session-id ID CDP session (e.g. session-6 from GET /targets)
|
|
752
834
|
--help Show this help
|
|
753
835
|
|
|
836
|
+
Depth 0 (passive): reload, discover DOM, screenshots, HAR — zero clicks.
|
|
837
|
+
Depth N>0: up to N tab/button/checkbox interactions per category.
|
|
838
|
+
|
|
754
839
|
The tool will:
|
|
755
840
|
1. Discover all tabs, buttons, links, forms, tables
|
|
756
|
-
2. Click through tabs to reveal content
|
|
757
|
-
3. Click safe buttons to trigger XHR
|
|
758
|
-
4. Scroll to trigger lazy loading
|
|
759
|
-
5. Toggle checkboxes/filters
|
|
841
|
+
2. Click through tabs to reveal content (depth > 0 only)
|
|
842
|
+
3. Click safe buttons to trigger XHR (depth > 0 only)
|
|
843
|
+
4. Scroll to trigger lazy loading (depth > 0 only)
|
|
844
|
+
5. Toggle checkboxes/filters (depth > 0 only)
|
|
760
845
|
6. Capture all network requests
|
|
761
846
|
7. Save screenshots at each step
|
|
762
847
|
8. Generate a comprehensive report
|
|
763
848
|
|
|
764
849
|
Examples:
|
|
850
|
+
node bexplore.js --depth 0 --output /tmp/passive --har /tmp/passive.har --session-id session-3
|
|
765
851
|
node bexplore.js --output /tmp/sage-explore --har /tmp/sage.har
|
|
766
852
|
node bexplore.js --depth 5 --output ~/explore-results
|
|
767
853
|
`);
|
|
@@ -774,7 +860,7 @@ Examples:
|
|
|
774
860
|
let sessionId = null;
|
|
775
861
|
|
|
776
862
|
for (let i = 0; i < args.length; i++) {
|
|
777
|
-
if (args[i] === '--depth') depth = parseInt(args[++i]);
|
|
863
|
+
if (args[i] === '--depth') depth = parseInt(args[++i], 10);
|
|
778
864
|
else if (args[i] === '--output') outputDir = args[++i];
|
|
779
865
|
else if (args[i] === '--har') harFile = args[++i];
|
|
780
866
|
else if (args[i] === '--session-id') sessionId = args[++i];
|
package/lib/bsnapshot.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function buildSnapshotExpression(interactiveOnly) {
|
|
4
|
+
return `(() => {
|
|
5
|
+
const interactiveOnly = ${interactiveOnly ? 'true' : 'false'};
|
|
6
|
+
const vis = (el) => {
|
|
7
|
+
if (!el) return false;
|
|
8
|
+
const s = getComputedStyle(el);
|
|
9
|
+
if (s.visibility === 'hidden' || s.display === 'none') return false;
|
|
10
|
+
const r = el.getBoundingClientRect();
|
|
11
|
+
return r.width > 0 && r.height > 0;
|
|
12
|
+
};
|
|
13
|
+
const cssPath = (el) => {
|
|
14
|
+
if (el.id) return '#' + CSS.escape(el.id);
|
|
15
|
+
const tid = el.getAttribute('data-testid');
|
|
16
|
+
if (tid) return '[data-testid="' + tid.replace(/"/g, '\\\\"') + '"]';
|
|
17
|
+
const parts = [];
|
|
18
|
+
let cur = el;
|
|
19
|
+
while (cur && cur.nodeType === 1 && cur !== document.body) {
|
|
20
|
+
let part = cur.tagName.toLowerCase();
|
|
21
|
+
const parent = cur.parentElement;
|
|
22
|
+
if (parent) {
|
|
23
|
+
const sibs = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
|
|
24
|
+
if (sibs.length > 1) part += ':nth-of-type(' + (sibs.indexOf(cur) + 1) + ')';
|
|
25
|
+
}
|
|
26
|
+
parts.unshift(part);
|
|
27
|
+
cur = parent;
|
|
28
|
+
if (parts.length >= 4) break;
|
|
29
|
+
}
|
|
30
|
+
return parts.join(' > ');
|
|
31
|
+
};
|
|
32
|
+
const roleOf = (el) => el.getAttribute('role') || ({ A: 'link', BUTTON: 'button', INPUT: 'textbox', SELECT: 'combobox', TEXTAREA: 'textbox' }[el.tagName] || el.tagName.toLowerCase());
|
|
33
|
+
const textOf = (el) => (el.getAttribute('aria-label') || el.textContent || el.value || el.getAttribute('placeholder') || '').trim().replace(/\\s+/g, ' ').slice(0, 120);
|
|
34
|
+
const sel = interactiveOnly
|
|
35
|
+
? 'a[href],button,input,select,textarea,[role="button"],[role="link"],[role="tab"],[role="menuitem"],[contenteditable="true"]'
|
|
36
|
+
: 'a[href],button,input,select,textarea,[role],[onclick],h1,h2,h3,label,summary';
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const elements = [];
|
|
39
|
+
let n = 0;
|
|
40
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
41
|
+
if (!vis(el)) continue;
|
|
42
|
+
const selector = cssPath(el);
|
|
43
|
+
if (seen.has(selector)) continue;
|
|
44
|
+
seen.add(selector);
|
|
45
|
+
n += 1;
|
|
46
|
+
elements.push({
|
|
47
|
+
id: n,
|
|
48
|
+
role: roleOf(el),
|
|
49
|
+
text: textOf(el),
|
|
50
|
+
selector,
|
|
51
|
+
aria: el.getAttribute('aria-label') || null,
|
|
52
|
+
tag: el.tagName.toLowerCase(),
|
|
53
|
+
href: el.href || null,
|
|
54
|
+
});
|
|
55
|
+
if (n >= 200) break;
|
|
56
|
+
}
|
|
57
|
+
return { url: location.href, title: document.title, elements };
|
|
58
|
+
})()`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatSnapshotText(data) {
|
|
62
|
+
const lines = [`URL: ${data.url}`, `Title: ${data.title}`, '', 'Elements:'];
|
|
63
|
+
for (const el of data.elements || []) {
|
|
64
|
+
lines.push(`[${el.id}]<${el.role}> ${el.text || '(empty)'} ${el.selector}`);
|
|
65
|
+
}
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { buildSnapshotExpression, formatSnapshotText };
|
package/lib/glider-daemon.sh
CHANGED
|
@@ -1,45 +1,91 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
+
|
|
2
3
|
# Glider daemon - respawns relay forever with log rotation
|
|
3
4
|
|
|
5
|
+
|
|
6
|
+
|
|
4
7
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
|
|
5
9
|
BSERVE="$SCRIPT_DIR/bserve.js"
|
|
10
|
+
|
|
6
11
|
LOG_DIR="$HOME/.glider"
|
|
12
|
+
|
|
7
13
|
LOG_FILE="$LOG_DIR/daemon.log"
|
|
14
|
+
|
|
8
15
|
PID_FILE="$LOG_DIR/daemon.pid"
|
|
16
|
+
|
|
9
17
|
MAX_LOG_SIZE=10485760 # 10MB
|
|
10
18
|
|
|
19
|
+
|
|
20
|
+
|
|
11
21
|
mkdir -p "$LOG_DIR"
|
|
12
22
|
|
|
23
|
+
|
|
24
|
+
|
|
13
25
|
# Rotate log if > 10MB
|
|
26
|
+
|
|
14
27
|
rotate_log() {
|
|
28
|
+
|
|
15
29
|
if [ -f "$LOG_FILE" ] && [ $(stat -f%z "$LOG_FILE" 2>/dev/null || echo 0) -gt $MAX_LOG_SIZE ]; then
|
|
16
|
-
|
|
30
|
+
|
|
31
|
+
/bin/unlink "$LOG_FILE.3"
|
|
32
|
+
|
|
17
33
|
[ -f "$LOG_FILE.2" ] && mv "$LOG_FILE.2" "$LOG_FILE.3"
|
|
34
|
+
|
|
18
35
|
[ -f "$LOG_FILE.1" ] && mv "$LOG_FILE.1" "$LOG_FILE.2"
|
|
36
|
+
|
|
19
37
|
mv "$LOG_FILE" "$LOG_FILE.1"
|
|
38
|
+
|
|
20
39
|
echo "[$(date)] Log rotated" > "$LOG_FILE"
|
|
40
|
+
|
|
21
41
|
fi
|
|
42
|
+
|
|
22
43
|
}
|
|
23
44
|
|
|
45
|
+
|
|
46
|
+
|
|
24
47
|
# Kill any existing
|
|
48
|
+
|
|
25
49
|
if [ -f "$PID_FILE" ]; then
|
|
50
|
+
|
|
26
51
|
kill $(cat "$PID_FILE") 2>/dev/null
|
|
52
|
+
|
|
27
53
|
rm "$PID_FILE"
|
|
54
|
+
|
|
28
55
|
fi
|
|
29
56
|
|
|
57
|
+
|
|
58
|
+
|
|
30
59
|
echo $$ > "$PID_FILE"
|
|
31
60
|
|
|
61
|
+
|
|
62
|
+
|
|
32
63
|
cleanup() {
|
|
33
|
-
|
|
64
|
+
|
|
65
|
+
/bin/unlink "$PID_FILE"
|
|
66
|
+
|
|
34
67
|
exit 0
|
|
68
|
+
|
|
35
69
|
}
|
|
70
|
+
|
|
36
71
|
trap cleanup SIGTERM SIGINT
|
|
37
72
|
|
|
73
|
+
|
|
74
|
+
|
|
38
75
|
while true; do
|
|
76
|
+
|
|
39
77
|
rotate_log
|
|
78
|
+
|
|
40
79
|
echo "[$(date)] Starting relay..." >> "$LOG_FILE"
|
|
80
|
+
|
|
41
81
|
node "$BSERVE" >> "$LOG_FILE" 2>&1
|
|
82
|
+
|
|
42
83
|
EXIT_CODE=$?
|
|
84
|
+
|
|
43
85
|
echo "[$(date)] Relay exited with code $EXIT_CODE, restarting in 2s..." >> "$LOG_FILE"
|
|
86
|
+
|
|
44
87
|
sleep 2
|
|
88
|
+
|
|
45
89
|
done
|
|
90
|
+
|
|
91
|
+
|
package/lib/guard.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const DOMAIN_CONFIG = path.join(os.homedir(), '.glider', 'config', 'allowed-domains.json');
|
|
8
|
+
|
|
9
|
+
function parseDomainList(raw) {
|
|
10
|
+
if (!raw) return [];
|
|
11
|
+
return String(raw)
|
|
12
|
+
.split(',')
|
|
13
|
+
.map((s) => s.trim())
|
|
14
|
+
.filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadConfigDomains() {
|
|
18
|
+
if (!fs.existsSync(DOMAIN_CONFIG)) return [];
|
|
19
|
+
try {
|
|
20
|
+
const data = JSON.parse(fs.readFileSync(DOMAIN_CONFIG, 'utf8'));
|
|
21
|
+
return Array.isArray(data) ? data : data.allowed || data.domains || [];
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveAllowedDomains(cliList) {
|
|
28
|
+
const fromEnv = parseDomainList(process.env.GLIDER_ALLOWED_DOMAINS);
|
|
29
|
+
const fromCli = cliList && cliList.length ? cliList : [];
|
|
30
|
+
const fromFile = loadConfigDomains();
|
|
31
|
+
if (fromCli.length) return fromCli;
|
|
32
|
+
if (fromEnv.length) return fromEnv;
|
|
33
|
+
if (fromFile.length) return fromFile;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hostMatchesPattern(host, pattern) {
|
|
38
|
+
const h = (host || '').toLowerCase();
|
|
39
|
+
const p = (pattern || '').toLowerCase();
|
|
40
|
+
if (!h || !p) return false;
|
|
41
|
+
if (p.includes('*')) {
|
|
42
|
+
const re = new RegExp(`^${p.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`);
|
|
43
|
+
return re.test(h);
|
|
44
|
+
}
|
|
45
|
+
return h === p || h.endsWith(`.${p}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function urlAllowed(url, allowed) {
|
|
49
|
+
if (!allowed || !allowed.length) return true;
|
|
50
|
+
let host;
|
|
51
|
+
try {
|
|
52
|
+
host = new URL(url).hostname;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return allowed.some((p) => hostMatchesPattern(host, p));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertUrlAllowed(url, allowed, action) {
|
|
60
|
+
if (urlAllowed(url, allowed)) return;
|
|
61
|
+
const host = (() => {
|
|
62
|
+
try { return new URL(url).hostname; } catch { return url; }
|
|
63
|
+
})();
|
|
64
|
+
throw new Error(`blocked by allowed_domains: ${action} on ${host}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
parseDomainList,
|
|
69
|
+
resolveAllowedDomains,
|
|
70
|
+
hostMatchesPattern,
|
|
71
|
+
urlAllowed,
|
|
72
|
+
assertUrlAllowed,
|
|
73
|
+
};
|
package/package.json
CHANGED
package/docs/BROWSERS.md
DELETED