textweb 0.1.2 → 0.2.3
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 +75 -20
- package/logo.svg +30 -0
- package/mcp/index.js +215 -25
- package/package.json +13 -2
- package/src/browser.js +280 -33
- package/src/cli.js +7 -6
- package/src/renderer.js +52 -46
- package/src/server.js +1 -2
- package/tools/tool_definitions.json +299 -24
- package/.env.example +0 -25
- package/canvas/dashboard.html +0 -153
- package/docs/index.html +0 -761
- package/src/apply.js +0 -745
- package/src/dashboard.js +0 -196
- package/src/llm.js +0 -220
- package/src/pipeline.js +0 -317
package/src/browser.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
const { chromium } = require('playwright');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
|
|
8
|
+
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
9
|
+
|
|
6
10
|
const { render } = require('./renderer');
|
|
7
11
|
|
|
8
12
|
class AgentBrowser {
|
|
@@ -15,32 +19,75 @@ class AgentBrowser {
|
|
|
15
19
|
this.lastResult = null;
|
|
16
20
|
this.headless = options.headless !== false;
|
|
17
21
|
this.charH = 16; // default, updated after first render
|
|
22
|
+
this.defaultTimeout = options.timeout || 30000;
|
|
23
|
+
this.defaultRetries = options.retries ?? 2;
|
|
24
|
+
this.defaultRetryDelayMs = options.retryDelayMs ?? 250;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
|
-
async
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
async _withRetries(actionName, fn, options = {}) {
|
|
28
|
+
const retries = options.retries ?? this.defaultRetries;
|
|
29
|
+
const retryDelayMs = options.retryDelayMs ?? this.defaultRetryDelayMs;
|
|
30
|
+
|
|
31
|
+
let lastError = null;
|
|
32
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
return await fn();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
lastError = err;
|
|
37
|
+
if (attempt >= retries) break;
|
|
38
|
+
await new Promise(r => setTimeout(r, retryDelayMs));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new Error(`${actionName} failed after ${retries + 1} attempt(s): ${lastError?.message || 'unknown error'}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_contextOptions(storageStatePath = null) {
|
|
46
|
+
const opts = {
|
|
47
|
+
viewport: DEFAULT_VIEWPORT,
|
|
48
|
+
userAgent: DEFAULT_USER_AGENT,
|
|
49
|
+
};
|
|
50
|
+
if (storageStatePath) {
|
|
51
|
+
opts.storageState = storageStatePath;
|
|
52
|
+
}
|
|
53
|
+
return opts;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async _createContext(storageStatePath = null) {
|
|
57
|
+
this.context = await this.browser.newContext(this._contextOptions(storageStatePath));
|
|
29
58
|
this.page = await this.context.newPage();
|
|
30
|
-
this.page.setDefaultTimeout(
|
|
59
|
+
this.page.setDefaultTimeout(this.defaultTimeout);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async launch(options = {}) {
|
|
63
|
+
if (!this.browser) {
|
|
64
|
+
this.browser = await chromium.launch({
|
|
65
|
+
headless: this.headless,
|
|
66
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!this.context) {
|
|
71
|
+
await this._createContext(options.storageStatePath || null);
|
|
72
|
+
}
|
|
73
|
+
|
|
31
74
|
return this;
|
|
32
75
|
}
|
|
33
76
|
|
|
34
|
-
async navigate(url) {
|
|
77
|
+
async navigate(url, options = {}) {
|
|
35
78
|
if (!this.page) await this.launch();
|
|
36
79
|
this.scrollY = 0;
|
|
37
|
-
|
|
38
|
-
await this.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
80
|
+
|
|
81
|
+
await this._withRetries('navigate', async () => {
|
|
82
|
+
// Use domcontentloaded + a short settle, not networkidle (SPAs never go idle)
|
|
83
|
+
await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: options.timeoutMs || this.defaultTimeout });
|
|
84
|
+
// Wait for network to settle or 3s max — whichever comes first
|
|
85
|
+
await Promise.race([
|
|
86
|
+
this.page.waitForLoadState('networkidle').catch(() => {}),
|
|
87
|
+
new Promise(r => setTimeout(r, 3000)),
|
|
88
|
+
]);
|
|
89
|
+
}, options);
|
|
90
|
+
|
|
44
91
|
return await this.snapshot();
|
|
45
92
|
}
|
|
46
93
|
|
|
@@ -57,17 +104,21 @@ class AgentBrowser {
|
|
|
57
104
|
return this.lastResult;
|
|
58
105
|
}
|
|
59
106
|
|
|
60
|
-
async click(ref) {
|
|
107
|
+
async click(ref, options = {}) {
|
|
61
108
|
const el = this._getElement(ref);
|
|
62
|
-
await this.
|
|
63
|
-
|
|
109
|
+
await this._withRetries(`click ref=${ref}`, async () => {
|
|
110
|
+
await this.page.click(el.selector);
|
|
111
|
+
await this._settle();
|
|
112
|
+
}, options);
|
|
64
113
|
return await this.snapshot();
|
|
65
114
|
}
|
|
66
115
|
|
|
67
|
-
async type(ref, text) {
|
|
116
|
+
async type(ref, text, options = {}) {
|
|
68
117
|
const el = this._getElement(ref);
|
|
69
|
-
await this.
|
|
70
|
-
|
|
118
|
+
await this._withRetries(`type ref=${ref}`, async () => {
|
|
119
|
+
await this.page.click(el.selector);
|
|
120
|
+
await this.page.fill(el.selector, text);
|
|
121
|
+
}, options);
|
|
71
122
|
return await this.snapshot();
|
|
72
123
|
}
|
|
73
124
|
|
|
@@ -101,22 +152,28 @@ class AgentBrowser {
|
|
|
101
152
|
await this.page.setInputFiles(selector, paths);
|
|
102
153
|
}
|
|
103
154
|
|
|
104
|
-
async press(key) {
|
|
105
|
-
await this.
|
|
106
|
-
|
|
155
|
+
async press(key, options = {}) {
|
|
156
|
+
await this._withRetries(`press key=${key}`, async () => {
|
|
157
|
+
await this.page.keyboard.press(key);
|
|
158
|
+
await this._settle();
|
|
159
|
+
}, options);
|
|
107
160
|
return await this.snapshot();
|
|
108
161
|
}
|
|
109
162
|
|
|
110
|
-
async upload(ref, filePaths) {
|
|
163
|
+
async upload(ref, filePaths, options = {}) {
|
|
111
164
|
const el = this._getElement(ref);
|
|
112
165
|
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
|
113
|
-
await this.
|
|
166
|
+
await this._withRetries(`upload ref=${ref}`, async () => {
|
|
167
|
+
await this.page.setInputFiles(el.selector, paths);
|
|
168
|
+
}, options);
|
|
114
169
|
return await this.snapshot();
|
|
115
170
|
}
|
|
116
171
|
|
|
117
|
-
async select(ref, value) {
|
|
172
|
+
async select(ref, value, options = {}) {
|
|
118
173
|
const el = this._getElement(ref);
|
|
119
|
-
await this.
|
|
174
|
+
await this._withRetries(`select ref=${ref}`, async () => {
|
|
175
|
+
await this.page.selectOption(el.selector, value);
|
|
176
|
+
}, options);
|
|
120
177
|
return await this.snapshot();
|
|
121
178
|
}
|
|
122
179
|
|
|
@@ -146,8 +203,148 @@ class AgentBrowser {
|
|
|
146
203
|
return region.join('\n');
|
|
147
204
|
}
|
|
148
205
|
|
|
149
|
-
async evaluate(fn) {
|
|
150
|
-
return await this.page.evaluate(fn);
|
|
206
|
+
async evaluate(fn, arg) {
|
|
207
|
+
return await this.page.evaluate(fn, arg);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Save cookies/localStorage/sessionStorage state to disk
|
|
212
|
+
*/
|
|
213
|
+
async saveStorageState(path) {
|
|
214
|
+
if (!this.context) throw new Error('No browser context open.');
|
|
215
|
+
await this.context.storageState({ path });
|
|
216
|
+
return { saved: true, path };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Load cookies/localStorage/sessionStorage state from disk into a fresh context
|
|
221
|
+
*/
|
|
222
|
+
async loadStorageState(path) {
|
|
223
|
+
if (!this.browser) {
|
|
224
|
+
await this.launch();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (this.context) {
|
|
228
|
+
await this.context.close();
|
|
229
|
+
this.context = null;
|
|
230
|
+
this.page = null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await this._createContext(path);
|
|
234
|
+
this.scrollY = 0;
|
|
235
|
+
this.lastResult = null;
|
|
236
|
+
return { loaded: true, path };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Wait until one or more conditions are true, then return a fresh snapshot.
|
|
241
|
+
* Supported conditions: selector, text, urlIncludes.
|
|
242
|
+
*/
|
|
243
|
+
async waitFor(options = {}) {
|
|
244
|
+
if (!this.page) throw new Error('No page open. Call navigate() first.');
|
|
245
|
+
|
|
246
|
+
const timeout = options.timeoutMs || this.defaultTimeout;
|
|
247
|
+
const pollMs = options.pollMs || 100;
|
|
248
|
+
|
|
249
|
+
await this._withRetries('waitFor', async () => {
|
|
250
|
+
const waits = [];
|
|
251
|
+
|
|
252
|
+
if (options.selector) {
|
|
253
|
+
waits.push(
|
|
254
|
+
this.page.waitForSelector(options.selector, {
|
|
255
|
+
state: options.state || 'visible',
|
|
256
|
+
timeout,
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (options.text) {
|
|
262
|
+
waits.push(
|
|
263
|
+
this.page.waitForFunction(
|
|
264
|
+
(text) => document.body && document.body.innerText.includes(text),
|
|
265
|
+
options.text,
|
|
266
|
+
{ timeout, polling: pollMs }
|
|
267
|
+
)
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (options.urlIncludes) {
|
|
272
|
+
waits.push(
|
|
273
|
+
this.page.waitForFunction(
|
|
274
|
+
(needle) => window.location.href.includes(needle),
|
|
275
|
+
options.urlIncludes,
|
|
276
|
+
{ timeout, polling: pollMs }
|
|
277
|
+
)
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!waits.length) {
|
|
282
|
+
await this.page.waitForTimeout(timeout);
|
|
283
|
+
} else {
|
|
284
|
+
await Promise.all(waits);
|
|
285
|
+
}
|
|
286
|
+
}, options);
|
|
287
|
+
|
|
288
|
+
await this._settle();
|
|
289
|
+
return await this.snapshot();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Assert a field's value/text by ref.
|
|
294
|
+
* comparator: equals | includes | regex | not_empty
|
|
295
|
+
*/
|
|
296
|
+
async assertField(ref, expected, options = {}) {
|
|
297
|
+
if (!this.page) throw new Error('No page open. Call navigate() first.');
|
|
298
|
+
const el = this._getElement(ref);
|
|
299
|
+
const comparator = options.comparator || 'equals';
|
|
300
|
+
const attribute = options.attribute || null;
|
|
301
|
+
|
|
302
|
+
const actual = await this.page.evaluate(({ selector, attributeName }) => {
|
|
303
|
+
const target = document.querySelector(selector);
|
|
304
|
+
if (!target) return null;
|
|
305
|
+
|
|
306
|
+
if (attributeName) {
|
|
307
|
+
return target.getAttribute(attributeName);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const tag = (target.tagName || '').toLowerCase();
|
|
311
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
|
312
|
+
return target.value ?? '';
|
|
313
|
+
}
|
|
314
|
+
return (target.textContent || '').trim();
|
|
315
|
+
}, { selector: el.selector, attributeName: attribute });
|
|
316
|
+
|
|
317
|
+
let pass = false;
|
|
318
|
+
const actualStr = actual == null ? '' : String(actual);
|
|
319
|
+
const expectedStr = expected == null ? '' : String(expected);
|
|
320
|
+
|
|
321
|
+
switch (comparator) {
|
|
322
|
+
case 'equals':
|
|
323
|
+
pass = actualStr === expectedStr;
|
|
324
|
+
break;
|
|
325
|
+
case 'includes':
|
|
326
|
+
pass = actualStr.includes(expectedStr);
|
|
327
|
+
break;
|
|
328
|
+
case 'regex': {
|
|
329
|
+
const re = new RegExp(expectedStr);
|
|
330
|
+
pass = re.test(actualStr);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case 'not_empty':
|
|
334
|
+
pass = actualStr.trim().length > 0;
|
|
335
|
+
break;
|
|
336
|
+
default:
|
|
337
|
+
throw new Error(`Unknown comparator: ${comparator}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
pass,
|
|
342
|
+
ref,
|
|
343
|
+
selector: el.selector,
|
|
344
|
+
comparator,
|
|
345
|
+
expected: expectedStr,
|
|
346
|
+
actual: actualStr,
|
|
347
|
+
};
|
|
151
348
|
}
|
|
152
349
|
|
|
153
350
|
async close() {
|
|
@@ -159,6 +356,56 @@ class AgentBrowser {
|
|
|
159
356
|
}
|
|
160
357
|
}
|
|
161
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Get the current page URL
|
|
361
|
+
*/
|
|
362
|
+
getCurrentUrl() {
|
|
363
|
+
return this.page ? this.page.url() : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Find elements matching a CSS selector
|
|
368
|
+
* Returns array of {tag, text, selector, visible} objects
|
|
369
|
+
*/
|
|
370
|
+
async query(selector) {
|
|
371
|
+
if (!this.page) throw new Error('No page open. Call navigate() first.');
|
|
372
|
+
return await this.page.evaluate((sel) => {
|
|
373
|
+
const els = document.querySelectorAll(sel);
|
|
374
|
+
return Array.from(els).map((el, i) => ({
|
|
375
|
+
tag: el.tagName.toLowerCase(),
|
|
376
|
+
text: (el.textContent || '').trim().substring(0, 200),
|
|
377
|
+
selector: `${sel}:nth-child(${i + 1})`,
|
|
378
|
+
visible: el.offsetParent !== null,
|
|
379
|
+
href: el.href || null,
|
|
380
|
+
value: el.value || null,
|
|
381
|
+
}));
|
|
382
|
+
}, selector);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Take a screenshot (for debugging)
|
|
387
|
+
* @param {object} options - Playwright screenshot options (path, fullPage, type, etc.)
|
|
388
|
+
*/
|
|
389
|
+
async screenshot(options = {}) {
|
|
390
|
+
if (!this.page) throw new Error('No page open. Call navigate() first.');
|
|
391
|
+
return await this.page.screenshot({
|
|
392
|
+
fullPage: true,
|
|
393
|
+
type: 'png',
|
|
394
|
+
...options,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Wait for page to settle after an interaction.
|
|
400
|
+
* Races networkidle against a short timeout to avoid hanging on SPAs.
|
|
401
|
+
*/
|
|
402
|
+
async _settle() {
|
|
403
|
+
await Promise.race([
|
|
404
|
+
this.page.waitForLoadState('networkidle').catch(() => {}),
|
|
405
|
+
new Promise(r => setTimeout(r, 3000)),
|
|
406
|
+
]);
|
|
407
|
+
}
|
|
408
|
+
|
|
162
409
|
_getElement(ref) {
|
|
163
410
|
if (!this.lastResult) throw new Error('No snapshot. Navigate first.');
|
|
164
411
|
const el = this.lastResult.elements[ref];
|
package/src/cli.js
CHANGED
|
@@ -17,7 +17,6 @@ function parseArgs() {
|
|
|
17
17
|
json: false,
|
|
18
18
|
serve: false,
|
|
19
19
|
cols: 100,
|
|
20
|
-
rows: 30,
|
|
21
20
|
port: 3000,
|
|
22
21
|
help: false
|
|
23
22
|
};
|
|
@@ -48,7 +47,9 @@ function parseArgs() {
|
|
|
48
47
|
|
|
49
48
|
case '--rows':
|
|
50
49
|
case '-r':
|
|
51
|
-
|
|
50
|
+
// Deprecated: height is dynamic (grows to fit content). Ignored.
|
|
51
|
+
console.error('Warning: --rows is deprecated. Height is dynamic (grows to fit content).');
|
|
52
|
+
args[++i]; // consume the value
|
|
52
53
|
break;
|
|
53
54
|
|
|
54
55
|
case '--port':
|
|
@@ -85,7 +86,7 @@ USAGE:
|
|
|
85
86
|
|
|
86
87
|
OPTIONS:
|
|
87
88
|
--cols, -c <number> Grid width in characters (default: 100)
|
|
88
|
-
--rows, -r <number>
|
|
89
|
+
--rows, -r <number> (deprecated, height is dynamic)
|
|
89
90
|
--port, -p <number> Server port (default: 3000)
|
|
90
91
|
--interactive, -i Interactive REPL mode
|
|
91
92
|
--json, -j JSON output format
|
|
@@ -117,7 +118,7 @@ INTERACTIVE COMMANDS:
|
|
|
117
118
|
async function render(url, options) {
|
|
118
119
|
const browser = new AgentBrowser({
|
|
119
120
|
cols: options.cols,
|
|
120
|
-
|
|
121
|
+
|
|
121
122
|
headless: true
|
|
122
123
|
});
|
|
123
124
|
|
|
@@ -156,7 +157,7 @@ async function render(url, options) {
|
|
|
156
157
|
async function interactive(url, options) {
|
|
157
158
|
const browser = new AgentBrowser({
|
|
158
159
|
cols: options.cols,
|
|
159
|
-
|
|
160
|
+
|
|
160
161
|
headless: true
|
|
161
162
|
});
|
|
162
163
|
|
|
@@ -367,7 +368,7 @@ async function serve(options) {
|
|
|
367
368
|
|
|
368
369
|
const server = createServer({
|
|
369
370
|
cols: options.cols,
|
|
370
|
-
|
|
371
|
+
|
|
371
372
|
});
|
|
372
373
|
|
|
373
374
|
server.listen(options.port, () => {
|
package/src/renderer.js
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* Key design decisions:
|
|
8
8
|
* - Overflow > truncation (never lose information)
|
|
9
9
|
* - Measure actual font metrics from the page
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
10
|
+
* - Row-grouping layout (elements grouped by Y position)
|
|
11
|
+
* - Dynamic height (grows to fit all content)
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -80,14 +80,48 @@ async function extractElements(page) {
|
|
|
80
80
|
|
|
81
81
|
function buildSelector(el) {
|
|
82
82
|
// Build a robust CSS selector for clicking
|
|
83
|
+
// Priority: id > data-testid > aria > role+name > name > positional
|
|
83
84
|
if (el.id) return '#' + CSS.escape(el.id);
|
|
84
85
|
|
|
85
|
-
// Try unique attributes
|
|
86
|
-
if (el.getAttribute('data-testid')) return `[data-testid="${el.getAttribute('data-testid')}"]`;
|
|
87
|
-
if (el.getAttribute('name')) return `${el.tagName.toLowerCase()}[name="${el.getAttribute('name')}"]`;
|
|
88
|
-
|
|
89
|
-
// Fallback: positional selector
|
|
90
86
|
const tag = el.tagName.toLowerCase();
|
|
87
|
+
|
|
88
|
+
// Stable test attributes (used by many frameworks)
|
|
89
|
+
for (const attr of ['data-testid', 'data-test', 'data-cy', 'data-test-id']) {
|
|
90
|
+
const val = el.getAttribute(attr);
|
|
91
|
+
if (val) return `[${attr}="${val}"]`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Aria-label (very stable, set by developers intentionally)
|
|
95
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
96
|
+
if (ariaLabel) {
|
|
97
|
+
const sel = `${tag}[aria-label="${CSS.escape(ariaLabel)}"]`;
|
|
98
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Role + name combination
|
|
102
|
+
const role = el.getAttribute('role');
|
|
103
|
+
if (role) {
|
|
104
|
+
const name = ariaLabel || el.textContent.trim().substring(0, 50);
|
|
105
|
+
if (name) {
|
|
106
|
+
const sel = `[role="${role}"]`;
|
|
107
|
+
// Only use if unique enough
|
|
108
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Name attribute (forms)
|
|
113
|
+
if (el.getAttribute('name')) return `${tag}[name="${el.getAttribute('name')}"]`;
|
|
114
|
+
|
|
115
|
+
// href for links (use partial match for stability)
|
|
116
|
+
if (tag === 'a' && el.href) {
|
|
117
|
+
const href = el.getAttribute('href');
|
|
118
|
+
if (href && !href.startsWith('javascript:') && href !== '#') {
|
|
119
|
+
const sel = `a[href="${CSS.escape(href)}"]`;
|
|
120
|
+
if (document.querySelectorAll(sel).length === 1) return sel;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Fallback: positional selector (least stable)
|
|
91
125
|
const parent = el.parentElement;
|
|
92
126
|
if (!parent) return tag;
|
|
93
127
|
const siblings = Array.from(parent.children);
|
|
@@ -277,44 +311,6 @@ async function extractElements(page) {
|
|
|
277
311
|
});
|
|
278
312
|
}
|
|
279
313
|
|
|
280
|
-
/**
|
|
281
|
-
* Place text onto the grid, allowing overflow (never truncate).
|
|
282
|
-
* Text wraps to the next line at grid edge, continuing at the same start column.
|
|
283
|
-
*/
|
|
284
|
-
function placeText(grid, zGrid, z, row, col, text, cols, rows) {
|
|
285
|
-
let r = row;
|
|
286
|
-
let c = col;
|
|
287
|
-
|
|
288
|
-
for (let i = 0; i < text.length; i++) {
|
|
289
|
-
// Grow grid vertically if needed (overflow — don't lose data)
|
|
290
|
-
while (r >= grid.length) {
|
|
291
|
-
grid.push(Array(cols).fill(' '));
|
|
292
|
-
zGrid.push(Array(cols).fill(-1));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (c >= cols) {
|
|
296
|
-
// Wrap to next line at original column position
|
|
297
|
-
r++;
|
|
298
|
-
c = col;
|
|
299
|
-
while (r >= grid.length) {
|
|
300
|
-
grid.push(Array(cols).fill(' '));
|
|
301
|
-
zGrid.push(Array(cols).fill(-1));
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const ch = text[i];
|
|
306
|
-
if (ch === '\n') { r++; c = col; continue; }
|
|
307
|
-
|
|
308
|
-
if (c >= 0 && c < cols && z >= zGrid[r][c]) {
|
|
309
|
-
grid[r][c] = ch;
|
|
310
|
-
zGrid[r][c] = z;
|
|
311
|
-
}
|
|
312
|
-
c++;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
return r; // Return last row written to (useful for tracking grid growth)
|
|
316
|
-
}
|
|
317
|
-
|
|
318
314
|
/**
|
|
319
315
|
* Detect row boundaries — groups of elements that share the same Y position
|
|
320
316
|
* This prevents text from different elements on the same visual line from overlapping
|
|
@@ -464,6 +460,7 @@ function renderGrid(elements, cols, charW, charH, scrollY = 0) {
|
|
|
464
460
|
*/
|
|
465
461
|
async function render(page, options = {}) {
|
|
466
462
|
const { cols = 120, scrollY = 0 } = options;
|
|
463
|
+
const startMs = Date.now();
|
|
467
464
|
|
|
468
465
|
// Measure actual font metrics from the page
|
|
469
466
|
const metrics = await measureCharSize(page);
|
|
@@ -471,7 +468,16 @@ async function render(page, options = {}) {
|
|
|
471
468
|
const charH = metrics.charH;
|
|
472
469
|
|
|
473
470
|
const elements = await extractElements(page);
|
|
474
|
-
|
|
471
|
+
const result = renderGrid(elements, cols, charW, charH, scrollY);
|
|
472
|
+
|
|
473
|
+
// Add stats to meta
|
|
474
|
+
result.meta.stats = {
|
|
475
|
+
totalElements: elements.length,
|
|
476
|
+
interactiveElements: result.meta.totalRefs,
|
|
477
|
+
renderMs: Date.now() - startMs,
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
return result;
|
|
475
481
|
}
|
|
476
482
|
|
|
477
483
|
module.exports = { render, extractElements, renderGrid, measureCharSize };
|
package/src/server.js
CHANGED
|
@@ -10,7 +10,7 @@ class TextWebServer {
|
|
|
10
10
|
constructor(options = {}) {
|
|
11
11
|
this.options = {
|
|
12
12
|
cols: options.cols || 100,
|
|
13
|
-
|
|
13
|
+
// rows is deprecated — height is dynamic
|
|
14
14
|
timeout: options.timeout || 30000,
|
|
15
15
|
...options
|
|
16
16
|
};
|
|
@@ -33,7 +33,6 @@ class TextWebServer {
|
|
|
33
33
|
if (!this.browser) {
|
|
34
34
|
this.browser = new AgentBrowser({
|
|
35
35
|
cols: this.options.cols,
|
|
36
|
-
rows: this.options.rows,
|
|
37
36
|
headless: true,
|
|
38
37
|
timeout: this.options.timeout
|
|
39
38
|
});
|