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