textweb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/docs/index.html +761 -0
- package/mcp/index.js +275 -0
- package/package.json +34 -0
- package/src/apply.js +565 -0
- package/src/browser.js +134 -0
- package/src/cli.js +427 -0
- package/src/renderer.js +452 -0
- package/src/server.js +504 -0
- package/tools/crewai.py +128 -0
- package/tools/langchain.py +165 -0
- package/tools/system_prompt.md +37 -0
- package/tools/tool_definitions.json +154 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TextWeb HTTP Server - REST API for web rendering and interaction
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const url = require('url');
|
|
7
|
+
const { AgentBrowser } = require('./browser');
|
|
8
|
+
|
|
9
|
+
class TextWebServer {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.options = {
|
|
12
|
+
cols: options.cols || 100,
|
|
13
|
+
rows: options.rows || 30,
|
|
14
|
+
timeout: options.timeout || 30000,
|
|
15
|
+
...options
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
this.browser = null;
|
|
19
|
+
this.lastActivity = Date.now();
|
|
20
|
+
|
|
21
|
+
// Start cleanup timer (close browser after inactivity)
|
|
22
|
+
setInterval(() => {
|
|
23
|
+
if (this.browser && Date.now() - this.lastActivity > 300000) { // 5 minutes
|
|
24
|
+
this.closeBrowser();
|
|
25
|
+
}
|
|
26
|
+
}, 60000); // Check every minute
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize browser if not already initialized
|
|
31
|
+
*/
|
|
32
|
+
async initBrowser() {
|
|
33
|
+
if (!this.browser) {
|
|
34
|
+
this.browser = new AgentBrowser({
|
|
35
|
+
cols: this.options.cols,
|
|
36
|
+
rows: this.options.rows,
|
|
37
|
+
headless: true,
|
|
38
|
+
timeout: this.options.timeout
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
this.lastActivity = Date.now();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Close browser instance
|
|
46
|
+
*/
|
|
47
|
+
async closeBrowser() {
|
|
48
|
+
if (this.browser) {
|
|
49
|
+
await this.browser.close();
|
|
50
|
+
this.browser = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse JSON body from request
|
|
56
|
+
*/
|
|
57
|
+
parseBody(req) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let body = '';
|
|
60
|
+
req.on('data', chunk => {
|
|
61
|
+
body += chunk.toString();
|
|
62
|
+
});
|
|
63
|
+
req.on('end', () => {
|
|
64
|
+
try {
|
|
65
|
+
resolve(body ? JSON.parse(body) : {});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
reject(new Error('Invalid JSON'));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
req.on('error', reject);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Send JSON response
|
|
76
|
+
*/
|
|
77
|
+
sendJSON(res, data, status = 200) {
|
|
78
|
+
res.writeHead(status, {
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
'Access-Control-Allow-Origin': '*',
|
|
81
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
82
|
+
'Access-Control-Allow-Headers': 'Content-Type'
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Convert Map to Object for JSON serialization
|
|
86
|
+
if (data.elements && data.elements instanceof Map) {
|
|
87
|
+
const elementsObj = {};
|
|
88
|
+
for (const [key, value] of data.elements) {
|
|
89
|
+
elementsObj[key] = value;
|
|
90
|
+
}
|
|
91
|
+
data.elements = elementsObj;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
res.end(JSON.stringify(data, null, 2));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Send error response
|
|
99
|
+
*/
|
|
100
|
+
sendError(res, message, status = 400) {
|
|
101
|
+
this.sendJSON(res, { error: message }, status);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Handle CORS preflight requests
|
|
106
|
+
*/
|
|
107
|
+
handleOptions(res) {
|
|
108
|
+
res.writeHead(200, {
|
|
109
|
+
'Access-Control-Allow-Origin': '*',
|
|
110
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
111
|
+
'Access-Control-Allow-Headers': 'Content-Type'
|
|
112
|
+
});
|
|
113
|
+
res.end();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Main request handler
|
|
118
|
+
*/
|
|
119
|
+
async handleRequest(req, res) {
|
|
120
|
+
const { pathname, query } = url.parse(req.url, true);
|
|
121
|
+
const method = req.method;
|
|
122
|
+
|
|
123
|
+
// Handle CORS preflight
|
|
124
|
+
if (method === 'OPTIONS') {
|
|
125
|
+
return this.handleOptions(res);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
switch (pathname) {
|
|
130
|
+
case '/health':
|
|
131
|
+
return this.handleHealth(req, res);
|
|
132
|
+
|
|
133
|
+
case '/navigate':
|
|
134
|
+
if (method === 'POST') {
|
|
135
|
+
return await this.handleNavigate(req, res);
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
|
|
139
|
+
case '/click':
|
|
140
|
+
if (method === 'POST') {
|
|
141
|
+
return await this.handleClick(req, res);
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
|
|
145
|
+
case '/type':
|
|
146
|
+
if (method === 'POST') {
|
|
147
|
+
return await this.handleType(req, res);
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
|
|
151
|
+
case '/scroll':
|
|
152
|
+
if (method === 'POST') {
|
|
153
|
+
return await this.handleScroll(req, res);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
|
|
157
|
+
case '/select':
|
|
158
|
+
if (method === 'POST') {
|
|
159
|
+
return await this.handleSelect(req, res);
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
|
|
163
|
+
case '/upload':
|
|
164
|
+
if (method === 'POST') {
|
|
165
|
+
return await this.handleUpload(req, res);
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
|
|
169
|
+
case '/snapshot':
|
|
170
|
+
if (method === 'GET') {
|
|
171
|
+
return await this.handleSnapshot(req, res);
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
|
|
175
|
+
case '/query':
|
|
176
|
+
if (method === 'POST') {
|
|
177
|
+
return await this.handleQuery(req, res);
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
|
|
181
|
+
case '/region':
|
|
182
|
+
if (method === 'POST') {
|
|
183
|
+
return await this.handleRegion(req, res);
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
|
|
187
|
+
case '/screenshot':
|
|
188
|
+
if (method === 'POST') {
|
|
189
|
+
return await this.handleScreenshot(req, res);
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
|
|
193
|
+
case '/close':
|
|
194
|
+
if (method === 'POST') {
|
|
195
|
+
return await this.handleClose(req, res);
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
|
|
199
|
+
default:
|
|
200
|
+
this.sendError(res, 'Not found', 404);
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.sendError(res, `Method ${method} not allowed for ${pathname}`, 405);
|
|
205
|
+
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.error('Request error:', error);
|
|
208
|
+
this.sendError(res, error.message, 500);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Health check endpoint
|
|
214
|
+
*/
|
|
215
|
+
handleHealth(req, res) {
|
|
216
|
+
this.sendJSON(res, {
|
|
217
|
+
status: 'ok',
|
|
218
|
+
timestamp: new Date().toISOString(),
|
|
219
|
+
browser: this.browser ? 'initialized' : 'not initialized',
|
|
220
|
+
lastActivity: new Date(this.lastActivity).toISOString()
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Navigate to URL
|
|
226
|
+
*/
|
|
227
|
+
async handleNavigate(req, res) {
|
|
228
|
+
const body = await this.parseBody(req);
|
|
229
|
+
|
|
230
|
+
if (!body.url) {
|
|
231
|
+
return this.sendError(res, 'URL is required');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await this.initBrowser();
|
|
235
|
+
const result = await this.browser.navigate(body.url, body.options);
|
|
236
|
+
|
|
237
|
+
this.sendJSON(res, {
|
|
238
|
+
success: true,
|
|
239
|
+
url: body.url,
|
|
240
|
+
view: result.view,
|
|
241
|
+
elements: result.elements,
|
|
242
|
+
meta: result.meta
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Click element
|
|
248
|
+
*/
|
|
249
|
+
async handleClick(req, res) {
|
|
250
|
+
const body = await this.parseBody(req);
|
|
251
|
+
|
|
252
|
+
if (typeof body.ref !== 'number') {
|
|
253
|
+
return this.sendError(res, 'Element reference (ref) is required');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!this.browser) {
|
|
257
|
+
return this.sendError(res, 'Browser not initialized. Navigate to a page first.');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const result = await this.browser.click(body.ref, body.options);
|
|
261
|
+
|
|
262
|
+
this.sendJSON(res, {
|
|
263
|
+
success: true,
|
|
264
|
+
action: 'click',
|
|
265
|
+
ref: body.ref,
|
|
266
|
+
view: result.view,
|
|
267
|
+
elements: result.elements,
|
|
268
|
+
meta: result.meta
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Type text into element
|
|
274
|
+
*/
|
|
275
|
+
async handleType(req, res) {
|
|
276
|
+
const body = await this.parseBody(req);
|
|
277
|
+
|
|
278
|
+
if (typeof body.ref !== 'number') {
|
|
279
|
+
return this.sendError(res, 'Element reference (ref) is required');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!body.text) {
|
|
283
|
+
return this.sendError(res, 'Text is required');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!this.browser) {
|
|
287
|
+
return this.sendError(res, 'Browser not initialized. Navigate to a page first.');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const result = await this.browser.type(body.ref, body.text, body.options);
|
|
291
|
+
|
|
292
|
+
this.sendJSON(res, {
|
|
293
|
+
success: true,
|
|
294
|
+
action: 'type',
|
|
295
|
+
ref: body.ref,
|
|
296
|
+
text: body.text,
|
|
297
|
+
view: result.view,
|
|
298
|
+
elements: result.elements,
|
|
299
|
+
meta: result.meta
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Scroll page
|
|
305
|
+
*/
|
|
306
|
+
async handleScroll(req, res) {
|
|
307
|
+
const body = await this.parseBody(req);
|
|
308
|
+
|
|
309
|
+
const direction = body.direction || 'down';
|
|
310
|
+
const amount = body.amount || 5;
|
|
311
|
+
|
|
312
|
+
if (!this.browser) {
|
|
313
|
+
return this.sendError(res, 'Browser not initialized. Navigate to a page first.');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const result = await this.browser.scroll(direction, amount);
|
|
317
|
+
|
|
318
|
+
this.sendJSON(res, {
|
|
319
|
+
success: true,
|
|
320
|
+
action: 'scroll',
|
|
321
|
+
direction,
|
|
322
|
+
amount,
|
|
323
|
+
view: result.view,
|
|
324
|
+
elements: result.elements,
|
|
325
|
+
meta: result.meta
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Select dropdown option
|
|
331
|
+
*/
|
|
332
|
+
async handleSelect(req, res) {
|
|
333
|
+
const body = await this.parseBody(req);
|
|
334
|
+
|
|
335
|
+
if (typeof body.ref !== 'number') {
|
|
336
|
+
return this.sendError(res, 'Element reference (ref) is required');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!body.value) {
|
|
340
|
+
return this.sendError(res, 'Value is required');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!this.browser) {
|
|
344
|
+
return this.sendError(res, 'Browser not initialized. Navigate to a page first.');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const result = await this.browser.select(body.ref, body.value);
|
|
348
|
+
|
|
349
|
+
this.sendJSON(res, {
|
|
350
|
+
success: true,
|
|
351
|
+
action: 'select',
|
|
352
|
+
ref: body.ref,
|
|
353
|
+
value: body.value,
|
|
354
|
+
view: result.view,
|
|
355
|
+
elements: result.elements,
|
|
356
|
+
meta: result.meta
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async handleUpload(req, res) {
|
|
361
|
+
const body = await this.parseBody(req);
|
|
362
|
+
|
|
363
|
+
if (typeof body.ref !== 'number') {
|
|
364
|
+
return this.sendError(res, 'Element reference (ref) is required');
|
|
365
|
+
}
|
|
366
|
+
if (!body.files) {
|
|
367
|
+
return this.sendError(res, 'files (string or array of file paths) is required');
|
|
368
|
+
}
|
|
369
|
+
if (!this.browser) {
|
|
370
|
+
return this.sendError(res, 'Browser not initialized. Navigate to a page first.');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const result = await this.browser.upload(body.ref, body.files);
|
|
374
|
+
|
|
375
|
+
this.sendJSON(res, {
|
|
376
|
+
success: true,
|
|
377
|
+
action: 'upload',
|
|
378
|
+
ref: body.ref,
|
|
379
|
+
view: result.view,
|
|
380
|
+
elements: result.elements,
|
|
381
|
+
meta: result.meta
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Get current page snapshot
|
|
387
|
+
*/
|
|
388
|
+
async handleSnapshot(req, res) {
|
|
389
|
+
if (!this.browser) {
|
|
390
|
+
return this.sendError(res, 'Browser not initialized. Navigate to a page first.');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const result = await this.browser.snapshot();
|
|
394
|
+
|
|
395
|
+
this.sendJSON(res, {
|
|
396
|
+
success: true,
|
|
397
|
+
action: 'snapshot',
|
|
398
|
+
url: this.browser.getCurrentUrl(),
|
|
399
|
+
view: result.view,
|
|
400
|
+
elements: result.elements,
|
|
401
|
+
meta: result.meta
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Query elements by selector
|
|
407
|
+
*/
|
|
408
|
+
async handleQuery(req, res) {
|
|
409
|
+
const body = await this.parseBody(req);
|
|
410
|
+
|
|
411
|
+
if (!body.selector) {
|
|
412
|
+
return this.sendError(res, 'CSS selector is required');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!this.browser) {
|
|
416
|
+
return this.sendError(res, 'Browser not initialized. Navigate to a page first.');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const matches = await this.browser.query(body.selector);
|
|
420
|
+
|
|
421
|
+
this.sendJSON(res, {
|
|
422
|
+
success: true,
|
|
423
|
+
action: 'query',
|
|
424
|
+
selector: body.selector,
|
|
425
|
+
matches
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Read text from grid region
|
|
431
|
+
*/
|
|
432
|
+
async handleRegion(req, res) {
|
|
433
|
+
const body = await this.parseBody(req);
|
|
434
|
+
|
|
435
|
+
const { r1, c1, r2, c2 } = body;
|
|
436
|
+
|
|
437
|
+
if (typeof r1 !== 'number' || typeof c1 !== 'number' ||
|
|
438
|
+
typeof r2 !== 'number' || typeof c2 !== 'number') {
|
|
439
|
+
return this.sendError(res, 'Region coordinates (r1, c1, r2, c2) are required');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!this.browser) {
|
|
443
|
+
return this.sendError(res, 'Browser not initialized. Navigate to a page first.');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const text = this.browser.readRegion(r1, c1, r2, c2);
|
|
447
|
+
|
|
448
|
+
this.sendJSON(res, {
|
|
449
|
+
success: true,
|
|
450
|
+
action: 'region',
|
|
451
|
+
coordinates: { r1, c1, r2, c2 },
|
|
452
|
+
text
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Take screenshot
|
|
458
|
+
*/
|
|
459
|
+
async handleScreenshot(req, res) {
|
|
460
|
+
if (!this.browser) {
|
|
461
|
+
return this.sendError(res, 'Browser not initialized. Navigate to a page first.');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const body = await this.parseBody(req);
|
|
465
|
+
const screenshot = await this.browser.screenshot(body.options);
|
|
466
|
+
|
|
467
|
+
res.writeHead(200, {
|
|
468
|
+
'Content-Type': 'image/png',
|
|
469
|
+
'Access-Control-Allow-Origin': '*'
|
|
470
|
+
});
|
|
471
|
+
res.end(screenshot);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Close browser
|
|
476
|
+
*/
|
|
477
|
+
async handleClose(req, res) {
|
|
478
|
+
await this.closeBrowser();
|
|
479
|
+
|
|
480
|
+
this.sendJSON(res, {
|
|
481
|
+
success: true,
|
|
482
|
+
action: 'close',
|
|
483
|
+
message: 'Browser closed'
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Create HTTP server instance
|
|
490
|
+
*/
|
|
491
|
+
function createServer(options = {}) {
|
|
492
|
+
const server = new TextWebServer(options);
|
|
493
|
+
|
|
494
|
+
return http.createServer((req, res) => {
|
|
495
|
+
server.handleRequest(req, res).catch(error => {
|
|
496
|
+
console.error('Server error:', error);
|
|
497
|
+
if (!res.headersSent) {
|
|
498
|
+
server.sendError(res, 'Internal server error', 500);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
module.exports = { createServer, TextWebServer };
|
package/tools/crewai.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TextWeb CrewAI Tool Integration
|
|
3
|
+
|
|
4
|
+
Wraps TextWeb as CrewAI-compatible tools for multi-agent workflows.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from textweb_crewai import TextWebBrowseTool, TextWebClickTool, TextWebTypeTool
|
|
8
|
+
|
|
9
|
+
researcher = Agent(
|
|
10
|
+
role="Web Researcher",
|
|
11
|
+
tools=[TextWebBrowseTool(), TextWebClickTool(), TextWebTypeTool()],
|
|
12
|
+
...
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
Requires:
|
|
16
|
+
pip install crewai requests
|
|
17
|
+
textweb --serve 3000
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
from typing import Type, Optional
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from crewai_tools import BaseTool
|
|
25
|
+
from pydantic import BaseModel, Field
|
|
26
|
+
except ImportError:
|
|
27
|
+
raise ImportError("Install crewai-tools: pip install crewai-tools")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "http://localhost:3000"
|
|
31
|
+
|
|
32
|
+
# Shared session for connection reuse
|
|
33
|
+
_session = requests.Session()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _call(endpoint: str, data: dict = None, method: str = "POST", base_url: str = DEFAULT_BASE_URL) -> str:
|
|
37
|
+
url = f"{base_url.rstrip('/')}{endpoint}"
|
|
38
|
+
if method == "GET":
|
|
39
|
+
resp = _session.get(url, timeout=30)
|
|
40
|
+
else:
|
|
41
|
+
resp = _session.post(url, json=data or {}, timeout=30)
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
result = resp.json()
|
|
44
|
+
|
|
45
|
+
view = result.get("view", "")
|
|
46
|
+
elements = result.get("elements", {})
|
|
47
|
+
meta = result.get("meta", {})
|
|
48
|
+
|
|
49
|
+
refs = "\n".join(
|
|
50
|
+
f"[{ref}] {el.get('semantic', '?')}: {el.get('text', '(no text)')}"
|
|
51
|
+
for ref, el in elements.items()
|
|
52
|
+
)
|
|
53
|
+
return f"URL: {meta.get('url', 'unknown')}\nTitle: {meta.get('title', 'unknown')}\n\n{view}\n\nInteractive elements:\n{refs}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ─── Tool Schemas ─────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
class NavigateSchema(BaseModel):
|
|
59
|
+
url: str = Field(description="URL to navigate to")
|
|
60
|
+
|
|
61
|
+
class ClickSchema(BaseModel):
|
|
62
|
+
ref: int = Field(description="Element [ref] number to click")
|
|
63
|
+
|
|
64
|
+
class TypeSchema(BaseModel):
|
|
65
|
+
ref: int = Field(description="Element [ref] number of the input")
|
|
66
|
+
text: str = Field(description="Text to type")
|
|
67
|
+
|
|
68
|
+
class SelectSchema(BaseModel):
|
|
69
|
+
ref: int = Field(description="Element [ref] number of the dropdown")
|
|
70
|
+
value: str = Field(description="Option to select")
|
|
71
|
+
|
|
72
|
+
class ScrollSchema(BaseModel):
|
|
73
|
+
direction: str = Field(description="up, down, or top")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ─── CrewAI Tools ─────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
class TextWebBrowseTool(BaseTool):
|
|
79
|
+
name: str = "textweb_navigate"
|
|
80
|
+
description: str = "Navigate to a URL and see it as a text grid. Interactive elements are marked with [ref] numbers for clicking/typing. ~500x smaller than screenshots, no vision model needed."
|
|
81
|
+
args_schema: Type[BaseModel] = NavigateSchema
|
|
82
|
+
|
|
83
|
+
def _run(self, url: str) -> str:
|
|
84
|
+
return _call("/navigate", {"url": url})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TextWebClickTool(BaseTool):
|
|
88
|
+
name: str = "textweb_click"
|
|
89
|
+
description: str = "Click an interactive element by its [ref] number from the text grid."
|
|
90
|
+
args_schema: Type[BaseModel] = ClickSchema
|
|
91
|
+
|
|
92
|
+
def _run(self, ref: int) -> str:
|
|
93
|
+
return _call("/click", {"ref": ref})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TextWebTypeTool(BaseTool):
|
|
97
|
+
name: str = "textweb_type"
|
|
98
|
+
description: str = "Type text into an input field by its [ref] number."
|
|
99
|
+
args_schema: Type[BaseModel] = TypeSchema
|
|
100
|
+
|
|
101
|
+
def _run(self, ref: int, text: str) -> str:
|
|
102
|
+
return _call("/type", {"ref": ref, "text": text})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TextWebSelectTool(BaseTool):
|
|
106
|
+
name: str = "textweb_select"
|
|
107
|
+
description: str = "Select a dropdown option by [ref] number."
|
|
108
|
+
args_schema: Type[BaseModel] = SelectSchema
|
|
109
|
+
|
|
110
|
+
def _run(self, ref: int, value: str) -> str:
|
|
111
|
+
return _call("/select", {"ref": ref, "value": value})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TextWebScrollTool(BaseTool):
|
|
115
|
+
name: str = "textweb_scroll"
|
|
116
|
+
description: str = "Scroll the page up, down, or to top."
|
|
117
|
+
args_schema: Type[BaseModel] = ScrollSchema
|
|
118
|
+
|
|
119
|
+
def _run(self, direction: str) -> str:
|
|
120
|
+
return _call("/scroll", {"direction": direction})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TextWebSnapshotTool(BaseTool):
|
|
124
|
+
name: str = "textweb_snapshot"
|
|
125
|
+
description: str = "Re-render the current page as text without navigating."
|
|
126
|
+
|
|
127
|
+
def _run(self) -> str:
|
|
128
|
+
return _call("/snapshot", method="GET")
|