textweb 0.1.1 → 0.1.2

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/.env.example ADDED
@@ -0,0 +1,25 @@
1
+ # TextWeb LLM Configuration
2
+ # Copy this to .env and fill in your values
3
+
4
+ # Base URL for any OpenAI-compatible API
5
+ # Default: http://localhost:1234/v1 (LM Studio)
6
+ # Examples:
7
+ # https://api.openai.com/v1
8
+ # https://api.anthropic.com/v1
9
+ # http://localhost:11434/v1 (Ollama)
10
+ TEXTWEB_LLM_URL=http://localhost:1234/v1
11
+
12
+ # API key (optional for local LLMs like LM Studio/Ollama)
13
+ TEXTWEB_LLM_API_KEY=
14
+
15
+ # Model name
16
+ TEXTWEB_LLM_MODEL=google/gemma-3-4b
17
+
18
+ # Max tokens for responses (default: 200)
19
+ TEXTWEB_LLM_MAX_TOKENS=200
20
+
21
+ # Temperature (default: 0.7)
22
+ TEXTWEB_LLM_TEMPERATURE=0.7
23
+
24
+ # Request timeout in milliseconds (default: 60000)
25
+ TEXTWEB_LLM_TIMEOUT=60000
package/README.md CHANGED
@@ -48,6 +48,26 @@ textweb --json https://example.com
48
48
 
49
49
  ~500 bytes. An LLM can read this, understand the layout, and say "click ref 9" to open the first link. No vision model needed.
50
50
 
51
+ ## LLM Configuration
52
+
53
+ TextWeb's job application pipeline uses a local or remote LLM for freeform questions. Configure via `.env` file or environment variables:
54
+
55
+ ```bash
56
+ cp .env.example .env
57
+ # Edit .env with your settings
58
+ ```
59
+
60
+ | Variable | Default | Description |
61
+ |----------|---------|-------------|
62
+ | `TEXTWEB_LLM_URL` | `http://localhost:1234/v1` | OpenAI-compatible API endpoint |
63
+ | `TEXTWEB_LLM_API_KEY` | *(empty)* | API key (optional for local LLMs) |
64
+ | `TEXTWEB_LLM_MODEL` | `google/gemma-3-4b` | Model name |
65
+ | `TEXTWEB_LLM_MAX_TOKENS` | `200` | Max response tokens |
66
+ | `TEXTWEB_LLM_TEMPERATURE` | `0.7` | Sampling temperature |
67
+ | `TEXTWEB_LLM_TIMEOUT` | `60000` | Request timeout (ms) |
68
+
69
+ Works with LM Studio, Ollama, OpenAI, or any OpenAI-compatible endpoint. No API key needed for local models.
70
+
51
71
  ## Integration Options
52
72
 
53
73
  TextWeb works with any AI agent framework. Pick your integration:
