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.
- package/canvas/dashboard.html +153 -0
- package/package.json +1 -1
- package/src/browser.js +82 -2
- package/src/cli.js +7 -6
- package/src/renderer.js +27 -40
- package/src/server.js +1 -2
- package/src/apply.js +0 -565
|
@@ -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/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.
|
|
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.
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* -
|
|
11
|
-
* -
|
|
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
|
-
|
|
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
|
-
}
|