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/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 };
|