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/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 };
@@ -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")