textweb 0.1.2 → 0.2.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/README.md +0 -20
- package/package.json +1 -1
- package/src/browser.js +52 -2
- package/src/cli.js +7 -6
- package/src/renderer.js +2 -40
- package/src/server.js +1 -2
- package/.env.example +0 -25
- 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/apply.js
DELETED
|
@@ -1,745 +0,0 @@
|
|
|
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 { generateAnswers, checkLLM } = require('./llm');
|
|
16
|
-
const path = require('path');
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
|
|
19
|
-
// ─── Applicant Profile ───────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
const PROFILE = {
|
|
22
|
-
firstName: 'Christopher',
|
|
23
|
-
lastName: 'Robison',
|
|
24
|
-
fullName: 'Christopher Robison',
|
|
25
|
-
email: 'cdr@cdr2.com',
|
|
26
|
-
phone: '(415) 810-6991',
|
|
27
|
-
location: 'San Francisco, CA',
|
|
28
|
-
linkedin: 'https://linkedin.com/in/crobison',
|
|
29
|
-
github: 'https://github.com/chrisrobison',
|
|
30
|
-
website: 'https://cdr2.com',
|
|
31
|
-
twitter: 'https://twitter.com/thechrisrobison',
|
|
32
|
-
github: 'https://github.com/chrisrobison',
|
|
33
|
-
currentTitle: 'CTO',
|
|
34
|
-
currentCompany: 'D. Harris Tours',
|
|
35
|
-
yearsExperience: '25',
|
|
36
|
-
willingToRelocate: 'Yes',
|
|
37
|
-
workAuthorization: 'US Citizen',
|
|
38
|
-
requireSponsorship: 'No',
|
|
39
|
-
salaryExpectation: '200000',
|
|
40
|
-
noticePeriod: 'Immediately',
|
|
41
|
-
country: 'United States',
|
|
42
|
-
countryCode: 'US',
|
|
43
|
-
school: 'City College of San Francisco',
|
|
44
|
-
degree: "Bachelor's",
|
|
45
|
-
|
|
46
|
-
// Default resume/cover letter
|
|
47
|
-
resumePath: path.join(process.env.HOME, '.jobsearch/christopher-robison-resume.pdf'),
|
|
48
|
-
coverLetterPath: null, // set per-application if available
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// ─── Field Matching ──────────────────────────────────────────────────────────
|
|
52
|
-
// Maps common form field labels/placeholders to profile values
|
|
53
|
-
|
|
54
|
-
const FIELD_PATTERNS = [
|
|
55
|
-
// Name fields
|
|
56
|
-
{ match: /first\s*name/i, value: () => PROFILE.firstName },
|
|
57
|
-
{ match: /last\s*name|family\s*name|surname/i, value: () => PROFILE.lastName },
|
|
58
|
-
{ match: /full\s*name|^name$|^name:|customer.*name|your.*name|applicant.*name/i, value: () => PROFILE.fullName },
|
|
59
|
-
|
|
60
|
-
// Contact (email before address — "email address" should match email, not location)
|
|
61
|
-
{ match: /e-?mail/i, value: () => PROFILE.email },
|
|
62
|
-
{ match: /phone|mobile|cell|telephone/i, value: () => PROFILE.phone },
|
|
63
|
-
|
|
64
|
-
// Location (exclude "email address" and yes/no questions that mention "location")
|
|
65
|
-
{ match: /^(?!.*e-?mail)(?!.*authorized)(?!.*sponsor)(?!.*remote)(?!.*relocat).*(city|^location$|address|zip|postal)/i, value: () => PROFILE.location },
|
|
66
|
-
|
|
67
|
-
// Links
|
|
68
|
-
{ match: /linkedin/i, value: () => PROFILE.linkedin },
|
|
69
|
-
{ match: /twitter|x\.com|@.*handle/i, value: () => PROFILE.twitter },
|
|
70
|
-
{ match: /github/i, value: () => PROFILE.github },
|
|
71
|
-
{ match: /github/i, value: () => PROFILE.github },
|
|
72
|
-
{ match: /website|portfolio|personal.*url|blog/i, value: () => PROFILE.website },
|
|
73
|
-
|
|
74
|
-
// Work info
|
|
75
|
-
{ match: /current.*title|job.*title/i, value: () => PROFILE.currentTitle },
|
|
76
|
-
{ match: /bound.*agreement|non.?compete|restrict|post.?employment|employment.*agreement/i, value: () => 'No' },
|
|
77
|
-
{ match: /current.*company|employer|organization/i, value: () => PROFILE.currentCompany },
|
|
78
|
-
{ match: /^(?!.*do you have).*(?:years.*experience|experience.*years|how many years)/i, value: () => PROFILE.yearsExperience },
|
|
79
|
-
{ match: /^do you have.*(?:years|experience)/i, value: () => 'Yes' },
|
|
80
|
-
|
|
81
|
-
// Logistics
|
|
82
|
-
{ match: /relocat/i, value: () => PROFILE.willingToRelocate },
|
|
83
|
-
{ match: /sponsor/i, value: () => 'No' },
|
|
84
|
-
{ match: /authorized|authorization|legally.*work|eligible.*work/i, value: () => 'Yes' },
|
|
85
|
-
{ match: /plan to work remote|prefer.*remote|work.*remotely/i, value: () => 'Yes' },
|
|
86
|
-
{ match: /ever been employed|previously.*employed|worked.*before/i, value: () => 'No' },
|
|
87
|
-
{ match: /salary|compensation|pay.*expect/i, value: () => PROFILE.salaryExpectation },
|
|
88
|
-
{ match: /notice.*period|start.*date|availab.*start|when.*start/i, value: () => PROFILE.noticePeriod },
|
|
89
|
-
{ match: /how.*hear|where.*find|referr(?!ed)|source.*(?:job|opening|position)|how.*learn.*about/i, value: () => 'Online job search' },
|
|
90
|
-
|
|
91
|
-
// Country
|
|
92
|
-
{ match: /^country$|select.*country.*reside|country.*currently/i, value: () => PROFILE.country },
|
|
93
|
-
|
|
94
|
-
// Education
|
|
95
|
-
{ match: /school|university|college|institution/i, value: () => PROFILE.school },
|
|
96
|
-
{ match: /degree|education.*level/i, value: () => PROFILE.degree },
|
|
97
|
-
|
|
98
|
-
// Social profiles
|
|
99
|
-
{ match: /twitter|x\.com|@.*handle/i, value: () => PROFILE.twitter },
|
|
100
|
-
{ match: /^github$|github.*profile|github.*url/i, value: () => PROFILE.github },
|
|
101
|
-
|
|
102
|
-
// Common freeform with safe defaults
|
|
103
|
-
{ match: /accessible|accommodat|adjustment.*interview|disability.*interview/i, value: () => 'No adjustments needed, thank you.' },
|
|
104
|
-
{ match: /preferred.*name|name.*prefer|call you/i, value: () => PROFILE.preferredName || PROFILE.firstName },
|
|
105
|
-
{ match: /country.*resid|current.*country/i, value: () => PROFILE.country },
|
|
106
|
-
{ match: /lgbtq|sexual.*orient/i, value: () => 'Prefer not to say' },
|
|
107
|
-
|
|
108
|
-
// Yes/No common questions
|
|
109
|
-
{ match: /opt.?in|subscribe|marketing.*(?:email|message)|whatsapp/i, value: () => 'No' },
|
|
110
|
-
];
|
|
111
|
-
|
|
112
|
-
// ─── EEO / Demographic Fields (skip these) ──────────────────────────────────
|
|
113
|
-
const EEO_PATTERNS = [
|
|
114
|
-
/gender/i,
|
|
115
|
-
/race|ethnicity/i,
|
|
116
|
-
/hispanic|latino/i,
|
|
117
|
-
/veteran/i,
|
|
118
|
-
/disability|disabled/i,
|
|
119
|
-
/sexual.*orientation/i,
|
|
120
|
-
/pronoun/i,
|
|
121
|
-
];
|
|
122
|
-
|
|
123
|
-
// ─── Platform Detection ─────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
function detectPlatform(url, pageText) {
|
|
126
|
-
const u = url.toLowerCase();
|
|
127
|
-
const t = (pageText || '').toLowerCase();
|
|
128
|
-
|
|
129
|
-
if (u.includes('linkedin.com')) return 'linkedin';
|
|
130
|
-
if (u.includes('greenhouse.io') || u.includes('boards.greenhouse')) return 'greenhouse';
|
|
131
|
-
if (u.includes('myworkday') || u.includes('workday.com')) return 'workday';
|
|
132
|
-
if (u.includes('lever.co') || u.includes('jobs.lever')) return 'lever';
|
|
133
|
-
if (u.includes('ashbyhq.com')) return 'ashby';
|
|
134
|
-
if (u.includes('smartrecruiters')) return 'smartrecruiters';
|
|
135
|
-
if (u.includes('icims')) return 'icims';
|
|
136
|
-
if (u.includes('indeed.com')) return 'indeed';
|
|
137
|
-
if (t.includes('greenhouse')) return 'greenhouse';
|
|
138
|
-
if (t.includes('workday')) return 'workday';
|
|
139
|
-
if (t.includes('lever')) return 'lever';
|
|
140
|
-
return 'generic';
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ─── Form Analysis ──────────────────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Analyze a page snapshot to identify fillable fields and map them to profile data
|
|
147
|
-
*/
|
|
148
|
-
function analyzeForm(result) {
|
|
149
|
-
const { view, elements } = result;
|
|
150
|
-
const lines = view.split('\n');
|
|
151
|
-
const actions = [];
|
|
152
|
-
|
|
153
|
-
for (const [ref, el] of Object.entries(elements)) {
|
|
154
|
-
if (el.semantic === 'input' || el.semantic === 'textarea') {
|
|
155
|
-
// Use the label from the renderer (which checks <label for>, aria-label, etc.)
|
|
156
|
-
// Fall back to spatial label detection from the text grid
|
|
157
|
-
const label = el.label || findLabel(el, lines, result);
|
|
158
|
-
|
|
159
|
-
// Skip EEO/demographic fields
|
|
160
|
-
if (isEEOField(label)) {
|
|
161
|
-
actions.push({
|
|
162
|
-
action: 'skip',
|
|
163
|
-
ref: parseInt(ref),
|
|
164
|
-
field: label,
|
|
165
|
-
reason: 'eeo_demographic',
|
|
166
|
-
});
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const profileValue = matchFieldToProfile(label, el);
|
|
171
|
-
|
|
172
|
-
if (profileValue) {
|
|
173
|
-
actions.push({
|
|
174
|
-
action: 'type',
|
|
175
|
-
ref: parseInt(ref),
|
|
176
|
-
selector: el.selector,
|
|
177
|
-
value: profileValue,
|
|
178
|
-
field: label,
|
|
179
|
-
confidence: 'high',
|
|
180
|
-
});
|
|
181
|
-
} else {
|
|
182
|
-
actions.push({
|
|
183
|
-
action: 'type',
|
|
184
|
-
ref: parseInt(ref),
|
|
185
|
-
selector: el.selector,
|
|
186
|
-
value: null,
|
|
187
|
-
field: label,
|
|
188
|
-
confidence: 'unknown',
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (el.semantic === 'file') {
|
|
194
|
-
const label = el.label || findLabel(el, lines, result);
|
|
195
|
-
const isResume = /resume|cv/i.test(label);
|
|
196
|
-
const isCoverLetter = /cover.*letter/i.test(label);
|
|
197
|
-
|
|
198
|
-
actions.push({
|
|
199
|
-
action: 'upload',
|
|
200
|
-
ref: parseInt(ref),
|
|
201
|
-
selector: el.selector,
|
|
202
|
-
filePath: isCoverLetter ? PROFILE.coverLetterPath : PROFILE.resumePath,
|
|
203
|
-
field: label,
|
|
204
|
-
fileType: isCoverLetter ? 'cover_letter' : 'resume',
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (el.semantic === 'select') {
|
|
209
|
-
const label = el.label || findLabel(el, lines, result);
|
|
210
|
-
actions.push({
|
|
211
|
-
action: 'select',
|
|
212
|
-
ref: parseInt(ref),
|
|
213
|
-
field: label,
|
|
214
|
-
confidence: 'needs_review',
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (el.semantic === 'checkbox' || el.semantic === 'radio') {
|
|
219
|
-
const label = el.label || findLabel(el, lines, result);
|
|
220
|
-
// Auto-check common consent/agreement checkboxes
|
|
221
|
-
if (/agree|consent|acknowledge|confirm|certif/i.test(label)) {
|
|
222
|
-
actions.push({
|
|
223
|
-
action: 'click',
|
|
224
|
-
ref: parseInt(ref),
|
|
225
|
-
field: label,
|
|
226
|
-
reason: 'auto-agree',
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Find submit button
|
|
233
|
-
for (const [ref, el] of Object.entries(elements)) {
|
|
234
|
-
if (el.semantic === 'button' || el.semantic === 'link') {
|
|
235
|
-
const text = (el.text || '').toLowerCase();
|
|
236
|
-
if (/submit|apply|next|continue|save|send/i.test(text) && !/cancel|back|sign.*in|log.*in/i.test(text)) {
|
|
237
|
-
actions.push({
|
|
238
|
-
action: 'submit',
|
|
239
|
-
ref: parseInt(ref),
|
|
240
|
-
text: el.text,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return actions;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Find the label text associated with a form field
|
|
251
|
-
*/
|
|
252
|
-
function findLabel(el, lines, result) {
|
|
253
|
-
// Strategy 1: Check the element's own text/placeholder
|
|
254
|
-
if (el.text && el.text.length > 2) return el.text;
|
|
255
|
-
|
|
256
|
-
// Strategy 2: Look at the text grid near this element's position
|
|
257
|
-
// Find which line this element is on
|
|
258
|
-
const view = result.view;
|
|
259
|
-
const allLines = view.split('\n');
|
|
260
|
-
|
|
261
|
-
for (let i = 0; i < allLines.length; i++) {
|
|
262
|
-
const refPattern = `[${Object.entries(result.elements).find(([r, e]) => e === el)?.[0]}`;
|
|
263
|
-
if (allLines[i].includes(refPattern)) {
|
|
264
|
-
// Check same line for label text (to the left of the field)
|
|
265
|
-
const line = allLines[i];
|
|
266
|
-
const refIdx = line.indexOf(refPattern);
|
|
267
|
-
const leftText = line.substring(0, refIdx).trim();
|
|
268
|
-
if (leftText) return leftText;
|
|
269
|
-
|
|
270
|
-
// Check line above
|
|
271
|
-
if (i > 0) {
|
|
272
|
-
const above = allLines[i - 1].trim();
|
|
273
|
-
if (above && above.length < 60) return above;
|
|
274
|
-
}
|
|
275
|
-
break;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return el.text || 'unknown field';
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Check if a field is an EEO/demographic field that should be skipped
|
|
284
|
-
*/
|
|
285
|
-
function isEEOField(label) {
|
|
286
|
-
if (!label) return false;
|
|
287
|
-
return EEO_PATTERNS.some(pattern => pattern.test(label));
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Match a field label to profile data
|
|
292
|
-
*/
|
|
293
|
-
function matchFieldToProfile(label, el) {
|
|
294
|
-
if (!label) return null;
|
|
295
|
-
|
|
296
|
-
for (const pattern of FIELD_PATTERNS) {
|
|
297
|
-
if (pattern.match.test(label)) {
|
|
298
|
-
return pattern.value();
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
return null;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// ─── Application Engine ─────────────────────────────────────────────────────
|
|
306
|
-
|
|
307
|
-
class JobApplicator {
|
|
308
|
-
constructor(options = {}) {
|
|
309
|
-
this.browser = null;
|
|
310
|
-
this.dryRun = options.dryRun || false;
|
|
311
|
-
this.verbose = options.verbose || false;
|
|
312
|
-
this.resumePath = options.resumePath || PROFILE.resumePath;
|
|
313
|
-
this.coverLetterPath = options.coverLetterPath || PROFILE.coverLetterPath;
|
|
314
|
-
this.maxSteps = options.maxSteps || 10; // safety limit for multi-step forms
|
|
315
|
-
this.useLLM = options.useLLM !== false; // default: try LLM for unknowns
|
|
316
|
-
this.llmConfig = options.llmConfig || {};
|
|
317
|
-
this.jobDescription = options.jobDescription || '';
|
|
318
|
-
this.company = options.company || '';
|
|
319
|
-
this.log = [];
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
async init() {
|
|
323
|
-
this.browser = new AgentBrowser({ cols: 120 });
|
|
324
|
-
await this.browser.launch();
|
|
325
|
-
return this;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
async apply(url) {
|
|
329
|
-
this._log('info', `Starting application: ${url}`);
|
|
330
|
-
|
|
331
|
-
// Navigate to the application page
|
|
332
|
-
let result = await this.browser.navigate(url);
|
|
333
|
-
let platform = detectPlatform(url, result.view);
|
|
334
|
-
this._log('info', `Detected platform: ${platform}`);
|
|
335
|
-
this._log('info', `Page: ${result.meta.title}`);
|
|
336
|
-
|
|
337
|
-
// Check for embedded ATS iframe (Greenhouse, Lever, etc.)
|
|
338
|
-
// Skip if we're already on an ATS domain
|
|
339
|
-
const alreadyOnATS = /greenhouse\.io|lever\.co|ashbyhq\.com|myworkday|workday\.com/i.test(url);
|
|
340
|
-
if (!alreadyOnATS) {
|
|
341
|
-
const iframeUrl = await this._detectATSIframe();
|
|
342
|
-
if (iframeUrl) {
|
|
343
|
-
this._log('info', `Found embedded ATS form: ${iframeUrl}`);
|
|
344
|
-
result = await this.browser.navigate(iframeUrl);
|
|
345
|
-
platform = detectPlatform(iframeUrl, result.view);
|
|
346
|
-
this._log('info', `Switched to: ${platform}`);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
if (this.verbose) {
|
|
351
|
-
console.log('\n' + result.view + '\n');
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
let step = 0;
|
|
355
|
-
let completed = false;
|
|
356
|
-
|
|
357
|
-
while (step < this.maxSteps && !completed) {
|
|
358
|
-
step++;
|
|
359
|
-
this._log('info', `--- Step ${step} ---`);
|
|
360
|
-
|
|
361
|
-
// Analyze current form
|
|
362
|
-
const actions = analyzeForm(result);
|
|
363
|
-
|
|
364
|
-
if (actions.length === 0) {
|
|
365
|
-
this._log('warn', 'No form fields or actions found on this page');
|
|
366
|
-
break;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Report what we found
|
|
370
|
-
const fillable = actions.filter(a => a.action === 'type' && a.value);
|
|
371
|
-
const unknown = actions.filter(a => a.action === 'type' && !a.value);
|
|
372
|
-
const uploads = actions.filter(a => a.action === 'upload');
|
|
373
|
-
const submits = actions.filter(a => a.action === 'submit');
|
|
374
|
-
|
|
375
|
-
this._log('info', `Found: ${fillable.length} auto-fill, ${unknown.length} unknown, ${uploads.length} uploads, ${submits.length} buttons`);
|
|
376
|
-
|
|
377
|
-
// Fill in known fields
|
|
378
|
-
for (const action of fillable) {
|
|
379
|
-
this._log('fill', `[${action.ref}] ${action.field} → "${action.value}"`);
|
|
380
|
-
if (!this.dryRun) {
|
|
381
|
-
try {
|
|
382
|
-
// Check if this is a combobox (React Select, etc.)
|
|
383
|
-
const isCombobox = await this.browser.page.evaluate((selector) => {
|
|
384
|
-
const el = document.querySelector(selector);
|
|
385
|
-
return el && (el.getAttribute('role') === 'combobox' ||
|
|
386
|
-
el.classList.contains('select__input') ||
|
|
387
|
-
el.getAttribute('aria-autocomplete') === 'list');
|
|
388
|
-
}, result.elements[action.ref]?.selector);
|
|
389
|
-
|
|
390
|
-
if (isCombobox) {
|
|
391
|
-
// For comboboxes: click, clear, type value, wait for dropdown, press Enter
|
|
392
|
-
this._log('fill', ` (combobox mode for [${action.ref}])`);
|
|
393
|
-
const sel = result.elements[action.ref].selector;
|
|
394
|
-
await this.browser.page.click(sel);
|
|
395
|
-
await this.browser.page.fill(sel, '');
|
|
396
|
-
await this.browser.page.type(sel, action.value, { delay: 50 });
|
|
397
|
-
await this.browser.page.waitForTimeout(500); // wait for dropdown
|
|
398
|
-
await this.browser.page.keyboard.press('ArrowDown');
|
|
399
|
-
await this.browser.page.waitForTimeout(100);
|
|
400
|
-
await this.browser.page.keyboard.press('Enter');
|
|
401
|
-
await this.browser.page.waitForTimeout(200);
|
|
402
|
-
result = await this.browser.snapshot();
|
|
403
|
-
} else {
|
|
404
|
-
result = await this.browser.type(action.ref, action.value);
|
|
405
|
-
}
|
|
406
|
-
} catch (err) {
|
|
407
|
-
this._log('error', `Failed to fill [${action.ref}] ${action.field}: ${err.message}`);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Upload files
|
|
413
|
-
for (const action of uploads) {
|
|
414
|
-
const filePath = action.fileType === 'cover_letter'
|
|
415
|
-
? (this.coverLetterPath || this.resumePath)
|
|
416
|
-
: this.resumePath;
|
|
417
|
-
|
|
418
|
-
if (filePath && fs.existsSync(filePath)) {
|
|
419
|
-
this._log('upload', `[${action.ref}] ${action.field} ← ${path.basename(filePath)}`);
|
|
420
|
-
if (!this.dryRun) {
|
|
421
|
-
try {
|
|
422
|
-
result = await this.browser.upload(action.ref, filePath);
|
|
423
|
-
} catch (err) {
|
|
424
|
-
this._log('error', `Failed to upload [${action.ref}]: ${err.message}`);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
} else {
|
|
428
|
-
this._log('warn', `No file for ${action.field} (path: ${filePath})`);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// Click agreement checkboxes
|
|
433
|
-
for (const action of actions.filter(a => a.action === 'click')) {
|
|
434
|
-
this._log('click', `[${action.ref}] ${action.field} (${action.reason})`);
|
|
435
|
-
if (!this.dryRun) {
|
|
436
|
-
try {
|
|
437
|
-
result = await this.browser.click(action.ref);
|
|
438
|
-
} catch (err) {
|
|
439
|
-
this._log('error', `Failed to click [${action.ref}]: ${err.message}`);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// Use LLM to answer unknown freeform fields
|
|
445
|
-
if (unknown.length > 0 && this.useLLM && !this.dryRun) {
|
|
446
|
-
const llmAvailable = await checkLLM(this.llmConfig);
|
|
447
|
-
if (llmAvailable) {
|
|
448
|
-
this._log('info', `Generating LLM answers for ${unknown.length} unknown fields...`);
|
|
449
|
-
|
|
450
|
-
// Extract job description from page if we don't have it
|
|
451
|
-
if (!this.jobDescription) {
|
|
452
|
-
this.jobDescription = result.view.substring(0, 3000);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const answers = await generateAnswers(unknown, this.jobDescription, this.company, this.llmConfig);
|
|
456
|
-
|
|
457
|
-
for (const action of unknown) {
|
|
458
|
-
const answer = answers[action.ref];
|
|
459
|
-
if (answer) {
|
|
460
|
-
this._log('fill', `[${action.ref}] ${action.field} → "${answer.substring(0, 80)}${answer.length > 80 ? '...' : ''}" (LLM)`);
|
|
461
|
-
try {
|
|
462
|
-
result = await this.browser.type(action.ref, answer);
|
|
463
|
-
} catch (err) {
|
|
464
|
-
this._log('error', `Failed to fill [${action.ref}] ${action.field}: ${err.message}`);
|
|
465
|
-
}
|
|
466
|
-
} else {
|
|
467
|
-
this._log('skip', `[${action.ref}] "${action.field}" — LLM couldn't answer`);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
} else {
|
|
471
|
-
this._log('warn', 'LLM not available — skipping freeform fields');
|
|
472
|
-
for (const action of unknown) {
|
|
473
|
-
this._log('skip', `[${action.ref}] "${action.field}" — no auto-fill match, no LLM`);
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
} else {
|
|
477
|
-
for (const action of unknown) {
|
|
478
|
-
this._log('skip', `[${action.ref}] "${action.field}" — no auto-fill match`);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Log skipped EEO fields
|
|
483
|
-
for (const action of actions.filter(a => a.action === 'skip' && a.reason === 'eeo_demographic')) {
|
|
484
|
-
this._log('skip', `[${action.ref}] "${action.field}" — EEO demographic (intentionally blank)`);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Take a fresh snapshot after fills
|
|
488
|
-
if (!this.dryRun) {
|
|
489
|
-
result = await this.browser.snapshot();
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
if (this.verbose) {
|
|
493
|
-
console.log('\n--- After filling ---');
|
|
494
|
-
console.log(result.view);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// Find submit/next button
|
|
498
|
-
const submitBtn = submits.find(s => /next|continue/i.test(s.text)) || submits[0];
|
|
499
|
-
|
|
500
|
-
if (!submitBtn) {
|
|
501
|
-
this._log('warn', 'No submit/next button found');
|
|
502
|
-
break;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Check for confirmation/success indicators
|
|
506
|
-
const viewLower = result.view.toLowerCase();
|
|
507
|
-
if (/application.*submitted|thank.*you.*appl|success.*submitted|application.*received/i.test(viewLower)) {
|
|
508
|
-
this._log('success', '🎉 Application submitted successfully!');
|
|
509
|
-
completed = true;
|
|
510
|
-
break;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// Submit / go to next step
|
|
514
|
-
this._log('click', `[${submitBtn.ref}] "${submitBtn.text}"`);
|
|
515
|
-
if (!this.dryRun) {
|
|
516
|
-
const prevUrl = result.meta.url;
|
|
517
|
-
try {
|
|
518
|
-
result = await this.browser.click(submitBtn.ref);
|
|
519
|
-
} catch (err) {
|
|
520
|
-
this._log('error', `Submit click failed: ${err.message}`);
|
|
521
|
-
break;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Check if we landed on a success/thank you page
|
|
525
|
-
const newView = result.view.toLowerCase();
|
|
526
|
-
if (/application.*submitted|thank.*you|success|received.*application|already.*applied/i.test(newView)) {
|
|
527
|
-
this._log('success', '🎉 Application submitted successfully!');
|
|
528
|
-
completed = true;
|
|
529
|
-
break;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// Check if URL changed significantly (redirect to confirmation)
|
|
533
|
-
if (result.meta.url !== prevUrl && /confirm|success|thank/i.test(result.meta.url)) {
|
|
534
|
-
this._log('success', '🎉 Redirected to confirmation page');
|
|
535
|
-
completed = true;
|
|
536
|
-
break;
|
|
537
|
-
}
|
|
538
|
-
} else {
|
|
539
|
-
this._log('dry-run', `Would click [${submitBtn.ref}] "${submitBtn.text}"`);
|
|
540
|
-
break;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (step >= this.maxSteps) {
|
|
545
|
-
this._log('warn', `Reached max steps (${this.maxSteps}). May need manual review.`);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
return {
|
|
549
|
-
url,
|
|
550
|
-
platform,
|
|
551
|
-
completed,
|
|
552
|
-
steps: step,
|
|
553
|
-
log: this.log,
|
|
554
|
-
finalView: result.view,
|
|
555
|
-
};
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
async close() {
|
|
559
|
-
if (this.browser) await this.browser.close();
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* Detect if the current page embeds an ATS application form in an iframe
|
|
564
|
-
*/
|
|
565
|
-
async _detectATSIframe() {
|
|
566
|
-
if (!this.browser || !this.browser.page) return null;
|
|
567
|
-
|
|
568
|
-
const iframeUrl = await this.browser.page.evaluate(() => {
|
|
569
|
-
const iframes = document.querySelectorAll('iframe');
|
|
570
|
-
for (const iframe of iframes) {
|
|
571
|
-
const src = iframe.src || '';
|
|
572
|
-
// Greenhouse embedded forms
|
|
573
|
-
if (src.includes('greenhouse.io/embed/job_app')) return src;
|
|
574
|
-
if (src.includes('job-boards.greenhouse.io')) return src;
|
|
575
|
-
// Lever embedded forms
|
|
576
|
-
if (src.includes('jobs.lever.co') && src.includes('apply')) return src;
|
|
577
|
-
// Ashby embedded forms
|
|
578
|
-
if (src.includes('jobs.ashbyhq.com') && src.includes('application')) return src;
|
|
579
|
-
// Workday
|
|
580
|
-
if (src.includes('myworkday') || src.includes('workday.com')) return src;
|
|
581
|
-
}
|
|
582
|
-
return null;
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
return iframeUrl;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
_log(level, message) {
|
|
589
|
-
const entry = { time: new Date().toISOString(), level, message };
|
|
590
|
-
this.log.push(entry);
|
|
591
|
-
const prefix = {
|
|
592
|
-
info: ' ℹ',
|
|
593
|
-
fill: ' ✏️',
|
|
594
|
-
upload: ' 📎',
|
|
595
|
-
click: ' 👆',
|
|
596
|
-
skip: ' ⏭️',
|
|
597
|
-
warn: ' ⚠️',
|
|
598
|
-
error: ' ❌',
|
|
599
|
-
success: ' ✅',
|
|
600
|
-
'dry-run': ' 🔍',
|
|
601
|
-
}[level] || ' ';
|
|
602
|
-
console.error(`${prefix} ${message}`);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// ─── Batch Mode ─────────────────────────────────────────────────────────────
|
|
607
|
-
|
|
608
|
-
async function batchApply(jobsFile, options) {
|
|
609
|
-
const jobs = JSON.parse(fs.readFileSync(jobsFile, 'utf8'));
|
|
610
|
-
const results = [];
|
|
611
|
-
|
|
612
|
-
console.error(`\nBatch applying to ${jobs.length} jobs...\n`);
|
|
613
|
-
|
|
614
|
-
for (let i = 0; i < jobs.length; i++) {
|
|
615
|
-
const job = jobs[i];
|
|
616
|
-
const url = job.apply_url || job.url || job.applyUrl;
|
|
617
|
-
if (!url) {
|
|
618
|
-
console.error(` ⏭️ [${i + 1}/${jobs.length}] Skipping "${job.title}" — no URL`);
|
|
619
|
-
continue;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
console.error(`\n━━━ [${i + 1}/${jobs.length}] ${job.title || 'Unknown'} at ${job.company || 'Unknown'} ━━━`);
|
|
623
|
-
|
|
624
|
-
const applicator = new JobApplicator({
|
|
625
|
-
...options,
|
|
626
|
-
coverLetterPath: job.coverLetterPath || options.coverLetterPath,
|
|
627
|
-
resumePath: job.resumePath || options.resumePath,
|
|
628
|
-
company: job.company || options.company,
|
|
629
|
-
jobDescription: job.description || options.jobDescription,
|
|
630
|
-
});
|
|
631
|
-
|
|
632
|
-
try {
|
|
633
|
-
await applicator.init();
|
|
634
|
-
const result = await applicator.apply(url);
|
|
635
|
-
results.push({ job, ...result });
|
|
636
|
-
} catch (err) {
|
|
637
|
-
console.error(` ❌ Failed: ${err.message}`);
|
|
638
|
-
results.push({ job, url, completed: false, error: err.message });
|
|
639
|
-
} finally {
|
|
640
|
-
await applicator.close();
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// Brief pause between applications
|
|
644
|
-
if (i < jobs.length - 1) {
|
|
645
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// Summary
|
|
650
|
-
const succeeded = results.filter(r => r.completed).length;
|
|
651
|
-
const failed = results.filter(r => !r.completed).length;
|
|
652
|
-
console.error(`\n━━━ Summary: ${succeeded} submitted, ${failed} need review ━━━\n`);
|
|
653
|
-
|
|
654
|
-
// Output results as JSON to stdout
|
|
655
|
-
console.log(JSON.stringify(results, null, 2));
|
|
656
|
-
return results;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
660
|
-
|
|
661
|
-
async function main() {
|
|
662
|
-
const args = process.argv.slice(2);
|
|
663
|
-
|
|
664
|
-
const options = {
|
|
665
|
-
dryRun: false,
|
|
666
|
-
verbose: false,
|
|
667
|
-
resumePath: PROFILE.resumePath,
|
|
668
|
-
coverLetterPath: null,
|
|
669
|
-
batchFile: null,
|
|
670
|
-
url: null,
|
|
671
|
-
useLLM: true,
|
|
672
|
-
company: '',
|
|
673
|
-
jobDescription: '',
|
|
674
|
-
noLLM: false,
|
|
675
|
-
};
|
|
676
|
-
|
|
677
|
-
for (let i = 0; i < args.length; i++) {
|
|
678
|
-
const arg = args[i];
|
|
679
|
-
if (arg === '--dry-run' || arg === '-n') options.dryRun = true;
|
|
680
|
-
else if (arg === '--verbose' || arg === '-v') options.verbose = true;
|
|
681
|
-
else if (arg === '--resume') options.resumePath = args[++i];
|
|
682
|
-
else if (arg === '--cover-letter') options.coverLetterPath = args[++i];
|
|
683
|
-
else if (arg === '--batch') options.batchFile = args[++i];
|
|
684
|
-
else if (arg === '--company') options.company = args[++i];
|
|
685
|
-
else if (arg === '--no-llm') { options.useLLM = false; options.noLLM = true; }
|
|
686
|
-
else if (arg === '-h' || arg === '--help') { printHelp(); process.exit(0); }
|
|
687
|
-
else if (!arg.startsWith('-')) options.url = arg;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
if (options.batchFile) {
|
|
691
|
-
await batchApply(options.batchFile, options);
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
if (!options.url) {
|
|
696
|
-
printHelp();
|
|
697
|
-
process.exit(1);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
const applicator = new JobApplicator(options);
|
|
701
|
-
try {
|
|
702
|
-
await applicator.init();
|
|
703
|
-
const result = await applicator.apply(options.url);
|
|
704
|
-
console.log(JSON.stringify(result, null, 2));
|
|
705
|
-
} finally {
|
|
706
|
-
await applicator.close();
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
function printHelp() {
|
|
711
|
-
console.log(`
|
|
712
|
-
TextWeb Job Applicator — Fill out job applications without screenshots
|
|
713
|
-
|
|
714
|
-
Usage:
|
|
715
|
-
node apply.js <url> Apply to a single job
|
|
716
|
-
node apply.js --batch <jobs.json> Apply to multiple jobs
|
|
717
|
-
|
|
718
|
-
Options:
|
|
719
|
-
--dry-run, -n Show what would be filled without submitting
|
|
720
|
-
--verbose, -v Print page views at each step
|
|
721
|
-
--resume <path> Path to resume PDF (default: ~/.jobsearch/christopher-robison-resume.pdf)
|
|
722
|
-
--cover-letter <path> Path to cover letter PDF
|
|
723
|
-
-h, --help Show this help
|
|
724
|
-
|
|
725
|
-
Batch JSON format:
|
|
726
|
-
[
|
|
727
|
-
{ "title": "VP Eng", "company": "Acme", "apply_url": "https://..." },
|
|
728
|
-
{ "title": "CTO", "company": "Startup", "url": "https://...", "resumePath": "/custom/resume.pdf" }
|
|
729
|
-
]
|
|
730
|
-
|
|
731
|
-
Examples:
|
|
732
|
-
node apply.js https://boards.greenhouse.io/company/jobs/123
|
|
733
|
-
node apply.js --dry-run https://jobs.lever.co/company/abc-123
|
|
734
|
-
node apply.js --batch ~/.jobsearch/to_apply.json --verbose
|
|
735
|
-
`);
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
module.exports = { JobApplicator, analyzeForm, detectPlatform, PROFILE, FIELD_PATTERNS };
|
|
739
|
-
|
|
740
|
-
if (require.main === module) {
|
|
741
|
-
main().catch(err => {
|
|
742
|
-
console.error(`Fatal: ${err.message}`);
|
|
743
|
-
process.exit(1);
|
|
744
|
-
});
|
|
745
|
-
}
|