textweb 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pipeline.js DELETED
@@ -1,317 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * TextWeb Job Pipeline — Full auto-apply with Canvas dashboard
4
- *
5
- * Usage:
6
- * node pipeline.js # Discover + apply
7
- * node pipeline.js --discover-only # Just find jobs, show on canvas
8
- * node pipeline.js --jobs jobs.json # Apply from a pre-built list
9
- * node pipeline.js --url <url> # Apply to a single URL
10
- * node pipeline.js --company <name> --url <url>
11
- */
12
-
13
- const { AgentBrowser } = require('./browser');
14
- const { analyzeForm } = require('./apply');
15
- const { generateAnswers, checkLLM } = require('./llm');
16
- const { PipelineDashboard } = require('./dashboard');
17
- const fs = require('fs');
18
- const path = require('path');
19
-
20
- const RESUME = path.join(process.env.HOME, '.jobsearch', 'christopher-robison-resume.pdf');
21
-
22
- // ── Job Discovery ─────────────────────────────────────────────
23
-
24
- const GREENHOUSE_BOARDS = [
25
- { name: 'Stripe', board: 'stripe' },
26
- { name: 'Discord', board: 'discord' },
27
- { name: 'Figma', board: 'figma' },
28
- { name: 'Vercel', board: 'vercel' },
29
- { name: 'Postman', board: 'postman' },
30
- { name: 'Airtable', board: 'airtable' },
31
- { name: 'GitLab', board: 'gitlab' },
32
- { name: 'Cloudflare', board: 'cloudflare' },
33
- { name: 'Coinbase', board: 'coinbase' },
34
- { name: 'Scale AI', board: 'scaleai' },
35
- { name: 'Rippling', board: 'rippling' },
36
- { name: 'Retool', board: 'retool' },
37
- { name: 'Supabase', board: 'supabase' },
38
- { name: 'Doximity', board: 'doximity' },
39
- { name: 'DoorDash', board: 'doordash' },
40
- { name: 'Instacart', board: 'instacart' },
41
- { name: 'HashiCorp', board: 'hashicorp' },
42
- { name: 'Plaid', board: 'plaid' },
43
- { name: 'Anduril', board: 'andurilindustries' },
44
- { name: 'Linear', board: 'linear06' },
45
- { name: 'Notion', board: 'notion' },
46
- { name: 'Anthropic', board: 'anthropic' },
47
- { name: 'OpenAI', board: 'openai' },
48
- { name: 'Databricks', board: 'databricks' },
49
- { name: 'Datadog', board: 'datadoghq' },
50
- { name: 'MongoDB', board: 'mongodb' },
51
- { name: 'Elastic', board: 'elastic' },
52
- { name: 'Snyk', board: 'snyk' },
53
- { name: 'Grafana', board: 'grafanalabs' },
54
- { name: 'Temporal', board: 'temporaltechnologies' },
55
- ];
56
-
57
- const TITLE_PATTERNS = /director|manager|vp|head|staff|principal|lead|architect/i;
58
- const DEPT_PATTERNS = /eng|software|platform|infra|tech|full.?stack|backend|frontend|mobile|data|ai|ml|devops|sre|cloud/i;
59
-
60
- async function discoverJobs(browser, boards, existingUrls = new Set()) {
61
- const found = [];
62
-
63
- for (const c of boards) {
64
- try {
65
- await browser.navigate('https://boards.greenhouse.io/' + c.board);
66
-
67
- const jobs = await browser.page.evaluate(() => {
68
- const results = [];
69
- document.querySelectorAll('a').forEach(a => {
70
- const href = a.getAttribute('href') || '';
71
- const m = href.match(/\/jobs\/(\d+)/);
72
- if (m) results.push({ id: m[1], title: a.textContent.trim().substring(0, 120) });
73
- });
74
- return results;
75
- });
76
-
77
- const matches = jobs.filter(j =>
78
- TITLE_PATTERNS.test(j.title) && DEPT_PATTERNS.test(j.title)
79
- );
80
-
81
- for (const j of matches.slice(0, 3)) {
82
- const url = `https://job-boards.greenhouse.io/embed/job_app?for=${c.board}&token=${j.id}`;
83
- if (!existingUrls.has(url)) {
84
- found.push({
85
- company: c.name,
86
- title: j.title.replace(/\s+/g, ' ').trim(),
87
- url,
88
- board: c.board,
89
- jobId: j.id,
90
- });
91
- }
92
- }
93
- } catch (e) {
94
- // Board not found or errored — skip
95
- }
96
- }
97
-
98
- return found;
99
- }
100
-
101
- // ── Application Engine ────────────────────────────────────────
102
-
103
- async function applyToJob(browser, job, llmOk, dash) {
104
- const { company, title, url } = job;
105
-
106
- await dash.startJob(company, title);
107
-
108
- let r;
109
- try {
110
- r = await browser.navigate(url);
111
- } catch (e) {
112
- await dash.failed(company, title, 'Page load failed');
113
- return false;
114
- }
115
-
116
- if (Object.keys(r.elements).length < 5) {
117
- await dash.failed(company, title, 'Too few elements (expired?)');
118
- return false;
119
- }
120
-
121
- const actions = analyzeForm(r);
122
- const fillable = actions.filter(a => a.action === 'type' && a.value && a.selector);
123
- const unknowns = actions.filter(a => a.action === 'type' && !a.value && a.selector);
124
- const uploads = actions.filter(a => a.action === 'upload' && a.selector);
125
- const skips = actions.filter(a => a.action === 'skip');
126
-
127
- console.log(`[${company}] Plan: ${fillable.length} auto | ${unknowns.length} LLM | ${uploads.length} upload | ${skips.length} skip`);
128
-
129
- // Phase 1: Auto-fill
130
- for (const a of fillable) {
131
- try {
132
- const isCombo = await browser.page.evaluate((sel) => {
133
- const el = document.querySelector(sel);
134
- return el && (el.getAttribute('role') === 'combobox' || el.classList.contains('select__input'));
135
- }, a.selector);
136
-
137
- if (isCombo) {
138
- await browser.page.click(a.selector);
139
- await browser.page.fill(a.selector, '');
140
- await browser.page.type(a.selector, a.value, { delay: 50 });
141
- await browser.page.waitForTimeout(500);
142
- await browser.page.keyboard.press('ArrowDown');
143
- await browser.page.waitForTimeout(100);
144
- await browser.page.keyboard.press('Enter');
145
- await browser.page.waitForTimeout(200);
146
- } else {
147
- await browser.fillBySelector(a.selector, a.value);
148
- }
149
-
150
- await dash.fieldFilling(company, title, a.field, a.value);
151
- console.log(` ✏️ ${a.field} → ${a.value}`);
152
- } catch (e) {
153
- console.log(` ⚠️ ${a.field} FAIL: ${e.message.substring(0, 50)}`);
154
- }
155
- }
156
-
157
- // Phase 2: Upload
158
- for (const a of uploads) {
159
- try {
160
- await dash.uploading(company, title);
161
- await browser.uploadBySelector(a.selector, RESUME);
162
- console.log(' 📎 Resume uploaded');
163
- } catch (e) {
164
- console.log(' ⚠️ Upload failed');
165
- }
166
- }
167
-
168
- // Phase 3: LLM
169
- if (unknowns.length > 0 && llmOk) {
170
- await dash.llmGenerating(company, title, unknowns.length);
171
- console.log(` 🤖 Generating ${unknowns.length} answers...`);
172
-
173
- const answers = await generateAnswers(unknowns, r.view.substring(0, 2000), company);
174
- for (const u of unknowns) {
175
- const ans = answers[u.ref];
176
- if (ans) {
177
- try {
178
- await browser.fillBySelector(u.selector, ans);
179
- await dash.fieldFilling(company, title, u.field, ans);
180
- console.log(` 🤖 ${u.field.substring(0, 50)} → ${ans.substring(0, 60)}...`);
181
- } catch (e) {
182
- console.log(` ⚠️ ${u.field.substring(0, 50)} FAIL`);
183
- }
184
- }
185
- }
186
- }
187
-
188
- for (const a of skips) {
189
- console.log(` ⏭️ ${a.field} (EEO)`);
190
- }
191
-
192
- // Phase 4: Submit
193
- await dash.submitting(company, title);
194
- r = await browser.snapshot();
195
-
196
- let submitSel = null;
197
- for (const [ref, el] of Object.entries(r.elements)) {
198
- if ((el.semantic === 'button' || el.semantic === 'link') && /submit/i.test(el.text || '')) {
199
- submitSel = el.selector;
200
- break;
201
- }
202
- }
203
-
204
- if (!submitSel) {
205
- await dash.failed(company, title, 'No submit button found');
206
- console.log(' ❌ No submit button');
207
- return false;
208
- }
209
-
210
- try {
211
- await browser.page.click(submitSel);
212
- await Promise.race([
213
- browser.page.waitForNavigation({ timeout: 8000 }).catch(() => {}),
214
- browser.page.waitForTimeout(6000),
215
- ]);
216
- r = await browser.snapshot();
217
- const t = r.view.toLowerCase();
218
-
219
- if (/error|required|invalid|please.*fill/i.test(t.substring(0, 600))) {
220
- await dash.failed(company, title, 'Validation errors');
221
- console.log(' ❌ Validation errors');
222
- return false;
223
- }
224
-
225
- const details = { autoFields: fillable.length, llmFields: unknowns.length };
226
-
227
- if (/submitted|thank\s*you|success|received|confirmed|we.*review/i.test(t)) {
228
- await dash.submitted(company, title, details);
229
- console.log(' ✅ SUBMITTED! 🎉');
230
- return true;
231
- } else {
232
- // No errors = probably worked
233
- await dash.submitted(company, title, details);
234
- console.log(' ✅ SUBMITTED (no errors)');
235
- return true;
236
- }
237
- } catch (e) {
238
- await dash.failed(company, title, e.message.substring(0, 60));
239
- console.log(` ❌ ${e.message.substring(0, 60)}`);
240
- return false;
241
- }
242
- }
243
-
244
- // ── Main ──────────────────────────────────────────────────────
245
-
246
- async function main() {
247
- const args = process.argv.slice(2);
248
- const discoverOnly = args.includes('--discover-only');
249
- const jobsFile = args.includes('--jobs') ? args[args.indexOf('--jobs') + 1] : null;
250
- const singleUrl = args.includes('--url') ? args[args.indexOf('--url') + 1] : null;
251
- const singleCompany = args.includes('--company') ? args[args.indexOf('--company') + 1] : 'Unknown';
252
- const limit = args.includes('--limit') ? parseInt(args[args.indexOf('--limit') + 1]) : 10;
253
-
254
- const dash = new PipelineDashboard();
255
- await dash.init();
256
-
257
- const llmOk = await checkLLM();
258
- console.log('LLM:', llmOk ? '✅' : '❌');
259
-
260
- const browser = new AgentBrowser({ cols: 120 });
261
- await browser.launch();
262
-
263
- let jobs = [];
264
-
265
- if (singleUrl) {
266
- jobs = [{ company: singleCompany, title: 'Application', url: singleUrl }];
267
- } else if (jobsFile) {
268
- jobs = JSON.parse(fs.readFileSync(jobsFile, 'utf8'));
269
- } else {
270
- // Discover new jobs
271
- console.log('🔍 Discovering jobs from', GREENHOUSE_BOARDS.length, 'boards...');
272
- const state = dash.getState();
273
- const existingUrls = new Set(state.jobs.map(j => j.url));
274
-
275
- jobs = await discoverJobs(browser, GREENHOUSE_BOARDS, existingUrls);
276
- console.log(`Found ${jobs.length} new matching roles`);
277
-
278
- // Queue them all on the dashboard
279
- for (const job of jobs.slice(0, limit)) {
280
- await dash.queueJob(job);
281
- }
282
-
283
- if (discoverOnly) {
284
- console.log('Discovery only — not applying.');
285
- await browser.close();
286
- return;
287
- }
288
-
289
- jobs = jobs.slice(0, limit);
290
- }
291
-
292
- // Apply to each
293
- const results = [];
294
- for (const job of jobs) {
295
- const ok = await applyToJob(browser, job, llmOk, dash);
296
- results.push({ ...job, ok });
297
- }
298
-
299
- await browser.close();
300
-
301
- // Final summary
302
- console.log('\n═══ SUMMARY ═══');
303
- const submitted = results.filter(r => r.ok).length;
304
- const failed = results.filter(r => !r.ok).length;
305
- for (const r of results) {
306
- console.log(`${r.ok ? '✅' : '❌'} ${r.company} — ${r.title}`);
307
- }
308
- console.log(`\n${submitted} submitted, ${failed} failed out of ${results.length} total`);
309
-
310
- // Refresh dashboard
311
- await dash.init();
312
- }
313
-
314
- main().catch(e => {
315
- console.error('Pipeline error:', e);
316
- process.exit(1);
317
- });