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,236 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bextract.js - Extract content from multiple browser tabs in parallel via Glider
4
+ *
5
+ * Usage:
6
+ * bextract # Extract from all connected tabs
7
+ * bextract --sessions s1,s2,s3 # Extract from specific sessions
8
+ * bextract --exclude session-1 # Exclude specific sessions
9
+ * bextract --js 'document.title' # Custom JS expression
10
+ * bextract --selector '.content' # Extract specific element
11
+ *
12
+ * Options:
13
+ * --js <expr> JavaScript expression to evaluate (default: document.body.innerText)
14
+ * --selector <sel> CSS selector to extract
15
+ * --sessions <list> Comma-separated session IDs
16
+ * --exclude <list> Comma-separated sessions to exclude
17
+ * --limit <n> Max characters per result (default: 10000)
18
+ * --timeout <ms> Timeout per extraction (default: 15000)
19
+ * --json Output as JSON
20
+ * --quiet Suppress progress output
21
+ *
22
+ * Examples:
23
+ * bextract --exclude session-1 --limit 5000
24
+ * bextract --js 'document.title' --json
25
+ * bextract --selector 'article' --sessions session-2,session-3
26
+ */
27
+
28
+ const WebSocket = require('ws');
29
+ const http = require('http');
30
+
31
+ const RELAY_URL = process.env.RELAY_URL || 'ws://127.0.0.1:19988/cdp';
32
+ const DEFAULT_LIMIT = 10000;
33
+ const DEFAULT_TIMEOUT = 15000;
34
+
35
+ async function getTargets() {
36
+ return new Promise((resolve) => {
37
+ http.get('http://127.0.0.1:19988/targets', (res) => {
38
+ let data = '';
39
+ res.on('data', chunk => data += chunk);
40
+ res.on('end', () => resolve(JSON.parse(data)));
41
+ }).on('error', () => resolve([]));
42
+ });
43
+ }
44
+
45
+ async function extractFromSession(sessionId, jsExpr, options = {}) {
46
+ const { timeout = DEFAULT_TIMEOUT, limit = DEFAULT_LIMIT } = options;
47
+
48
+ return new Promise((resolve, reject) => {
49
+ const ws = new WebSocket(RELAY_URL);
50
+ let resolved = false;
51
+
52
+ const timer = setTimeout(() => {
53
+ if (!resolved) {
54
+ resolved = true;
55
+ ws.close();
56
+ reject(new Error(`Timeout for ${sessionId}`));
57
+ }
58
+ }, timeout);
59
+
60
+ ws.on('open', () => {
61
+ ws.send(JSON.stringify({
62
+ id: 1,
63
+ sessionId,
64
+ method: 'Runtime.evaluate',
65
+ params: {
66
+ expression: jsExpr,
67
+ returnByValue: true
68
+ }
69
+ }));
70
+ });
71
+
72
+ ws.on('message', (data) => {
73
+ const msg = JSON.parse(data.toString());
74
+ if (msg.id === 1 && !resolved) {
75
+ resolved = true;
76
+ clearTimeout(timer);
77
+ ws.close();
78
+
79
+ if (msg.error) {
80
+ reject(new Error(msg.error.message));
81
+ } else {
82
+ let value = msg.result?.result?.value;
83
+ if (typeof value === 'string' && value.length > limit) {
84
+ value = value.substring(0, limit) + `\n... [truncated at ${limit} chars]`;
85
+ }
86
+ resolve(value);
87
+ }
88
+ }
89
+ });
90
+
91
+ ws.on('error', (err) => {
92
+ if (!resolved) {
93
+ resolved = true;
94
+ clearTimeout(timer);
95
+ reject(err);
96
+ }
97
+ });
98
+ });
99
+ }
100
+
101
+ async function extractParallel(sessions, jsExpr, options = {}) {
102
+ const { quiet = false } = options;
103
+ const results = {};
104
+
105
+ const promises = sessions.map(async ({ sessionId, url }) => {
106
+ try {
107
+ if (!quiet) console.error(`[bextract] Extracting from ${sessionId}...`);
108
+ const content = await extractFromSession(sessionId, jsExpr, options);
109
+ results[sessionId] = { url, content, error: null };
110
+ } catch (err) {
111
+ results[sessionId] = { url, content: null, error: err.message };
112
+ }
113
+ });
114
+
115
+ await Promise.all(promises);
116
+ return results;
117
+ }
118
+
119
+ async function main() {
120
+ const args = process.argv.slice(2);
121
+
122
+ let jsExpr = 'document.body.innerText';
123
+ let selector = null;
124
+ let sessions = null;
125
+ let exclude = [];
126
+ let limit = DEFAULT_LIMIT;
127
+ let timeout = DEFAULT_TIMEOUT;
128
+ let outputJson = false;
129
+ let quiet = false;
130
+
131
+ for (let i = 0; i < args.length; i++) {
132
+ const arg = args[i];
133
+ if (arg === '--js' || arg === '-e') {
134
+ jsExpr = args[++i];
135
+ } else if (arg === '--selector' || arg === '-s') {
136
+ selector = args[++i];
137
+ } else if (arg === '--sessions') {
138
+ sessions = args[++i].split(',');
139
+ } else if (arg === '--exclude' || arg === '-x') {
140
+ exclude = args[++i].split(',');
141
+ } else if (arg === '--limit' || arg === '-l') {
142
+ limit = parseInt(args[++i], 10);
143
+ } else if (arg === '--timeout' || arg === '-t') {
144
+ timeout = parseInt(args[++i], 10);
145
+ } else if (arg === '--json' || arg === '-j') {
146
+ outputJson = true;
147
+ } else if (arg === '--quiet' || arg === '-q') {
148
+ quiet = true;
149
+ } else if (arg === '--help' || arg === '-h') {
150
+ console.log(`
151
+ bextract - Extract content from multiple browser tabs in parallel
152
+
153
+ Usage:
154
+ bextract # Extract from all connected tabs
155
+ bextract --sessions s1,s2,s3 # Extract from specific sessions
156
+ bextract --exclude session-1 # Exclude specific sessions
157
+ bextract --js 'document.title' # Custom JS expression
158
+ bextract --selector '.content' # Extract specific element
159
+
160
+ Options:
161
+ -e, --js <expr> JavaScript expression (default: document.body.innerText)
162
+ -s, --selector <sel> CSS selector to extract
163
+ --sessions <list> Comma-separated session IDs
164
+ -x, --exclude <list> Sessions to exclude
165
+ -l, --limit <n> Max chars per result (default: ${DEFAULT_LIMIT})
166
+ -t, --timeout <ms> Timeout per extraction (default: ${DEFAULT_TIMEOUT})
167
+ -j, --json Output as JSON
168
+ -q, --quiet Suppress progress output
169
+ -h, --help Show this help
170
+ `);
171
+ process.exit(0);
172
+ }
173
+ }
174
+
175
+ // Build JS expression
176
+ if (selector) {
177
+ jsExpr = `document.querySelector('${selector}')?.innerText || ''`;
178
+ }
179
+
180
+ try {
181
+ // Get targets
182
+ const targets = await getTargets();
183
+ if (targets.length === 0) {
184
+ console.error('Error: No browser tabs connected. Click extension icon on tabs first.');
185
+ process.exit(1);
186
+ }
187
+
188
+ // Filter sessions
189
+ let filteredTargets = targets;
190
+ if (sessions) {
191
+ filteredTargets = targets.filter(t => sessions.includes(t.sessionId));
192
+ }
193
+ if (exclude.length > 0) {
194
+ filteredTargets = filteredTargets.filter(t => !exclude.includes(t.sessionId));
195
+ }
196
+
197
+ if (filteredTargets.length === 0) {
198
+ console.error('Error: No matching sessions found');
199
+ process.exit(1);
200
+ }
201
+
202
+ if (!quiet) {
203
+ console.error(`[bextract] Extracting from ${filteredTargets.length} tabs in parallel...`);
204
+ }
205
+
206
+ // Extract in parallel
207
+ const results = await extractParallel(
208
+ filteredTargets.map(t => ({ sessionId: t.sessionId, url: t.targetInfo?.url })),
209
+ jsExpr,
210
+ { limit, timeout, quiet }
211
+ );
212
+
213
+ // Output
214
+ if (outputJson) {
215
+ console.log(JSON.stringify(results, null, 2));
216
+ } else {
217
+ for (const [sessionId, data] of Object.entries(results)) {
218
+ console.log(`\n${'='.repeat(60)}`);
219
+ console.log(`SESSION: ${sessionId}`);
220
+ console.log(`URL: ${data.url}`);
221
+ console.log('='.repeat(60));
222
+ if (data.error) {
223
+ console.log(`ERROR: ${data.error}`);
224
+ } else {
225
+ console.log(data.content);
226
+ }
227
+ }
228
+ }
229
+
230
+ } catch (err) {
231
+ console.error(`Error: ${err.message}`);
232
+ process.exit(1);
233
+ }
234
+ }
235
+
236
+ main();
package/lib/bfetch.js ADDED
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fetch-via-browser.js
4
+ * Fetch URLs using your authenticated browser session via CDP relay
5
+ *
6
+ * Usage:
7
+ * ./fetch-via-browser.js <url> [--output file.json]
8
+ * ./fetch-via-browser.js https://www.reddit.com/r/programming.json
9
+ */
10
+
11
+ const WebSocket = require('ws');
12
+
13
+ const RELAY_URL = process.env.RELAY_URL || 'ws://127.0.0.1:19988/cdp';
14
+
15
+ class BrowserFetcher {
16
+ constructor() {
17
+ this.ws = null;
18
+ this.messageId = 0;
19
+ this.pending = new Map();
20
+ this.sessionId = null;
21
+ this.targetId = null;
22
+ }
23
+
24
+ async connect() {
25
+ return new Promise((resolve, reject) => {
26
+ this.ws = new WebSocket(RELAY_URL);
27
+
28
+ this.ws.on('open', () => {
29
+ console.error('[fetcher] Connected to relay');
30
+ resolve();
31
+ });
32
+
33
+ this.ws.on('error', (err) => {
34
+ reject(new Error(`Connection failed: ${err.message}`));
35
+ });
36
+
37
+ this.ws.on('message', (data) => {
38
+ const msg = JSON.parse(data.toString());
39
+
40
+ // Response to our command
41
+ if (msg.id !== undefined) {
42
+ const pending = this.pending.get(msg.id);
43
+ if (pending) {
44
+ this.pending.delete(msg.id);
45
+ if (msg.error) {
46
+ pending.reject(new Error(msg.error.message));
47
+ } else {
48
+ pending.resolve(msg.result);
49
+ }
50
+ }
51
+ return;
52
+ }
53
+
54
+ // Event
55
+ if (msg.method === 'Target.attachedToTarget') {
56
+ if (!this.sessionId) {
57
+ this.sessionId = msg.params.sessionId;
58
+ this.targetId = msg.params.targetInfo.targetId;
59
+ console.error(`[fetcher] Got target: ${msg.params.targetInfo.url}`);
60
+ }
61
+ }
62
+ });
63
+ });
64
+ }
65
+
66
+ async send(method, params = {}, sessionId = null) {
67
+ const id = ++this.messageId;
68
+ const msg = { id, method, params };
69
+ if (sessionId) msg.sessionId = sessionId;
70
+
71
+ this.ws.send(JSON.stringify(msg));
72
+
73
+ return new Promise((resolve, reject) => {
74
+ const timer = setTimeout(() => {
75
+ this.pending.delete(id);
76
+ reject(new Error(`Timeout: ${method}`));
77
+ }, 30000);
78
+
79
+ this.pending.set(id, {
80
+ resolve: (result) => { clearTimeout(timer); resolve(result); },
81
+ reject: (error) => { clearTimeout(timer); reject(error); }
82
+ });
83
+ });
84
+ }
85
+
86
+ async init() {
87
+ // Initialize connection to browser
88
+ await this.send('Target.setAutoAttach', {
89
+ autoAttach: true,
90
+ waitForDebuggerOnStart: false,
91
+ flatten: true
92
+ });
93
+
94
+ // Wait for target
95
+ if (!this.sessionId) {
96
+ await new Promise((resolve) => setTimeout(resolve, 500));
97
+ }
98
+
99
+ if (!this.sessionId) {
100
+ throw new Error('No browser tab connected. Click the extension icon on a tab first.');
101
+ }
102
+
103
+ // Enable Runtime for evaluation
104
+ await this.send('Runtime.enable', {}, this.sessionId);
105
+ }
106
+
107
+ async fetch(url) {
108
+ console.error(`[fetcher] Fetching: ${url}`);
109
+
110
+ // Use browser's fetch API with its cookies
111
+ const script = `
112
+ (async () => {
113
+ const response = await fetch(${JSON.stringify(url)}, {
114
+ credentials: 'include',
115
+ headers: {
116
+ 'Accept': 'application/json, text/plain, */*'
117
+ }
118
+ });
119
+
120
+ const contentType = response.headers.get('content-type') || '';
121
+ const status = response.status;
122
+
123
+ let body;
124
+ if (contentType.includes('application/json')) {
125
+ body = await response.json();
126
+ } else {
127
+ body = await response.text();
128
+ }
129
+
130
+ return { status, contentType, body };
131
+ })()
132
+ `;
133
+
134
+ const result = await this.send('Runtime.evaluate', {
135
+ expression: script,
136
+ awaitPromise: true,
137
+ returnByValue: true
138
+ }, this.sessionId);
139
+
140
+ if (result.exceptionDetails) {
141
+ throw new Error(result.exceptionDetails.text || 'Evaluation failed');
142
+ }
143
+
144
+ return result.result.value;
145
+ }
146
+
147
+ async navigate(url) {
148
+ console.error(`[fetcher] Navigating to: ${url}`);
149
+ await this.send('Page.navigate', { url }, this.sessionId);
150
+ await new Promise(resolve => setTimeout(resolve, 2000));
151
+ }
152
+
153
+ async getPageContent() {
154
+ const result = await this.send('Runtime.evaluate', {
155
+ expression: 'document.documentElement.outerHTML',
156
+ returnByValue: true
157
+ }, this.sessionId);
158
+ return result.result.value;
159
+ }
160
+
161
+ async screenshot(path) {
162
+ const result = await this.send('Page.captureScreenshot', {
163
+ format: 'png'
164
+ }, this.sessionId);
165
+
166
+ const fs = require('fs');
167
+ fs.writeFileSync(path, Buffer.from(result.data, 'base64'));
168
+ console.error(`[fetcher] Screenshot saved: ${path}`);
169
+ }
170
+
171
+ close() {
172
+ if (this.ws) {
173
+ this.ws.close();
174
+ }
175
+ }
176
+ }
177
+
178
+ async function main() {
179
+ const args = process.argv.slice(2);
180
+
181
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
182
+ console.log(`
183
+ Usage: fetch-via-browser [options] <url>
184
+
185
+ Fetch URLs using your authenticated browser session.
186
+ Requires the relay server running and extension connected.
187
+
188
+ Options:
189
+ --output, -o <file> Save output to file
190
+ --raw Output raw response (don't pretty-print JSON)
191
+ --navigate Navigate to URL instead of fetching via XHR
192
+ --screenshot <file> Take screenshot after fetching
193
+ --help, -h Show this help
194
+
195
+ Examples:
196
+ fetch-via-browser https://www.reddit.com/r/programming.json
197
+ fetch-via-browser -o result.json https://www.reddit.com/user/spez/about.json
198
+ fetch-via-browser --navigate --screenshot shot.png https://old.reddit.com/r/webdev
199
+ `);
200
+ process.exit(0);
201
+ }
202
+
203
+ let url = null;
204
+ let outputFile = null;
205
+ let raw = false;
206
+ let navigate = false;
207
+ let screenshot = null;
208
+
209
+ for (let i = 0; i < args.length; i++) {
210
+ if (args[i] === '--output' || args[i] === '-o') {
211
+ outputFile = args[++i];
212
+ } else if (args[i] === '--raw') {
213
+ raw = true;
214
+ } else if (args[i] === '--navigate') {
215
+ navigate = true;
216
+ } else if (args[i] === '--screenshot') {
217
+ screenshot = args[++i];
218
+ } else if (!args[i].startsWith('-')) {
219
+ url = args[i];
220
+ }
221
+ }
222
+
223
+ if (!url) {
224
+ console.error('Error: URL required');
225
+ process.exit(1);
226
+ }
227
+
228
+ const fetcher = new BrowserFetcher();
229
+
230
+ try {
231
+ await fetcher.connect();
232
+ await fetcher.init();
233
+
234
+ let result;
235
+
236
+ if (navigate) {
237
+ await fetcher.navigate(url);
238
+ result = await fetcher.getPageContent();
239
+ } else {
240
+ result = await fetcher.fetch(url);
241
+ }
242
+
243
+ if (screenshot) {
244
+ await fetcher.screenshot(screenshot);
245
+ }
246
+
247
+ // Output
248
+ let output;
249
+ if (typeof result === 'object') {
250
+ if (result.body && typeof result.body === 'object') {
251
+ output = raw ? JSON.stringify(result.body) : JSON.stringify(result.body, null, 2);
252
+ } else {
253
+ output = raw ? JSON.stringify(result) : JSON.stringify(result, null, 2);
254
+ }
255
+ } else {
256
+ output = result;
257
+ }
258
+
259
+ if (outputFile) {
260
+ require('fs').writeFileSync(outputFile, output);
261
+ console.error(`[fetcher] Saved to: ${outputFile}`);
262
+ } else {
263
+ console.log(output);
264
+ }
265
+
266
+ } catch (err) {
267
+ console.error(`Error: ${err.message}`);
268
+ process.exit(1);
269
+ } finally {
270
+ fetcher.close();
271
+ }
272
+ }
273
+
274
+ main();
package/lib/bserve.js CHANGED
@@ -33,6 +33,18 @@ const server = http.createServer((req, res) => {
33
33
  } else if (req.url === '/targets') {
34
34
  res.writeHead(200, { 'Content-Type': 'application/json' });
35
35
  res.end(JSON.stringify(Array.from(connectedTargets.values())));
36
+ } else if (req.url === '/attach' && req.method === 'POST') {
37
+ // Trigger extension to attach active tab
38
+ (async () => {
39
+ try {
40
+ const result = await sendToExtension({ method: 'attachActiveTab', params: {} });
41
+ res.writeHead(200, { 'Content-Type': 'application/json' });
42
+ res.end(JSON.stringify(result));
43
+ } catch (e) {
44
+ res.writeHead(500, { 'Content-Type': 'application/json' });
45
+ res.end(JSON.stringify({ error: e.message }));
46
+ }
47
+ })();
36
48
  } else if (req.url === '/cdp' && req.method === 'POST') {
37
49
  // HTTP POST endpoint for CDP commands
38
50
  let body = '';
@@ -111,6 +123,8 @@ function handleExtensionConnection(ws) {
111
123
  }
112
124
 
113
125
  function handleExtensionMessage(msg) {
126
+ console.log('[relay] Extension message:', JSON.stringify(msg).slice(0, 200));
127
+
114
128
  // Response to our request
115
129
  if (msg.id !== undefined) {
116
130
  const pending = pendingRequests.get(msg.id);
@@ -235,6 +249,24 @@ async function sendToExtension({ method, params, timeout = 30000 }) {
235
249
  }
236
250
 
237
251
  async function routeCDPCommand({ method, params, sessionId }) {
252
+ // Target.* commands that operate at browser level don't need sessionId
253
+ const browserLevelCommands = [
254
+ 'Target.createTarget',
255
+ 'Target.closeTarget',
256
+ 'Target.activateTarget',
257
+ 'Target.getTargets',
258
+ 'Target.setAutoAttach',
259
+ 'Target.setDiscoverTargets',
260
+ 'Target.attachToTarget',
261
+ 'Target.getTargetInfo',
262
+ 'Browser.getVersion'
263
+ ];
264
+
265
+ // Auto-pick first session only for session-scoped commands
266
+ if (!sessionId && connectedTargets.size > 0 && !browserLevelCommands.includes(method)) {
267
+ sessionId = Array.from(connectedTargets.values())[0].sessionId;
268
+ }
269
+
238
270
  // Handle some commands locally
239
271
  switch (method) {
240
272
  case 'Browser.getVersion':
@@ -257,13 +289,10 @@ async function routeCDPCommand({ method, params, sessionId }) {
257
289
  };
258
290
 
259
291
  case 'Target.attachToTarget':
260
- const targetId = params?.targetId;
261
- for (const target of connectedTargets.values()) {
262
- if (target.targetId === targetId) {
263
- return { sessionId: target.sessionId };
264
- }
265
- }
266
- throw new Error(`Target ${targetId} not found`);
292
+ // Forward to extension - it handles the actual attachment
293
+ // The extension will send back Target.attachedToTarget event
294
+ console.log(`[relay] Forwarding Target.attachToTarget to extension for target: ${params?.targetId}`);
295
+ break;
267
296
 
268
297
  case 'Target.getTargetInfo':
269
298
  if (params?.targetId) {
@@ -279,6 +308,13 @@ async function routeCDPCommand({ method, params, sessionId }) {
279
308
  }
280
309
  const first = Array.from(connectedTargets.values())[0];
281
310
  return { targetInfo: first?.targetInfo };
311
+
312
+ case 'Target.createTarget':
313
+ case 'Target.closeTarget':
314
+ case 'Target.activateTarget':
315
+ // These MUST go to extension - they control browser windows/tabs
316
+ console.log(`[relay] Forwarding ${method} to extension:`, JSON.stringify(params).slice(0, 100));
317
+ break;
282
318
  }
283
319
 
284
320
  // Forward to extension