textweb 0.1.1 โ†’ 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.
@@ -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.2.0",
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/browser.js CHANGED
@@ -60,7 +60,7 @@ class AgentBrowser {
60
60
  async click(ref) {
61
61
  const el = this._getElement(ref);
62
62
  await this.page.click(el.selector);
63
- await this.page.waitForLoadState('networkidle').catch(() => {});
63
+ await this._settle();
64
64
  return await this.snapshot();
65
65
  }
66
66
 
@@ -71,9 +71,39 @@ 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
- await this.page.waitForLoadState('networkidle').catch(() => {});
106
+ await this._settle();
77
107
  return await this.snapshot();
78
108
  }
79
109
 
@@ -129,6 +159,56 @@ class AgentBrowser {
129
159
  }
130
160
  }
131
161
 
162
+ /**
163
+ * Get the current page URL
164
+ */
165
+ getCurrentUrl() {
166
+ return this.page ? this.page.url() : null;
167
+ }
168
+
169
+ /**
170
+ * Find elements matching a CSS selector
171
+ * Returns array of {tag, text, selector, visible} objects
172
+ */
173
+ async query(selector) {
174
+ if (!this.page) throw new Error('No page open. Call navigate() first.');
175
+ return await this.page.evaluate((sel) => {
176
+ const els = document.querySelectorAll(sel);
177
+ return Array.from(els).map((el, i) => ({
178
+ tag: el.tagName.toLowerCase(),
179
+ text: (el.textContent || '').trim().substring(0, 200),
180
+ selector: `${sel}:nth-child(${i + 1})`,
181
+ visible: el.offsetParent !== null,
182
+ href: el.href || null,
183
+ value: el.value || null,
184
+ }));
185
+ }, selector);
186
+ }
187
+
188
+ /**
189
+ * Take a screenshot (for debugging)
190
+ * @param {object} options - Playwright screenshot options (path, fullPage, type, etc.)
191
+ */
192
+ async screenshot(options = {}) {
193
+ if (!this.page) throw new Error('No page open. Call navigate() first.');
194
+ return await this.page.screenshot({
195
+ fullPage: true,
196
+ type: 'png',
197
+ ...options,
198
+ });
199
+ }
200
+
201
+ /**
202
+ * Wait for page to settle after an interaction.
203
+ * Races networkidle against a short timeout to avoid hanging on SPAs.
204
+ */
205
+ async _settle() {
206
+ await Promise.race([
207
+ this.page.waitForLoadState('networkidle').catch(() => {}),
208
+ new Promise(r => setTimeout(r, 3000)),
209
+ ]);
210
+ }
211
+
132
212
  _getElement(ref) {
133
213
  if (!this.lastResult) throw new Error('No snapshot. Navigate first.');
134
214
  const el = this.lastResult.elements[ref];
package/src/cli.js CHANGED
@@ -17,7 +17,6 @@ function parseArgs() {
17
17
  json: false,
18
18
  serve: false,
19
19
  cols: 100,
20
- rows: 30,
21
20
  port: 3000,
22
21
  help: false
23
22
  };
@@ -48,7 +47,9 @@ function parseArgs() {
48
47
 
49
48
  case '--rows':
50
49
  case '-r':
51
- options.rows = parseInt(args[++i]) || 30;
50
+ // Deprecated: height is dynamic (grows to fit content). Ignored.
51
+ console.error('Warning: --rows is deprecated. Height is dynamic (grows to fit content).');
52
+ args[++i]; // consume the value
52
53
  break;
53
54
 
54
55
  case '--port':
@@ -85,7 +86,7 @@ USAGE:
85
86
 
86
87
  OPTIONS:
87
88
  --cols, -c <number> Grid width in characters (default: 100)
88
- --rows, -r <number> Grid height in characters (default: 30)
89
+ --rows, -r <number> (deprecated, height is dynamic)
89
90
  --port, -p <number> Server port (default: 3000)
90
91
  --interactive, -i Interactive REPL mode
91
92
  --json, -j JSON output format
@@ -117,7 +118,7 @@ INTERACTIVE COMMANDS:
117
118
  async function render(url, options) {
118
119
  const browser = new AgentBrowser({
119
120
  cols: options.cols,
120
- rows: options.rows,
121
+
121
122
  headless: true
122
123
  });
123
124
 
@@ -156,7 +157,7 @@ async function render(url, options) {
156
157
  async function interactive(url, options) {
157
158
  const browser = new AgentBrowser({
158
159
  cols: options.cols,
159
- rows: options.rows,
160
+
160
161
  headless: true
161
162
  });
162
163
 
@@ -367,7 +368,7 @@ async function serve(options) {
367
368
 
368
369
  const server = createServer({
369
370
  cols: options.cols,
370
- rows: options.rows
371
+
371
372
  });
372
373
 
373
374
  server.listen(options.port, () => {
package/src/renderer.js CHANGED
@@ -7,8 +7,8 @@
7
7
  * Key design decisions:
8
8
  * - Overflow > truncation (never lose information)
9
9
  * - Measure actual font metrics from the page
10
- * - Table-aware layout
11
- * - Z-index compositing (back to front)
10
+ * - Row-grouping layout (elements grouped by Y position)
11
+ * - Dynamic height (grows to fit all content)
12
12
  */
13
13
 
14
14
  /**
@@ -187,6 +187,29 @@ async function extractElements(page) {
187
187
  text = '---';
188
188
  }
189
189
 
190
+ // Resolve label for form elements
191
+ let label = '';
192
+ if (!isText && (tag === 'input' || tag === 'select' || tag === 'textarea')) {
193
+ // Strategy 1: <label for="id">
194
+ if (el.id) {
195
+ const labelEl = document.querySelector('label[for="' + CSS.escape(el.id) + '"]');
196
+ if (labelEl) label = labelEl.textContent.trim().replace(/\s*\*\s*$/, '').trim();
197
+ }
198
+ // Strategy 2: aria-label
199
+ if (!label && el.getAttribute('aria-label')) {
200
+ label = el.getAttribute('aria-label');
201
+ }
202
+ // Strategy 3: wrapping <label>
203
+ if (!label) {
204
+ const parentLabel = el.closest('label');
205
+ if (parentLabel) label = parentLabel.textContent.trim().replace(/\s*\*\s*$/, '').trim();
206
+ }
207
+ // Strategy 4: name attribute as fallback
208
+ if (!label && el.name) {
209
+ label = el.name.replace(/[_\-\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
210
+ }
211
+ }
212
+
190
213
  // Determine semantic type
191
214
  let semantic = 'text';
192
215
  const headingMatch = tag.match(/^h(\d)$/);
@@ -231,6 +254,7 @@ async function extractElements(page) {
231
254
 
232
255
  results.push({
233
256
  text,
257
+ label: label || '',
234
258
  tag,
235
259
  semantic,
236
260
  headingLevel: headingMatch ? parseInt(headingMatch[1]) : 0,
@@ -253,44 +277,6 @@ async function extractElements(page) {
253
277
  });
254
278
  }
255
279
 
256
- /**
257
- * Place text onto the grid, allowing overflow (never truncate).
258
- * Text wraps to the next line at grid edge, continuing at the same start column.
259
- */
260
- function placeText(grid, zGrid, z, row, col, text, cols, rows) {
261
- let r = row;
262
- let c = col;
263
-
264
- for (let i = 0; i < text.length; i++) {
265
- // Grow grid vertically if needed (overflow โ€” don't lose data)
266
- while (r >= grid.length) {
267
- grid.push(Array(cols).fill(' '));
268
- zGrid.push(Array(cols).fill(-1));
269
- }
270
-
271
- if (c >= cols) {
272
- // Wrap to next line at original column position
273
- r++;
274
- c = col;
275
- while (r >= grid.length) {
276
- grid.push(Array(cols).fill(' '));
277
- zGrid.push(Array(cols).fill(-1));
278
- }
279
- }
280
-
281
- const ch = text[i];
282
- if (ch === '\n') { r++; c = col; continue; }
283
-
284
- if (c >= 0 && c < cols && z >= zGrid[r][c]) {
285
- grid[r][c] = ch;
286
- zGrid[r][c] = z;
287
- }
288
- c++;
289
- }
290
-
291
- return r; // Return last row written to (useful for tracking grid growth)
292
- }
293
-
294
280
  /**
295
281
  * Detect row boundaries โ€” groups of elements that share the same Y position
296
282
  * This prevents text from different elements on the same visual line from overlapping
@@ -399,6 +385,7 @@ function renderGrid(elements, cols, charW, charH, scrollY = 0) {
399
385
  semantic: el.semantic,
400
386
  href: el.href,
401
387
  text: el.text,
388
+ label: el.label || '',
402
389
  x: el.x,
403
390
  y: el.y,
404
391
  };
package/src/server.js CHANGED
@@ -10,7 +10,7 @@ class TextWebServer {
10
10
  constructor(options = {}) {
11
11
  this.options = {
12
12
  cols: options.cols || 100,
13
- rows: options.rows || 30,
13
+ // rows is deprecated โ€” height is dynamic
14
14
  timeout: options.timeout || 30000,
15
15
  ...options
16
16
  };
@@ -33,7 +33,6 @@ class TextWebServer {
33
33
  if (!this.browser) {
34
34
  this.browser = new AgentBrowser({
35
35
  cols: this.options.cols,
36
- rows: this.options.rows,
37
36
  headless: true,
38
37
  timeout: this.options.timeout
39
38
  });
package/src/apply.js DELETED
@@ -1,565 +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 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
- }