textweb 0.1.0 → 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 +25 -0
- package/README.md +20 -0
- package/canvas/dashboard.html +153 -0
- package/package.json +1 -1
- package/src/apply.js +195 -15
- package/src/browser.js +37 -1
- package/src/cli.js +2 -2
- package/src/dashboard.js +196 -0
- package/src/llm.js +220 -0
- package/src/pipeline.js +317 -0
- package/src/renderer.js +25 -0
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,'<').replace(/>/g,'>'); }
|
|
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
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"
|
|
58
|
-
{ match: /^(?!.*e-?mail).*(city
|
|
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:
|
|
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: /
|
|
73
|
-
{ match: /
|
|
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
|
-
//
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
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
|
@@ -34,7 +34,13 @@ class AgentBrowser {
|
|
|
34
34
|
async navigate(url) {
|
|
35
35
|
if (!this.page) await this.launch();
|
|
36
36
|
this.scrollY = 0;
|
|
37
|
-
|
|
37
|
+
// Use domcontentloaded + a short settle, not networkidle (SPAs never go idle)
|
|
38
|
+
await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
39
|
+
// Wait for network to settle or 3s max — whichever comes first
|
|
40
|
+
await Promise.race([
|
|
41
|
+
this.page.waitForLoadState('networkidle').catch(() => {}),
|
|
42
|
+
new Promise(r => setTimeout(r, 3000)),
|
|
43
|
+
]);
|
|
38
44
|
return await this.snapshot();
|
|
39
45
|
}
|
|
40
46
|
|
|
@@ -65,6 +71,36 @@ class AgentBrowser {
|
|
|
65
71
|
return await this.snapshot();
|
|
66
72
|
}
|
|
67
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
|
+
|
|
68
104
|
async press(key) {
|
|
69
105
|
await this.page.keyboard.press(key);
|
|
70
106
|
await this.page.waitForLoadState('networkidle').catch(() => {});
|
package/src/cli.js
CHANGED
|
@@ -139,7 +139,7 @@ async function render(url, options) {
|
|
|
139
139
|
if (elCount > 0) {
|
|
140
140
|
console.error(`\\nInteractive elements:`);
|
|
141
141
|
for (const [ref, element] of Object.entries(result.elements || {})) {
|
|
142
|
-
console.error(`[${ref}] ${element.
|
|
142
|
+
console.error(`[${ref}] ${element.semantic || element.tag}: ${element.text || '(no text)'}`);
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
}
|
|
@@ -312,7 +312,7 @@ Interactive Commands:
|
|
|
312
312
|
if (result && Object.keys(result.elements || {}).length > 0) {
|
|
313
313
|
console.log(`Interactive elements (${Object.keys(result.elements || {}).length}):`);
|
|
314
314
|
for (const [ref, element] of Object.entries(result.elements || {})) {
|
|
315
|
-
console.log(`[${ref}] ${element.
|
|
315
|
+
console.log(`[${ref}] ${element.semantic || element.tag}: ${element.text || '(no text)'}`);
|
|
316
316
|
}
|
|
317
317
|
} else {
|
|
318
318
|
console.log('No interactive elements found');
|