halo-agent 2.4.8 → 2.6.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/orchestrator.js +36 -3
- package/package.json +2 -1
- package/poller.js +37 -0
- package/resolveLinkedIn.js +161 -0
- package/scanAccessibility.js +43 -0
package/orchestrator.js
CHANGED
|
@@ -272,6 +272,15 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
272
272
|
const retryResult = await fillFields(page, aep, { speed: typingSpeed, ctx, ats: ats_type, config, jobId });
|
|
273
273
|
fillResult.filled += retryResult.filled;
|
|
274
274
|
fillResult.skipped = retryResult.skipped;
|
|
275
|
+
// Preserve sticky upload flags. If pass 1 uploaded resume / cover letter,
|
|
276
|
+
// pass 2's smartFill won't redo it (the file slot is already filled and
|
|
277
|
+
// smartFill skips), so retryResult.resumeUploaded reads false — but the
|
|
278
|
+
// file IS in the slot. ORing keeps the truth across passes; without this
|
|
279
|
+
// the orchestrator's blanket fallback uploader (below) re-uploads resume
|
|
280
|
+
// into whatever file input it finds next, which on Greenhouse is the
|
|
281
|
+
// cover-letter slot. That's the Robinhood corruption bug.
|
|
282
|
+
fillResult.resumeUploaded = fillResult.resumeUploaded || retryResult.resumeUploaded;
|
|
283
|
+
fillResult.coverLetterUploaded = fillResult.coverLetterUploaded || retryResult.coverLetterUploaded;
|
|
275
284
|
console.log(`[orchestrator] Retry fill: +${retryResult.filled} fields filled`);
|
|
276
285
|
}
|
|
277
286
|
|
|
@@ -292,9 +301,33 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
|
|
|
292
301
|
// the cover-letter slot too. smartFill now reports resumeUploaded, so we
|
|
293
302
|
// only fall back when the planner genuinely missed the resume field.
|
|
294
303
|
if (tempResumeFile && !fillResult.resumeUploaded) {
|
|
295
|
-
console.log('[orchestrator] smartFill did not upload a resume — running fallback uploader.');
|
|
296
|
-
|
|
297
|
-
|
|
304
|
+
console.log('[orchestrator] smartFill did not upload a resume — running fallback uploader (resume-scoped).');
|
|
305
|
+
// Scoped selectors. Only match file inputs that are CLEARLY resume slots:
|
|
306
|
+
// - aria-label / name / id mentions "resume" or "cv"
|
|
307
|
+
// - dedicated [data-testid*="resume"]
|
|
308
|
+
// - inputs inside a section/fieldset/div whose nearest heading text
|
|
309
|
+
// contains "Resume" or "CV" (Greenhouse pattern — uses a sibling
|
|
310
|
+
// <h3>Resume/CV*</h3>). We use Playwright's :has() to chain.
|
|
311
|
+
//
|
|
312
|
+
// Critically we EXCLUDE selectors that would match the cover-letter slot.
|
|
313
|
+
// If no resume-scoped match exists, we don't upload anywhere — better to
|
|
314
|
+
// surface a real failure than silently corrupt the cover-letter slot.
|
|
315
|
+
const resumeOnlySelectors = [
|
|
316
|
+
'input[type="file"][aria-label*="resume" i]',
|
|
317
|
+
'input[type="file"][aria-label*="cv" i]',
|
|
318
|
+
'input[type="file"][name*="resume" i]',
|
|
319
|
+
'input[type="file"][id*="resume" i]',
|
|
320
|
+
'[data-testid*="resume" i]',
|
|
321
|
+
].join(', ');
|
|
322
|
+
const uploaded = await uploadFile(page, resumeOnlySelectors, tempResumeFile);
|
|
323
|
+
if (uploaded) {
|
|
324
|
+
fillResult.filled = (fillResult.filled || 0) + 1;
|
|
325
|
+
fillResult.resumeUploaded = true;
|
|
326
|
+
} else {
|
|
327
|
+
console.warn('[orchestrator] Fallback uploader found no resume-scoped slot. Refusing to upload to generic file inputs to avoid cover-letter corruption.');
|
|
328
|
+
}
|
|
329
|
+
} else if (tempResumeFile && fillResult.resumeUploaded) {
|
|
330
|
+
console.log('[orchestrator] Skipping fallback uploader — smartFill already uploaded resume.');
|
|
298
331
|
}
|
|
299
332
|
|
|
300
333
|
await reportStatus('IN_PROGRESS', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "halo-agent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"vision.js",
|
|
31
31
|
"manusAutomate.js",
|
|
32
32
|
"ui.js",
|
|
33
|
+
"resolveLinkedIn.js",
|
|
33
34
|
"README.md"
|
|
34
35
|
],
|
|
35
36
|
"keywords": [
|
package/poller.js
CHANGED
|
@@ -31,6 +31,43 @@ async function startPolling(chromeConn, config) {
|
|
|
31
31
|
const item = await fetchNextJob(config);
|
|
32
32
|
if (!item) continue;
|
|
33
33
|
|
|
34
|
+
// ── resolve_linkedin task branch ──────────────────────────────────
|
|
35
|
+
// Quick (<5s) URL-read tasks live alongside apply tasks. The agent's
|
|
36
|
+
// Chrome is already authenticated to LinkedIn, so we read the offsite
|
|
37
|
+
// "Apply on company website" URL from the live DOM and POST it back.
|
|
38
|
+
// No tab churn, no full apply flow.
|
|
39
|
+
if (item.intent === 'resolve_linkedin') {
|
|
40
|
+
active = true;
|
|
41
|
+
try {
|
|
42
|
+
const linkedinUrl = item.step_detail || item.apply_url || item.url;
|
|
43
|
+
ui.info(`${ui.gray('//')} resolving ${ui.bold(item.company)} ${ui.gray('on linkedin')}`);
|
|
44
|
+
const { resolveLinkedInOnPage } = require('./resolveLinkedIn');
|
|
45
|
+
const result = await resolveLinkedInOnPage(chromeConn, linkedinUrl).catch((e) => ({
|
|
46
|
+
resolved_url: null, reason: e?.message || 'resolve threw',
|
|
47
|
+
}));
|
|
48
|
+
await fetch(`${config.apiUrl}/apply-queue/${item.id}/resolved`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: {
|
|
51
|
+
Authorization: `Bearer ${config.token}`,
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
...cfAccessHeaders(config),
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
resolved_url: result.resolved_url || null,
|
|
57
|
+
reason: result.reason || undefined,
|
|
58
|
+
}),
|
|
59
|
+
}).catch(() => {});
|
|
60
|
+
if (result.resolved_url) {
|
|
61
|
+
ui.info(` ${ui.sym.tick} ${ui.gray('→')} ${result.resolved_url}`);
|
|
62
|
+
} else {
|
|
63
|
+
ui.warn(` ${ui.sym.cross} could not resolve · ${result.reason || 'no URL found'}`);
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
active = false;
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
active = true;
|
|
35
72
|
console.log('');
|
|
36
73
|
ui.info(`${ui.bold(item.company)} ${ui.gray(ui.sym.dot)} ${item.title}`);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// resolveLinkedIn — open a LinkedIn job-view URL in the agent's
|
|
2
|
+
// authenticated Chrome session, read the offsite "Apply on company website"
|
|
3
|
+
// URL from the live DOM, return it. The user is already logged into
|
|
4
|
+
// LinkedIn here, so the offsite URL is actually present (unlike the
|
|
5
|
+
// unauthenticated page Cloudflare Browser Run sees).
|
|
6
|
+
//
|
|
7
|
+
// Strategies, in order:
|
|
8
|
+
// 1. <code id="applyUrl"> JSON-encoded URL
|
|
9
|
+
// 2. <a data-tracking-control-name*="apply-link-offsite"> href
|
|
10
|
+
// 3. Voyager / inline JSON dump with companyApplyUrl / applyMethod
|
|
11
|
+
// 4. Click intercept — click the visible Apply button, capture the
|
|
12
|
+
// destination URL from a new-tab event (works for the modern UI where
|
|
13
|
+
// the apply button JS-navigates instead of using an <a href>).
|
|
14
|
+
//
|
|
15
|
+
// Returns { resolved_url: string | null, reason?: string }.
|
|
16
|
+
|
|
17
|
+
const RESOLVE_TIMEOUT_MS = 15_000;
|
|
18
|
+
|
|
19
|
+
async function resolveLinkedInOnPage(chromeConn, linkedinUrl) {
|
|
20
|
+
if (!linkedinUrl || !/linkedin\.com\/jobs\/view/i.test(linkedinUrl)) {
|
|
21
|
+
return { resolved_url: null, reason: 'not a linkedin job-view url' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let page;
|
|
25
|
+
try {
|
|
26
|
+
page = await chromeConn.newPage();
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return { resolved_url: null, reason: `couldn't open page: ${e.message}` };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Listen for new-tab events on the same context — this catches the
|
|
32
|
+
// "Apply on company website" button when it opens the ATS in a new tab.
|
|
33
|
+
let newTabUrl = null;
|
|
34
|
+
const onNewPage = async (p) => {
|
|
35
|
+
try {
|
|
36
|
+
// The new tab navigates through a LinkedIn redirector first, then
|
|
37
|
+
// lands on the final ATS URL. We wait briefly, then capture.
|
|
38
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
39
|
+
const u = p.url();
|
|
40
|
+
if (u && /^https?:\/\//i.test(u) && !/linkedin\.com/i.test(u)) {
|
|
41
|
+
newTabUrl = u;
|
|
42
|
+
}
|
|
43
|
+
// Close the new tab — we have what we need, no point loading it.
|
|
44
|
+
try { await p.close(); } catch {}
|
|
45
|
+
} catch {}
|
|
46
|
+
};
|
|
47
|
+
try { chromeConn.context.on('page', onNewPage); } catch {}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await Promise.race([
|
|
51
|
+
page.goto(linkedinUrl, { waitUntil: 'domcontentloaded', timeout: RESOLVE_TIMEOUT_MS }),
|
|
52
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('navigation timeout')), RESOLVE_TIMEOUT_MS)),
|
|
53
|
+
]);
|
|
54
|
+
// Let LinkedIn's JS render the apply button (lazy-loaded).
|
|
55
|
+
await page.waitForSelector('#applyUrl, [data-tracking-control-name*="apply"], button[aria-label*="Apply" i], a[aria-label*="Apply" i]', { timeout: 6000 }).catch(() => {});
|
|
56
|
+
|
|
57
|
+
// Run the 1-3 strategies inline.
|
|
58
|
+
const found = await page.evaluate(() => {
|
|
59
|
+
const isExt = (u) =>
|
|
60
|
+
!!u && typeof u === 'string' && /^https?:\/\//i.test(u) && !/linkedin\.com/i.test(u);
|
|
61
|
+
|
|
62
|
+
// 1. <code id="applyUrl">
|
|
63
|
+
try {
|
|
64
|
+
const code = document.getElementById('applyUrl');
|
|
65
|
+
if (code && code.textContent) {
|
|
66
|
+
let raw = code.textContent.trim();
|
|
67
|
+
try { const p = JSON.parse(raw); if (isExt(p)) return p; } catch (e) {}
|
|
68
|
+
raw = raw.replace(/^"+|"+$/g, '');
|
|
69
|
+
if (isExt(raw)) return raw;
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {}
|
|
72
|
+
|
|
73
|
+
// 2. data-tracking-control-name=apply-link-offsite anchors.
|
|
74
|
+
try {
|
|
75
|
+
const sel = [
|
|
76
|
+
'a[data-tracking-control-name*="apply-link-offsite"]',
|
|
77
|
+
'a[data-tracking-control-name*="apply_unify"]',
|
|
78
|
+
'a[data-tracking-control-name*="apply-link"]',
|
|
79
|
+
].join(', ');
|
|
80
|
+
const els = Array.from(document.querySelectorAll(sel));
|
|
81
|
+
for (const el of els) {
|
|
82
|
+
const href = el.getAttribute('href') || '';
|
|
83
|
+
if (isExt(href)) return new URL(href, document.baseURI).href;
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {}
|
|
86
|
+
|
|
87
|
+
// 3. Inline JSON dumps in <code> elements.
|
|
88
|
+
try {
|
|
89
|
+
const codes = Array.from(document.querySelectorAll('code'));
|
|
90
|
+
for (const c of codes) {
|
|
91
|
+
const txt = c.textContent || '';
|
|
92
|
+
if (!txt.includes('companyApplyUrl') && !txt.includes('applyMethod')) continue;
|
|
93
|
+
try {
|
|
94
|
+
const obj = JSON.parse(txt);
|
|
95
|
+
const stack = [obj];
|
|
96
|
+
while (stack.length) {
|
|
97
|
+
const cur = stack.pop();
|
|
98
|
+
if (!cur || typeof cur !== 'object') continue;
|
|
99
|
+
if (isExt(cur.companyApplyUrl)) return cur.companyApplyUrl;
|
|
100
|
+
if (isExt(cur.applyUrl)) return cur.applyUrl;
|
|
101
|
+
if (isExt(cur.url) && cur.applyMethod) return cur.url;
|
|
102
|
+
for (const k of Object.keys(cur)) {
|
|
103
|
+
if (typeof cur[k] === 'object') stack.push(cur[k]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch (e) {}
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {}
|
|
109
|
+
|
|
110
|
+
return null;
|
|
111
|
+
}).catch(() => null);
|
|
112
|
+
|
|
113
|
+
if (found) {
|
|
114
|
+
// Clean up listener and page.
|
|
115
|
+
try { chromeConn.context.off('page', onNewPage); } catch {}
|
|
116
|
+
try { await page.close(); } catch {}
|
|
117
|
+
return { resolved_url: found };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Strategy 4 — click the Apply button and watch for a new tab.
|
|
121
|
+
try {
|
|
122
|
+
const clicked = await page.evaluate(() => {
|
|
123
|
+
const candidates = [
|
|
124
|
+
'button[data-tracking-control-name*="apply"]',
|
|
125
|
+
'a[data-tracking-control-name*="apply"]',
|
|
126
|
+
'button[aria-label*="Apply" i]',
|
|
127
|
+
'a[aria-label*="Apply" i]',
|
|
128
|
+
];
|
|
129
|
+
for (const sel of candidates) {
|
|
130
|
+
const el = document.querySelector(sel);
|
|
131
|
+
if (el) {
|
|
132
|
+
try { el.click(); return true; } catch (e) {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
});
|
|
137
|
+
if (clicked) {
|
|
138
|
+
// Wait up to 5s for the new tab listener to populate newTabUrl.
|
|
139
|
+
const start = Date.now();
|
|
140
|
+
while (Date.now() - start < 5000 && !newTabUrl) {
|
|
141
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
142
|
+
}
|
|
143
|
+
if (newTabUrl) {
|
|
144
|
+
try { chromeConn.context.off('page', onNewPage); } catch {}
|
|
145
|
+
try { await page.close(); } catch {}
|
|
146
|
+
return { resolved_url: newTabUrl };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
|
|
151
|
+
try { chromeConn.context.off('page', onNewPage); } catch {}
|
|
152
|
+
try { await page.close(); } catch {}
|
|
153
|
+
return { resolved_url: null, reason: 'no offsite URL found in any strategy' };
|
|
154
|
+
} catch (e) {
|
|
155
|
+
try { chromeConn.context.off('page', onNewPage); } catch {}
|
|
156
|
+
try { await page.close(); } catch {}
|
|
157
|
+
return { resolved_url: null, reason: e?.message || 'navigation failed' };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { resolveLinkedInOnPage };
|
package/scanAccessibility.js
CHANGED
|
@@ -311,6 +311,48 @@ async function enrichFromDom(page, axFields) {
|
|
|
311
311
|
}
|
|
312
312
|
} catch {}
|
|
313
313
|
|
|
314
|
+
// Group-heading disambiguation for file inputs with generic labels.
|
|
315
|
+
// Greenhouse renders two `<input type="file">` inputs both labeled
|
|
316
|
+
// "Attach" — one under a `<h3>Resume/CV*</h3>` heading, one under
|
|
317
|
+
// `<h3>Cover Letter</h3>`. The AX name only sees "Attach" for both,
|
|
318
|
+
// so the planner can't route resume vs cover_letter deterministically.
|
|
319
|
+
// Walk up to the nearest heading and capture its text as groupLabel.
|
|
320
|
+
let groupLabel = '';
|
|
321
|
+
try {
|
|
322
|
+
const isFile = (el.tagName === 'INPUT' && String(el.type || '').toLowerCase() === 'file');
|
|
323
|
+
if (isFile) {
|
|
324
|
+
// Use the resolved AX/placeholder label if present; else fall back
|
|
325
|
+
// to the input's textContent / aria-label.
|
|
326
|
+
const lblForCheck = (el.getAttribute('aria-label') || el.placeholder || '').trim();
|
|
327
|
+
// Conservative: only fire when the immediate label is a generic
|
|
328
|
+
// upload verb. We don't want to clobber a real label like "Resume".
|
|
329
|
+
const generic = /^(attach|attach\s*file|upload|upload\s*file|choose file|select file|browse|add file|file)$/i;
|
|
330
|
+
// The visible button label is often on a sibling <button>; check that too.
|
|
331
|
+
let buttonText = '';
|
|
332
|
+
try {
|
|
333
|
+
const wrapper = el.closest('div, section, fieldset, [class*="field"], [class*="upload"]');
|
|
334
|
+
const btn = wrapper && wrapper.querySelector('button, [role="button"]');
|
|
335
|
+
if (btn) buttonText = safeText(btn.innerText || btn.textContent || '').trim();
|
|
336
|
+
} catch {}
|
|
337
|
+
const labelLooksGeneric = !lblForCheck || generic.test(lblForCheck) || generic.test(buttonText || '');
|
|
338
|
+
if (labelLooksGeneric) {
|
|
339
|
+
// Walk up to 8 ancestors and look for a heading inside that ancestor.
|
|
340
|
+
let p = el.parentElement, hops = 0;
|
|
341
|
+
while (p && hops < 8) {
|
|
342
|
+
const heading = p.querySelector('h1, h2, h3, h4, h5, h6, legend, [role="heading"]');
|
|
343
|
+
if (heading && !heading.contains(el)) {
|
|
344
|
+
const t = safeText(heading.textContent || '').replace(/\s*\*\s*$/, '').trim();
|
|
345
|
+
if (t && t.length > 0 && t.length < 150) {
|
|
346
|
+
groupLabel = t;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
p = p.parentElement; hops++;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} catch {}
|
|
355
|
+
|
|
314
356
|
return {
|
|
315
357
|
mmid,
|
|
316
358
|
tag,
|
|
@@ -321,6 +363,7 @@ async function enrichFromDom(page, axFields) {
|
|
|
321
363
|
ariaLabel: el.getAttribute('aria-label') || '',
|
|
322
364
|
placeholder: el.placeholder || '',
|
|
323
365
|
recoveredLabel,
|
|
366
|
+
groupLabel,
|
|
324
367
|
selectorHint,
|
|
325
368
|
currentValue,
|
|
326
369
|
options,
|