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/apply.js ADDED
@@ -0,0 +1,565 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * TextWeb Job Application Agent
5
+ *
6
+ * Fills out job applications using text-grid rendering instead of screenshots.
7
+ * Handles: LinkedIn Easy Apply, Greenhouse, Workday, Lever, Ashby, generic forms.
8
+ *
9
+ * Usage:
10
+ * node apply.js <url> [--resume path] [--cover-letter path] [--dry-run]
11
+ * node apply.js --batch <jobs.json>
12
+ */
13
+
14
+ const { AgentBrowser } = require('./browser');
15
+ const path = require('path');
16
+ const fs = require('fs');
17
+
18
+ // ─── Applicant Profile ───────────────────────────────────────────────────────
19
+
20
+ const PROFILE = {
21
+ firstName: 'Christopher',
22
+ lastName: 'Robison',
23
+ fullName: 'Christopher Robison',
24
+ email: 'cdr@cdr2.com',
25
+ phone: '(415) 810-6991',
26
+ location: 'San Francisco, CA',
27
+ linkedin: 'https://linkedin.com/in/crobison',
28
+ github: 'https://github.com/chrisrobison',
29
+ website: 'https://cdr2.com',
30
+ currentTitle: 'CTO',
31
+ currentCompany: 'D. Harris Tours',
32
+ yearsExperience: '25',
33
+ willingToRelocate: 'Yes',
34
+ workAuthorization: 'US Citizen',
35
+ requireSponsorship: 'No',
36
+ salaryExpectation: '200000',
37
+ noticePeriod: 'Immediately',
38
+
39
+ // Default resume/cover letter
40
+ resumePath: path.join(process.env.HOME, '.jobsearch/christopher-robison-resume.pdf'),
41
+ coverLetterPath: null, // set per-application if available
42
+ };
43
+
44
+ // ─── Field Matching ──────────────────────────────────────────────────────────
45
+ // Maps common form field labels/placeholders to profile values
46
+
47
+ const FIELD_PATTERNS = [
48
+ // Name fields
49
+ { match: /first\s*name/i, value: () => PROFILE.firstName },
50
+ { match: /last\s*name|family\s*name|surname/i, value: () => PROFILE.lastName },
51
+ { match: /full\s*name|^name$|^name:|customer.*name|your.*name|applicant.*name/i, value: () => PROFILE.fullName },
52
+
53
+ // Contact (email before address — "email address" should match email, not location)
54
+ { match: /e-?mail/i, value: () => PROFILE.email },
55
+ { match: /phone|mobile|cell|telephone/i, value: () => PROFILE.phone },
56
+
57
+ // Location (exclude "email address" by requiring no "email" nearby)
58
+ { match: /^(?!.*e-?mail).*(city|location|address|zip|postal)/i, value: () => PROFILE.location },
59
+
60
+ // Links
61
+ { match: /linkedin/i, value: () => PROFILE.linkedin },
62
+ { match: /github/i, value: () => PROFILE.github },
63
+ { match: /website|portfolio|personal.*url|blog/i, value: () => PROFILE.website },
64
+
65
+ // Work info
66
+ { match: /current.*title|job.*title/i, value: () => PROFILE.currentTitle },
67
+ { match: /current.*company|employer|organization/i, value: () => PROFILE.currentCompany },
68
+ { match: /years.*experience|experience.*years/i, value: () => PROFILE.yearsExperience },
69
+
70
+ // Logistics
71
+ { match: /relocat/i, value: () => PROFILE.willingToRelocate },
72
+ { match: /authorized|authorization|legally.*work|eligible.*work/i, value: () => PROFILE.workAuthorization },
73
+ { match: /sponsor/i, value: () => PROFILE.requireSponsorship },
74
+ { match: /salary|compensation|pay.*expect/i, value: () => PROFILE.salaryExpectation },
75
+ { match: /notice.*period|start.*date|availab.*start|when.*start/i, value: () => PROFILE.noticePeriod },
76
+ { match: /how.*hear|where.*find|referr(?!ed)|source.*(?:job|opening|position)|how.*learn.*about/i, value: () => 'Online job search' },
77
+ ];
78
+
79
+ // ─── Platform Detection ─────────────────────────────────────────────────────
80
+
81
+ function detectPlatform(url, pageText) {
82
+ const u = url.toLowerCase();
83
+ const t = (pageText || '').toLowerCase();
84
+
85
+ if (u.includes('linkedin.com')) return 'linkedin';
86
+ if (u.includes('greenhouse.io') || u.includes('boards.greenhouse')) return 'greenhouse';
87
+ if (u.includes('myworkday') || u.includes('workday.com')) return 'workday';
88
+ if (u.includes('lever.co') || u.includes('jobs.lever')) return 'lever';
89
+ if (u.includes('ashbyhq.com')) return 'ashby';
90
+ if (u.includes('smartrecruiters')) return 'smartrecruiters';
91
+ if (u.includes('icims')) return 'icims';
92
+ if (u.includes('indeed.com')) return 'indeed';
93
+ if (t.includes('greenhouse')) return 'greenhouse';
94
+ if (t.includes('workday')) return 'workday';
95
+ if (t.includes('lever')) return 'lever';
96
+ return 'generic';
97
+ }
98
+
99
+ // ─── Form Analysis ──────────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Analyze a page snapshot to identify fillable fields and map them to profile data
103
+ */
104
+ function analyzeForm(result) {
105
+ const { view, elements } = result;
106
+ const lines = view.split('\n');
107
+ const actions = [];
108
+
109
+ for (const [ref, el] of Object.entries(elements)) {
110
+ if (el.semantic === 'input' || el.semantic === 'textarea') {
111
+ // Find the label for this field by looking at surrounding text
112
+ const label = findLabel(el, lines, result);
113
+ const profileValue = matchFieldToProfile(label, el);
114
+
115
+ if (profileValue) {
116
+ actions.push({
117
+ action: 'type',
118
+ ref: parseInt(ref),
119
+ value: profileValue,
120
+ field: label,
121
+ confidence: 'high',
122
+ });
123
+ } else {
124
+ actions.push({
125
+ action: 'type',
126
+ ref: parseInt(ref),
127
+ value: null,
128
+ field: label,
129
+ confidence: 'unknown',
130
+ });
131
+ }
132
+ }
133
+
134
+ if (el.semantic === 'file') {
135
+ const label = findLabel(el, lines, result);
136
+ const isResume = /resume|cv/i.test(label);
137
+ const isCoverLetter = /cover.*letter/i.test(label);
138
+
139
+ actions.push({
140
+ action: 'upload',
141
+ ref: parseInt(ref),
142
+ filePath: isCoverLetter ? PROFILE.coverLetterPath : PROFILE.resumePath,
143
+ field: label,
144
+ fileType: isCoverLetter ? 'cover_letter' : 'resume',
145
+ });
146
+ }
147
+
148
+ if (el.semantic === 'select') {
149
+ const label = findLabel(el, lines, result);
150
+ actions.push({
151
+ action: 'select',
152
+ ref: parseInt(ref),
153
+ field: label,
154
+ confidence: 'needs_review',
155
+ });
156
+ }
157
+
158
+ if (el.semantic === 'checkbox' || el.semantic === 'radio') {
159
+ const label = findLabel(el, lines, result);
160
+ // Auto-check common consent/agreement checkboxes
161
+ if (/agree|consent|acknowledge|confirm|certif/i.test(label)) {
162
+ actions.push({
163
+ action: 'click',
164
+ ref: parseInt(ref),
165
+ field: label,
166
+ reason: 'auto-agree',
167
+ });
168
+ }
169
+ }
170
+ }
171
+
172
+ // Find submit button
173
+ for (const [ref, el] of Object.entries(elements)) {
174
+ if (el.semantic === 'button' || el.semantic === 'link') {
175
+ const text = (el.text || '').toLowerCase();
176
+ if (/submit|apply|next|continue|save|send/i.test(text) && !/cancel|back|sign.*in|log.*in/i.test(text)) {
177
+ actions.push({
178
+ action: 'submit',
179
+ ref: parseInt(ref),
180
+ text: el.text,
181
+ });
182
+ }
183
+ }
184
+ }
185
+
186
+ return actions;
187
+ }
188
+
189
+ /**
190
+ * Find the label text associated with a form field
191
+ */
192
+ function findLabel(el, lines, result) {
193
+ // Strategy 1: Check the element's own text/placeholder
194
+ if (el.text && el.text.length > 2) return el.text;
195
+
196
+ // Strategy 2: Look at the text grid near this element's position
197
+ // Find which line this element is on
198
+ const view = result.view;
199
+ const allLines = view.split('\n');
200
+
201
+ for (let i = 0; i < allLines.length; i++) {
202
+ const refPattern = `[${Object.entries(result.elements).find(([r, e]) => e === el)?.[0]}`;
203
+ if (allLines[i].includes(refPattern)) {
204
+ // Check same line for label text (to the left of the field)
205
+ const line = allLines[i];
206
+ const refIdx = line.indexOf(refPattern);
207
+ const leftText = line.substring(0, refIdx).trim();
208
+ if (leftText) return leftText;
209
+
210
+ // Check line above
211
+ if (i > 0) {
212
+ const above = allLines[i - 1].trim();
213
+ if (above && above.length < 60) return above;
214
+ }
215
+ break;
216
+ }
217
+ }
218
+
219
+ return el.text || 'unknown field';
220
+ }
221
+
222
+ /**
223
+ * Match a field label to profile data
224
+ */
225
+ function matchFieldToProfile(label, el) {
226
+ if (!label) return null;
227
+
228
+ for (const pattern of FIELD_PATTERNS) {
229
+ if (pattern.match.test(label)) {
230
+ return pattern.value();
231
+ }
232
+ }
233
+
234
+ return null;
235
+ }
236
+
237
+ // ─── Application Engine ─────────────────────────────────────────────────────
238
+
239
+ class JobApplicator {
240
+ constructor(options = {}) {
241
+ this.browser = null;
242
+ this.dryRun = options.dryRun || false;
243
+ this.verbose = options.verbose || false;
244
+ this.resumePath = options.resumePath || PROFILE.resumePath;
245
+ this.coverLetterPath = options.coverLetterPath || PROFILE.coverLetterPath;
246
+ this.maxSteps = options.maxSteps || 10; // safety limit for multi-step forms
247
+ this.log = [];
248
+ }
249
+
250
+ async init() {
251
+ this.browser = new AgentBrowser({ cols: 120 });
252
+ await this.browser.launch();
253
+ return this;
254
+ }
255
+
256
+ async apply(url) {
257
+ this._log('info', `Starting application: ${url}`);
258
+
259
+ // Navigate to the application page
260
+ let result = await this.browser.navigate(url);
261
+ const platform = detectPlatform(url, result.view);
262
+ this._log('info', `Detected platform: ${platform}`);
263
+ this._log('info', `Page: ${result.meta.title}`);
264
+
265
+ if (this.verbose) {
266
+ console.log('\n' + result.view + '\n');
267
+ }
268
+
269
+ let step = 0;
270
+ let completed = false;
271
+
272
+ while (step < this.maxSteps && !completed) {
273
+ step++;
274
+ this._log('info', `--- Step ${step} ---`);
275
+
276
+ // Analyze current form
277
+ const actions = analyzeForm(result);
278
+
279
+ if (actions.length === 0) {
280
+ this._log('warn', 'No form fields or actions found on this page');
281
+ break;
282
+ }
283
+
284
+ // Report what we found
285
+ const fillable = actions.filter(a => a.action === 'type' && a.value);
286
+ const unknown = actions.filter(a => a.action === 'type' && !a.value);
287
+ const uploads = actions.filter(a => a.action === 'upload');
288
+ const submits = actions.filter(a => a.action === 'submit');
289
+
290
+ this._log('info', `Found: ${fillable.length} auto-fill, ${unknown.length} unknown, ${uploads.length} uploads, ${submits.length} buttons`);
291
+
292
+ // Fill in known fields
293
+ for (const action of fillable) {
294
+ this._log('fill', `[${action.ref}] ${action.field} → "${action.value}"`);
295
+ if (!this.dryRun) {
296
+ try {
297
+ result = await this.browser.type(action.ref, action.value);
298
+ } catch (err) {
299
+ this._log('error', `Failed to fill [${action.ref}] ${action.field}: ${err.message}`);
300
+ }
301
+ }
302
+ }
303
+
304
+ // Upload files
305
+ for (const action of uploads) {
306
+ const filePath = action.fileType === 'cover_letter'
307
+ ? (this.coverLetterPath || this.resumePath)
308
+ : this.resumePath;
309
+
310
+ if (filePath && fs.existsSync(filePath)) {
311
+ this._log('upload', `[${action.ref}] ${action.field} ← ${path.basename(filePath)}`);
312
+ if (!this.dryRun) {
313
+ try {
314
+ result = await this.browser.upload(action.ref, filePath);
315
+ } catch (err) {
316
+ this._log('error', `Failed to upload [${action.ref}]: ${err.message}`);
317
+ }
318
+ }
319
+ } else {
320
+ this._log('warn', `No file for ${action.field} (path: ${filePath})`);
321
+ }
322
+ }
323
+
324
+ // Click agreement checkboxes
325
+ for (const action of actions.filter(a => a.action === 'click')) {
326
+ this._log('click', `[${action.ref}] ${action.field} (${action.reason})`);
327
+ if (!this.dryRun) {
328
+ try {
329
+ result = await this.browser.click(action.ref);
330
+ } catch (err) {
331
+ this._log('error', `Failed to click [${action.ref}]: ${err.message}`);
332
+ }
333
+ }
334
+ }
335
+
336
+ // Log unknown fields
337
+ for (const action of unknown) {
338
+ this._log('skip', `[${action.ref}] "${action.field}" — no auto-fill match`);
339
+ }
340
+
341
+ // Take a fresh snapshot after fills
342
+ if (!this.dryRun) {
343
+ result = await this.browser.snapshot();
344
+ }
345
+
346
+ if (this.verbose) {
347
+ console.log('\n--- After filling ---');
348
+ console.log(result.view);
349
+ }
350
+
351
+ // Find submit/next button
352
+ const submitBtn = submits.find(s => /next|continue/i.test(s.text)) || submits[0];
353
+
354
+ if (!submitBtn) {
355
+ this._log('warn', 'No submit/next button found');
356
+ break;
357
+ }
358
+
359
+ // Check for confirmation/success indicators
360
+ const viewLower = result.view.toLowerCase();
361
+ if (/application.*submitted|thank.*you.*appl|success.*submitted|application.*received/i.test(viewLower)) {
362
+ this._log('success', '🎉 Application submitted successfully!');
363
+ completed = true;
364
+ break;
365
+ }
366
+
367
+ // Submit / go to next step
368
+ this._log('click', `[${submitBtn.ref}] "${submitBtn.text}"`);
369
+ if (!this.dryRun) {
370
+ const prevUrl = result.meta.url;
371
+ try {
372
+ result = await this.browser.click(submitBtn.ref);
373
+ } catch (err) {
374
+ this._log('error', `Submit click failed: ${err.message}`);
375
+ break;
376
+ }
377
+
378
+ // Check if we landed on a success/thank you page
379
+ const newView = result.view.toLowerCase();
380
+ if (/application.*submitted|thank.*you|success|received.*application|already.*applied/i.test(newView)) {
381
+ this._log('success', '🎉 Application submitted successfully!');
382
+ completed = true;
383
+ break;
384
+ }
385
+
386
+ // Check if URL changed significantly (redirect to confirmation)
387
+ if (result.meta.url !== prevUrl && /confirm|success|thank/i.test(result.meta.url)) {
388
+ this._log('success', '🎉 Redirected to confirmation page');
389
+ completed = true;
390
+ break;
391
+ }
392
+ } else {
393
+ this._log('dry-run', `Would click [${submitBtn.ref}] "${submitBtn.text}"`);
394
+ break;
395
+ }
396
+ }
397
+
398
+ if (step >= this.maxSteps) {
399
+ this._log('warn', `Reached max steps (${this.maxSteps}). May need manual review.`);
400
+ }
401
+
402
+ return {
403
+ url,
404
+ platform,
405
+ completed,
406
+ steps: step,
407
+ log: this.log,
408
+ finalView: result.view,
409
+ };
410
+ }
411
+
412
+ async close() {
413
+ if (this.browser) await this.browser.close();
414
+ }
415
+
416
+ _log(level, message) {
417
+ const entry = { time: new Date().toISOString(), level, message };
418
+ this.log.push(entry);
419
+ const prefix = {
420
+ info: ' ℹ',
421
+ fill: ' ✏️',
422
+ upload: ' 📎',
423
+ click: ' 👆',
424
+ skip: ' ⏭️',
425
+ warn: ' ⚠️',
426
+ error: ' ❌',
427
+ success: ' ✅',
428
+ 'dry-run': ' 🔍',
429
+ }[level] || ' ';
430
+ console.error(`${prefix} ${message}`);
431
+ }
432
+ }
433
+
434
+ // ─── Batch Mode ─────────────────────────────────────────────────────────────
435
+
436
+ async function batchApply(jobsFile, options) {
437
+ const jobs = JSON.parse(fs.readFileSync(jobsFile, 'utf8'));
438
+ const results = [];
439
+
440
+ console.error(`\nBatch applying to ${jobs.length} jobs...\n`);
441
+
442
+ for (let i = 0; i < jobs.length; i++) {
443
+ const job = jobs[i];
444
+ const url = job.apply_url || job.url || job.applyUrl;
445
+ if (!url) {
446
+ console.error(` ⏭️ [${i + 1}/${jobs.length}] Skipping "${job.title}" — no URL`);
447
+ continue;
448
+ }
449
+
450
+ console.error(`\n━━━ [${i + 1}/${jobs.length}] ${job.title || 'Unknown'} at ${job.company || 'Unknown'} ━━━`);
451
+
452
+ const applicator = new JobApplicator({
453
+ ...options,
454
+ coverLetterPath: job.coverLetterPath || options.coverLetterPath,
455
+ resumePath: job.resumePath || options.resumePath,
456
+ });
457
+
458
+ try {
459
+ await applicator.init();
460
+ const result = await applicator.apply(url);
461
+ results.push({ job, ...result });
462
+ } catch (err) {
463
+ console.error(` ❌ Failed: ${err.message}`);
464
+ results.push({ job, url, completed: false, error: err.message });
465
+ } finally {
466
+ await applicator.close();
467
+ }
468
+
469
+ // Brief pause between applications
470
+ if (i < jobs.length - 1) {
471
+ await new Promise(r => setTimeout(r, 2000));
472
+ }
473
+ }
474
+
475
+ // Summary
476
+ const succeeded = results.filter(r => r.completed).length;
477
+ const failed = results.filter(r => !r.completed).length;
478
+ console.error(`\n━━━ Summary: ${succeeded} submitted, ${failed} need review ━━━\n`);
479
+
480
+ // Output results as JSON to stdout
481
+ console.log(JSON.stringify(results, null, 2));
482
+ return results;
483
+ }
484
+
485
+ // ─── CLI ─────────────────────────────────────────────────────────────────────
486
+
487
+ async function main() {
488
+ const args = process.argv.slice(2);
489
+
490
+ const options = {
491
+ dryRun: false,
492
+ verbose: false,
493
+ resumePath: PROFILE.resumePath,
494
+ coverLetterPath: null,
495
+ batchFile: null,
496
+ url: null,
497
+ };
498
+
499
+ for (let i = 0; i < args.length; i++) {
500
+ const arg = args[i];
501
+ if (arg === '--dry-run' || arg === '-n') options.dryRun = true;
502
+ else if (arg === '--verbose' || arg === '-v') options.verbose = true;
503
+ else if (arg === '--resume') options.resumePath = args[++i];
504
+ else if (arg === '--cover-letter') options.coverLetterPath = args[++i];
505
+ else if (arg === '--batch') options.batchFile = args[++i];
506
+ else if (arg === '-h' || arg === '--help') { printHelp(); process.exit(0); }
507
+ else if (!arg.startsWith('-')) options.url = arg;
508
+ }
509
+
510
+ if (options.batchFile) {
511
+ await batchApply(options.batchFile, options);
512
+ return;
513
+ }
514
+
515
+ if (!options.url) {
516
+ printHelp();
517
+ process.exit(1);
518
+ }
519
+
520
+ const applicator = new JobApplicator(options);
521
+ try {
522
+ await applicator.init();
523
+ const result = await applicator.apply(options.url);
524
+ console.log(JSON.stringify(result, null, 2));
525
+ } finally {
526
+ await applicator.close();
527
+ }
528
+ }
529
+
530
+ function printHelp() {
531
+ console.log(`
532
+ TextWeb Job Applicator — Fill out job applications without screenshots
533
+
534
+ Usage:
535
+ node apply.js <url> Apply to a single job
536
+ node apply.js --batch <jobs.json> Apply to multiple jobs
537
+
538
+ Options:
539
+ --dry-run, -n Show what would be filled without submitting
540
+ --verbose, -v Print page views at each step
541
+ --resume <path> Path to resume PDF (default: ~/.jobsearch/christopher-robison-resume.pdf)
542
+ --cover-letter <path> Path to cover letter PDF
543
+ -h, --help Show this help
544
+
545
+ Batch JSON format:
546
+ [
547
+ { "title": "VP Eng", "company": "Acme", "apply_url": "https://..." },
548
+ { "title": "CTO", "company": "Startup", "url": "https://...", "resumePath": "/custom/resume.pdf" }
549
+ ]
550
+
551
+ Examples:
552
+ node apply.js https://boards.greenhouse.io/company/jobs/123
553
+ node apply.js --dry-run https://jobs.lever.co/company/abc-123
554
+ node apply.js --batch ~/.jobsearch/to_apply.json --verbose
555
+ `);
556
+ }
557
+
558
+ module.exports = { JobApplicator, analyzeForm, detectPlatform, PROFILE, FIELD_PATTERNS };
559
+
560
+ if (require.main === module) {
561
+ main().catch(err => {
562
+ console.error(`Fatal: ${err.message}`);
563
+ process.exit(1);
564
+ });
565
+ }
package/src/browser.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * AgentBrowser — the main interface for AI agents to browse the web
3
+ */
4
+
5
+ const { chromium } = require('playwright');
6
+ const { render } = require('./renderer');
7
+
8
+ class AgentBrowser {
9
+ constructor(options = {}) {
10
+ this.cols = options.cols || 120;
11
+ this.scrollY = 0;
12
+ this.browser = null;
13
+ this.context = null;
14
+ this.page = null;
15
+ this.lastResult = null;
16
+ this.headless = options.headless !== false;
17
+ this.charH = 16; // default, updated after first render
18
+ }
19
+
20
+ async launch() {
21
+ this.browser = await chromium.launch({
22
+ headless: this.headless,
23
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
24
+ });
25
+ this.context = await this.browser.newContext({
26
+ viewport: { width: 1280, height: 800 },
27
+ userAgent: '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',
28
+ });
29
+ this.page = await this.context.newPage();
30
+ this.page.setDefaultTimeout(30000);
31
+ return this;
32
+ }
33
+
34
+ async navigate(url) {
35
+ if (!this.page) await this.launch();
36
+ this.scrollY = 0;
37
+ await this.page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
38
+ return await this.snapshot();
39
+ }
40
+
41
+ async snapshot() {
42
+ if (!this.page) throw new Error('No page open. Call navigate() first.');
43
+ this.lastResult = await render(this.page, {
44
+ cols: this.cols,
45
+ scrollY: this.scrollY,
46
+ });
47
+ this.lastResult.meta.url = this.page.url();
48
+ this.lastResult.meta.title = await this.page.title();
49
+ // Cache measured charH for scrolling
50
+ if (this.lastResult.meta.charH) this.charH = this.lastResult.meta.charH;
51
+ return this.lastResult;
52
+ }
53
+
54
+ async click(ref) {
55
+ const el = this._getElement(ref);
56
+ await this.page.click(el.selector);
57
+ await this.page.waitForLoadState('networkidle').catch(() => {});
58
+ return await this.snapshot();
59
+ }
60
+
61
+ async type(ref, text) {
62
+ const el = this._getElement(ref);
63
+ await this.page.click(el.selector);
64
+ await this.page.fill(el.selector, text);
65
+ return await this.snapshot();
66
+ }
67
+
68
+ async press(key) {
69
+ await this.page.keyboard.press(key);
70
+ await this.page.waitForLoadState('networkidle').catch(() => {});
71
+ return await this.snapshot();
72
+ }
73
+
74
+ async upload(ref, filePaths) {
75
+ const el = this._getElement(ref);
76
+ const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
77
+ await this.page.setInputFiles(el.selector, paths);
78
+ return await this.snapshot();
79
+ }
80
+
81
+ async select(ref, value) {
82
+ const el = this._getElement(ref);
83
+ await this.page.selectOption(el.selector, value);
84
+ return await this.snapshot();
85
+ }
86
+
87
+ async scroll(direction = 'down', amount = 1) {
88
+ // Scroll by roughly one "page" worth of content
89
+ const pageH = 40 * this.charH; // ~40 lines of content
90
+ const delta = amount * pageH;
91
+ if (direction === 'down') {
92
+ this.scrollY += delta;
93
+ } else if (direction === 'up') {
94
+ this.scrollY = Math.max(0, this.scrollY - delta);
95
+ } else if (direction === 'top') {
96
+ this.scrollY = 0;
97
+ }
98
+ await this.page.evaluate((y) => window.scrollTo(0, y), this.scrollY);
99
+ await this.page.waitForTimeout(500);
100
+ return await this.snapshot();
101
+ }
102
+
103
+ async readRegion(r1, c1, r2, c2) {
104
+ if (!this.lastResult) throw new Error('No snapshot. Navigate first.');
105
+ const lines = this.lastResult.view.split('\n');
106
+ const region = [];
107
+ for (let r = r1; r <= Math.min(r2, lines.length - 1); r++) {
108
+ region.push(lines[r].substring(c1, c2 + 1));
109
+ }
110
+ return region.join('\n');
111
+ }
112
+
113
+ async evaluate(fn) {
114
+ return await this.page.evaluate(fn);
115
+ }
116
+
117
+ async close() {
118
+ if (this.browser) {
119
+ await this.browser.close();
120
+ this.browser = null;
121
+ this.context = null;
122
+ this.page = null;
123
+ }
124
+ }
125
+
126
+ _getElement(ref) {
127
+ if (!this.lastResult) throw new Error('No snapshot. Navigate first.');
128
+ const el = this.lastResult.elements[ref];
129
+ if (!el) throw new Error(`Element ref [${ref}] not found. Available: ${Object.keys(this.lastResult.elements).join(', ')}`);
130
+ return el;
131
+ }
132
+ }
133
+
134
+ module.exports = { AgentBrowser };