@@ -0,0 +1,153 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>TextWeb Job Pipeline</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro", system-ui, sans-serif;
11
+ background: linear-gradient(135deg, #0a0a1a 0%, #1a1a3e 50%, #0a1628 100%);
12
+ color: #e8e8f0; min-height: 100vh; padding: 16px; overflow-y: auto;
13
+ }
14
+ .hdr { text-align: center; margin-bottom: 14px; }
15
+ .hdr h1 {
16
+ font-size: 20px; font-weight: 700;
17
+ background: linear-gradient(90deg, #7b68ee, #00d4ff);
18
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
19
+ }
20
+ .sub { font-size: 11px; color: #888; margin-top: 2px; }
21
+ .stats { display: flex; gap: 8px; justify-content: center; margin-bottom: 12px; flex-wrap: wrap; }
22
+ .st {
23
+ background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
24
+ border-radius: 10px; padding: 8px 12px; text-align: center; flex: 1; max-width: 90px; min-width: 70px;
25
+ }
26
+ .st .n { font-size: 24px; font-weight: 800; line-height: 1; }
27
+ .st .l { font-size: 8px; color: #999; margin-top: 2px; text-transform: uppercase; letter-spacing: 0.5px; }
28
+ .st.ok .n { color: #4ade80; }
29
+ .st.mb .n { color: #fbbf24; }
30
+ .st.fa .n { color: #f87171; }
31
+ .st.tt .n { color: #7b68ee; }
32
+ .st.ac .n { color: #38bdf8; }
33
+ .btns { display: flex; gap: 6px; justify-content: center; margin-bottom: 12px; flex-wrap: wrap; }
34
+ .btn {
35
+ background: rgba(123,104,238,0.2); border: 1px solid rgba(123,104,238,0.4);
36
+ border-radius: 8px; padding: 7px 14px; color: #c8c8ff; font-size: 11px;
37
+ cursor: pointer; font-weight: 600; transition: all 0.15s; white-space: nowrap;
38
+ }
39
+ .btn:hover { background: rgba(123,104,238,0.35); transform: scale(1.02); }
40
+ .btn:active { transform: scale(0.98); }
41
+ .btn.green { background: rgba(74,222,128,0.15); border-color: rgba(74,222,128,0.3); color: #86efac; }
42
+ .btn.amber { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.3); color: #fde68a; }
43
+ .jl {
44
+ background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
45
+ border-radius: 10px; overflow: hidden; margin-bottom: 10px;
46
+ }
47
+ .sl {
48
+ font-size: 9px; text-transform: uppercase; letter-spacing: 1px; color: #666;
49
+ padding: 6px 12px 3px; background: rgba(255,255,255,0.02);
50
+ }
51
+ .sl.act { color: #38bdf8; background: rgba(56,189,248,0.05); }
52
+ .sl.q { color: #a78bfa; }
53
+ .j {
54
+ display: flex; align-items: center; padding: 7px 12px;
55
+ border-bottom: 1px solid rgba(255,255,255,0.05); gap: 8px; font-size: 12px;
56
+ transition: background 0.15s;
57
+ }
58
+ .j:last-child { border-bottom: none; }
59
+ .j:hover { background: rgba(255,255,255,0.03); }
60
+ .j.active { background: rgba(56,189,248,0.08); animation: activePulse 2s infinite; }
61
+ .j .i { font-size: 14px; flex-shrink: 0; }
62
+ .j .c { font-weight: 600; color: #c8c8ff; min-width: 75px; font-size: 11px; }
63
+ .j .t { color: #aaa; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
64
+ .j .f { font-size: 10px; color: #666; flex-shrink: 0; font-variant-numeric: tabular-nums; }
65
+ .j .tm { font-size: 9px; color: #555; flex-shrink: 0; }
66
+ @keyframes activePulse { 0%,100%{background:rgba(56,189,248,0.08)} 50%{background:rgba(56,189,248,0.15)} }
67
+ .live { text-align: center; margin-top: 10px; font-size: 10px; color: #666; min-height: 20px; }
68
+ .dot {
69
+ display: inline-block; width: 6px; height: 6px; border-radius: 50%;
70
+ background: #4ade80; margin-right: 4px; animation: jpulse 2s infinite;
71
+ }
72
+ .dot.active { background: #38bdf8; animation: jpulse 0.8s infinite; }
73
+ @keyframes jpulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
74
+ .ft { text-align: center; margin-top: 6px; font-size: 9px; color: #444; }
75
+ .empty { text-align: center; padding: 30px 20px; color: #666; }
76
+ .empty .big { font-size: 40px; margin-bottom: 10px; }
77
+ </style>
78
+ </head>
79
+ <body>
80
+ <div class="hdr">
81
+ <h1>🦊 TextWeb Job Pipeline</h1>
82
+ <div class="sub">Zero screenshots · Zero API cost · Local LLM</div>
83
+ </div>
84
+ <div class="stats" id="stats"></div>
85
+ <div class="btns">
86
+ <div class="btn green" onclick="agentMsg('Find and apply to 5 more engineering leadership jobs via the TextWeb pipeline')">🔍 Find + Apply</div>
87
+ <div class="btn" onclick="agentMsg('Discover new jobs on Greenhouse boards and show them on the canvas dashboard')">📋 Discover</div>
88
+ <div class="btn amber" onclick="agentMsg('Check my email for any job application confirmations or responses')">📬 Inbox</div>
89
+ </div>
90
+ <div class="jl" id="jobList"></div>
91
+ <div class="live" id="liveStatus"></div>
92
+ <div class="ft">TextWeb v0.1.1 · Gemma 3 4B · Playwright headless</div>
93
+ <script>
94
+ function agentMsg(msg) {
95
+ window.location.href = 'openclaw://agent?message=' + encodeURIComponent(msg);
96
+ }
97
+ function esc(s) { return (s||'').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
98
+ function relTime(iso) {
99
+ if (!iso) return '';
100
+ const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
101
+ if (mins < 1) return 'now';
102
+ if (mins < 60) return mins + 'm';
103
+ const hrs = Math.floor(mins / 60);
104
+ if (hrs < 24) return hrs + 'h';
105
+ return new Date(iso).toLocaleDateString('en-US',{month:'short',day:'numeric'});
106
+ }
107
+ function render(data) {
108
+ const jobs = data.jobs || [];
109
+ const icons = {submitted:'✅',probable:'🟡',active:'⏳',failed:'❌',queued:'📋',filling:'✏️',uploading:'📎',llm:'🤖',submitting:'🔘'};
110
+ const activeStatuses = ['active','filling','uploading','llm','submitting'];
111
+ const confirmed = jobs.filter(j=>j.status==='submitted').length;
112
+ const probable = jobs.filter(j=>j.status==='probable').length;
113
+ const active = jobs.filter(j=>activeStatuses.includes(j.status)).length;
114
+ const failed = jobs.filter(j=>j.status==='failed').length;
115
+ const queued = jobs.filter(j=>j.status==='queued').length;
116
+ let sh = `<div class="st ok"><div class="n">${confirmed}</div><div class="l">Confirmed</div></div>
117
+ <div class="st mb"><div class="n">${probable}</div><div class="l">Probable</div></div>`;
118
+ if(active) sh += `<div class="st ac"><div class="n">${active}</div><div class="l">Active</div></div>`;
119
+ if(queued) sh += `<div class="st" style="border-color:rgba(167,139,250,0.3)"><div class="n" style="color:#a78bfa">${queued}</div><div class="l">Queued</div></div>`;
120
+ if(failed) sh += `<div class="st fa"><div class="n">${failed}</div><div class="l">Failed</div></div>`;
121
+ sh += `<div class="st tt"><div class="n">${jobs.length}</div><div class="l">Total</div></div>`;
122
+ document.getElementById('stats').innerHTML = sh;
123
+ const groups = [
124
+ {label:'🔴 ACTIVE',cls:'act',statuses:activeStatuses},
125
+ {label:'QUEUED',cls:'q',statuses:['queued']},
126
+ {label:'CONFIRMED',cls:'',statuses:['submitted']},
127
+ {label:'PROBABLE',cls:'',statuses:['probable']},
128
+ {label:'FAILED',cls:'',statuses:['failed']},
129
+ ];
130
+ let lh = '';
131
+ for (const g of groups) {
132
+ const items = jobs.filter(j=>g.statuses.includes(j.status));
133
+ if (!items.length) continue;
134
+ lh += `<div class="sl ${g.cls}">${g.label}</div>`;
135
+ for (const j of items) {
136
+ const f = j.autoFields!=null ? `${j.autoFields}+${j.llmFields||0}` : '';
137
+ const t = relTime(j.submittedAt||j.startedAt||j.queuedAt);
138
+ lh += `<div class="j${activeStatuses.includes(j.status)?' active':''}"><span class="i">${icons[j.status]||'📋'}</span><span class="c">${esc(j.company)}</span><span class="t">${esc(j.title)}</span><span class="f">${f}</span><span class="tm">${t}</span></div>`;
139
+ }
140
+ }
141
+ if(!jobs.length) lh = '<div class="empty"><div class="big">🦊</div>No jobs yet. Click Find + Apply!</div>';
142
+ document.getElementById('jobList').innerHTML = lh;
143
+ const live = document.getElementById('liveStatus');
144
+ if(data.liveStatus) live.innerHTML = `<span class="dot active"></span>${esc(data.liveStatus)}`;
145
+ else if(active) live.innerHTML = '<span class="dot active"></span>Applying...';
146
+ else live.innerHTML = `<span class="dot"></span>Idle · ${new Date().toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'})}`;
147
+ }
148
+ // Use embedded state (injected by dashboard.js) or empty
149
+ const state = window.__PIPELINE_STATE__ || {jobs:[]};
150
+ render(state);
151
+ </script>
152
+ </body>
153
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "textweb",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A text-grid web renderer for AI agents — see the web without screenshots",
5
5
  "main": "src/browser.js",
6
6
  "bin": {
package/src/apply.js CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  const { AgentBrowser } = require('./browser');
15
+ const { generateAnswers, checkLLM } = require('./llm');
15
16
  const path = require('path');
16
17
  const fs = require('fs');
17
18
 
@@ -27,6 +28,8 @@ const PROFILE = {
27
28
  linkedin: 'https://linkedin.com/in/crobison',
28
29
  github: 'https://github.com/chrisrobison',
29
30
  website: 'https://cdr2.com',
31
+ twitter: 'https://twitter.com/thechrisrobison',
32
+ github: 'https://github.com/chrisrobison',
30
33
  currentTitle: 'CTO',
31
34
  currentCompany: 'D. Harris Tours',
32
35
  yearsExperience: '25',
@@ -35,6 +38,10 @@ const PROFILE = {
35
38
  requireSponsorship: 'No',
36
39
  salaryExpectation: '200000',
37
40
  noticePeriod: 'Immediately',
41
+ country: 'United States',
42
+ countryCode: 'US',
43
+ school: 'City College of San Francisco',
44
+ degree: "Bachelor's",
38
45
 
39
46
  // Default resume/cover letter
40
47
  resumePath: path.join(process.env.HOME, '.jobsearch/christopher-robison-resume.pdf'),
@@ -54,26 +61,63 @@ const FIELD_PATTERNS = [
54
61
  { match: /e-?mail/i, value: () => PROFILE.email },
55
62
  { match: /phone|mobile|cell|telephone/i, value: () => PROFILE.phone },
56
63
 
57
- // Location (exclude "email address" by requiring no "email" nearby)
58
- { match: /^(?!.*e-?mail).*(city|location|address|zip|postal)/i, value: () => PROFILE.location },
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 },
59
66
 
60
67
  // Links
61
68
  { match: /linkedin/i, value: () => PROFILE.linkedin },
69
+ { match: /twitter|x\.com|@.*handle/i, value: () => PROFILE.twitter },
70
+ { match: /github/i, value: () => PROFILE.github },
62
71
  { match: /github/i, value: () => PROFILE.github },
63
72
  { match: /website|portfolio|personal.*url|blog/i, value: () => PROFILE.website },
64
73
 
65
74
  // Work info
66
75
  { match: /current.*title|job.*title/i, value: () => PROFILE.currentTitle },
76
+ { match: /bound.*agreement|non.?compete|restrict|post.?employment|employment.*agreement/i, value: () => 'No' },
67
77
  { match: /current.*company|employer|organization/i, value: () => PROFILE.currentCompany },
68
- { match: /years.*experience|experience.*years/i, value: () => PROFILE.yearsExperience },
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' },
69
80
 
70
81
  // Logistics
71
82
  { 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 },
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' },
74
87
  { match: /salary|compensation|pay.*expect/i, value: () => PROFILE.salaryExpectation },
75
88
  { match: /notice.*period|start.*date|availab.*start|when.*start/i, value: () => PROFILE.noticePeriod },
76
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,
77
121
  ];
78
122
 
79
123
  // ─── Platform Detection ─────────────────────────────────────────────────────
@@ -108,14 +152,28 @@ function analyzeForm(result) {
108
152
 
109
153
  for (const [ref, el] of Object.entries(elements)) {
110
154
  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);
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
+
113
170
  const profileValue = matchFieldToProfile(label, el);
114
171
 
115
172
  if (profileValue) {
116
173
  actions.push({
117
174
  action: 'type',
118
175
  ref: parseInt(ref),
176
+ selector: el.selector,
119
177
  value: profileValue,
120
178
  field: label,
121
179
  confidence: 'high',
@@ -124,6 +182,7 @@ function analyzeForm(result) {
124
182
  actions.push({
125
183
  action: 'type',
126
184
  ref: parseInt(ref),
185
+ selector: el.selector,
127
186
  value: null,
128
187
  field: label,
129
188
  confidence: 'unknown',
@@ -132,13 +191,14 @@ function analyzeForm(result) {
132
191
  }
133
192
 
134
193
  if (el.semantic === 'file') {
135
- const label = findLabel(el, lines, result);
194
+ const label = el.label || findLabel(el, lines, result);
136
195
  const isResume = /resume|cv/i.test(label);
137
196
  const isCoverLetter = /cover.*letter/i.test(label);
138
197
 
139
198
  actions.push({
140
199
  action: 'upload',
141
200
  ref: parseInt(ref),
201
+ selector: el.selector,
142
202
  filePath: isCoverLetter ? PROFILE.coverLetterPath : PROFILE.resumePath,
143
203
  field: label,
144
204
  fileType: isCoverLetter ? 'cover_letter' : 'resume',
@@ -146,7 +206,7 @@ function analyzeForm(result) {
146
206
  }
147
207
 
148
208
  if (el.semantic === 'select') {
149
- const label = findLabel(el, lines, result);
209
+ const label = el.label || findLabel(el, lines, result);
150
210
  actions.push({
151
211
  action: 'select',
152
212
  ref: parseInt(ref),
@@ -156,7 +216,7 @@ function analyzeForm(result) {
156
216
  }
157
217
 
158
218
  if (el.semantic === 'checkbox' || el.semantic === 'radio') {
159
- const label = findLabel(el, lines, result);
219
+ const label = el.label || findLabel(el, lines, result);
160
220
  // Auto-check common consent/agreement checkboxes
161
221
  if (/agree|consent|acknowledge|confirm|certif/i.test(label)) {
162
222
  actions.push({
@@ -219,6 +279,14 @@ function findLabel(el, lines, result) {
219
279
  return el.text || 'unknown field';
220
280
  }
221
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
+
222
290
  /**
223
291
  * Match a field label to profile data
224
292
  */
@@ -244,6 +312,10 @@ class JobApplicator {
244
312
  this.resumePath = options.resumePath || PROFILE.resumePath;
245
313
  this.coverLetterPath = options.coverLetterPath || PROFILE.coverLetterPath;
246
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 || '';
247
319
  this.log = [];
248
320
  }
249
321
 
@@ -258,10 +330,23 @@ class JobApplicator {
258
330
 
259
331
  // Navigate to the application page
260
332
  let result = await this.browser.navigate(url);
261
- const platform = detectPlatform(url, result.view);
333
+ let platform = detectPlatform(url, result.view);
262
334
  this._log('info', `Detected platform: ${platform}`);
263
335
  this._log('info', `Page: ${result.meta.title}`);
264
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
+
265
350
  if (this.verbose) {
266
351
  console.log('\n' + result.view + '\n');
267
352
  }
@@ -294,7 +379,30 @@ class JobApplicator {
294
379
  this._log('fill', `[${action.ref}] ${action.field} → "${action.value}"`);
295
380
  if (!this.dryRun) {
296
381
  try {
297
- result = await this.browser.type(action.ref, action.value);
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
+ }
298
406
  } catch (err) {
299
407
  this._log('error', `Failed to fill [${action.ref}] ${action.field}: ${err.message}`);
300
408
  }
@@ -333,9 +441,47 @@ class JobApplicator {
333
441
  }
334
442
  }
335
443
 
336
- // Log unknown fields
337
- for (const action of unknown) {
338
- this._log('skip', `[${action.ref}] "${action.field}" no auto-fill match`);
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)`);
339
485
  }
340
486
 
341
487
  // Take a fresh snapshot after fills
@@ -413,6 +559,32 @@ class JobApplicator {
413
559
  if (this.browser) await this.browser.close();
414
560
  }
415
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
+
416
588
  _log(level, message) {
417
589
  const entry = { time: new Date().toISOString(), level, message };
418
590
  this.log.push(entry);
@@ -453,6 +625,8 @@ async function batchApply(jobsFile, options) {
453
625
  ...options,
454
626
  coverLetterPath: job.coverLetterPath || options.coverLetterPath,
455
627
  resumePath: job.resumePath || options.resumePath,
628
+ company: job.company || options.company,
629
+ jobDescription: job.description || options.jobDescription,
456
630
  });
457
631
 
458
632
  try {
@@ -494,6 +668,10 @@ async function main() {
494
668
  coverLetterPath: null,
495
669
  batchFile: null,
496
670
  url: null,
671
+ useLLM: true,
672
+ company: '',
673
+ jobDescription: '',
674
+ noLLM: false,
497
675
  };
498
676
 
499
677
  for (let i = 0; i < args.length; i++) {
@@ -503,6 +681,8 @@ async function main() {
503
681
  else if (arg === '--resume') options.resumePath = args[++i];
504
682
  else if (arg === '--cover-letter') options.coverLetterPath = args[++i];
505
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; }
506
686
  else if (arg === '-h' || arg === '--help') { printHelp(); process.exit(0); }
507
687
  else if (!arg.startsWith('-')) options.url = arg;
508
688
  }
package/src/browser.js CHANGED
@@ -71,6 +71,36 @@ class AgentBrowser {
71
71
  return await this.snapshot();
72
72
  }
73
73
 
74
+ /**
75
+ * Fill a field by CSS selector without re-rendering (faster for batch fills)
76
+ */
77
+ async fillBySelector(selector, text) {
78
+ try {
79
+ await this.page.click(selector, { timeout: 5000 });
80
+ await this.page.fill(selector, text);
81
+ } catch (e) {
82
+ // Fallback: try typing character by character (for contenteditable, etc.)
83
+ try {
84
+ await this.page.click(selector, { timeout: 5000 });
85
+ await this.page.evaluate((sel) => {
86
+ const el = document.querySelector(sel);
87
+ if (el) { el.value = ''; el.textContent = ''; }
88
+ }, selector);
89
+ await this.page.type(selector, text, { delay: 10 });
90
+ } catch (e2) {
91
+ throw new Error(`Cannot fill ${selector}: ${e.message}`);
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Upload a file by CSS selector
98
+ */
99
+ async uploadBySelector(selector, filePaths) {
100
+ const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
101
+ await this.page.setInputFiles(selector, paths);
102
+ }
103
+
74
104
  async press(key) {
75
105
  await this.page.keyboard.press(key);
76
106
  await this.page.waitForLoadState('networkidle').catch(() => {});