halo-agent 2.6.0 → 2.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
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,7 +30,6 @@
30
30
  "vision.js",
31
31
  "manusAutomate.js",
32
32
  "ui.js",
33
- "resolveLinkedIn.js",
34
33
  "README.md"
35
34
  ],
36
35
  "keywords": [
package/poller.js CHANGED
@@ -31,43 +31,6 @@ 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
-
71
34
  active = true;
72
35
  console.log('');
73
36
  ui.info(`${ui.bold(item.company)} ${ui.gray(ui.sym.dot)} ${item.title}`);
@@ -76,6 +76,12 @@ async function injectMmid(page) {
76
76
  '[role="option"]',
77
77
  '[role="menuitem"]',
78
78
  'button[type="submit"]',
79
+ // ATS toggle-button pairs (Ashby "Yes"/"No", some Greenhouse choice
80
+ // chips) are rendered as plain `<button>` elements without an explicit
81
+ // role attribute. Without this, the scan misses every required Yes/No
82
+ // question on Ashby and the planner silently leaves them blank.
83
+ // Downstream filters drop nav/close/submit buttons by label heuristic.
84
+ 'button',
79
85
  'a[href]',
80
86
  ].join(',');
81
87
  const all = document.querySelectorAll(sel);
@@ -496,7 +502,7 @@ async function scanFrameDom(frame, frameUrl) {
496
502
  const seen = new Set();
497
503
  let n = Math.floor(Math.random() * 800000) + 200000; // high offset, no main-frame collision
498
504
 
499
- const sel = 'input,textarea,select,[contenteditable="true"],[role="textbox"],[role="combobox"],[role="listbox"],[role="radio"],[role="checkbox"],[role="switch"],[role="button"],button[type="submit"]';
505
+ const sel = 'input,textarea,select,[contenteditable="true"],[role="textbox"],[role="combobox"],[role="listbox"],[role="radio"],[role="checkbox"],[role="switch"],[role="button"],button[type="submit"],button';
500
506
  document.querySelectorAll(sel).forEach((el) => {
501
507
  const tag = el.tagName.toLowerCase();
502
508
  const type = (el.type || '').toLowerCase();
@@ -567,6 +573,7 @@ async function scanFrameDom(frame, frameUrl) {
567
573
  return raw.map((f) => ({
568
574
  mmid: f.mmid,
569
575
  role: f.forceRole || normalizeRole(f),
576
+ tag: f.tag, // needed by main-frame filter to distinguish <button> from [role=button]
570
577
  label: f.label || f.textContent || '',
571
578
  description: '',
572
579
  required: false,
@@ -651,6 +658,21 @@ async function scanAccessibility(page) {
651
658
  if (f.frameUrl) {
652
659
  const fl = (f.label || '').trim();
653
660
  if (f.role === 'button' && (/^remove\s+\S/i.test(fl) || /^clear\s+selection/i.test(fl) || fl === '×' || fl === '✕' || fl === 'x')) continue;
661
+ // Apply the same nav-button filter for in-frame buttons.
662
+ if (f.role === 'button' && f.tag === 'button') {
663
+ const ll = fl.toLowerCase().trim();
664
+ const navWords = [
665
+ 'back', 'cancel', 'close', 'menu', 'open menu', 'dismiss',
666
+ 'submit', 'submit application', 'apply', 'apply now',
667
+ 'sign in', 'sign up', 'log in', 'login', 'logout',
668
+ 'next', 'previous', 'continue', 'edit', 'save',
669
+ 'save and continue', 'save & continue',
670
+ 'toggle theme', 'change theme', 'show password', 'hide password',
671
+ ];
672
+ if (navWords.includes(ll)) continue;
673
+ if (f.inputType === 'submit') continue;
674
+ if (fl.length > 80) continue;
675
+ }
654
676
  if (!fl && !['button', 'link'].includes(f.role)) continue;
655
677
  out.push(f); // already in public shape, carries frameUrl
656
678
  continue;
@@ -673,6 +695,29 @@ async function scanAccessibility(page) {
673
695
  )) {
674
696
  continue;
675
697
  }
698
+ // Filter out navigation / submit / close buttons surfaced by the
699
+ // broader generic-button selector. Answer-choice buttons (Yes / No /
700
+ // short option text) stay — they're what powers Ashby's toggle pairs.
701
+ if (normalizeRole(f) === 'button' && f.tag === 'button') {
702
+ const ll = label.toLowerCase().trim();
703
+ // Drop common nav buttons by exact-ish label
704
+ const navWords = [
705
+ 'back', 'cancel', 'close', 'menu', 'open menu', 'dismiss',
706
+ 'submit', 'submit application', 'apply', 'apply now',
707
+ 'sign in', 'sign up', 'log in', 'login', 'logout',
708
+ 'next', 'previous', 'continue', // multi-step nav — filter; the form's own pagination handles
709
+ 'edit', 'save', 'save and continue', 'save & continue',
710
+ 'toggle theme', 'change theme', 'show password', 'hide password',
711
+ 'view jd', 'view job', 'open application',
712
+ ];
713
+ if (navWords.includes(ll)) continue;
714
+ // Drop submit-type buttons even without a matching label
715
+ if (f.inputType === 'submit') continue;
716
+ // Drop very long labels — answer choices are short. >80 chars is
717
+ // almost certainly help text, fineprint, or accidentally-captured
718
+ // descendant content.
719
+ if (label.length > 80) continue;
720
+ }
676
721
  out.push({
677
722
  mmid: f.mmid,
678
723
  role: normalizeRole(f),
@@ -1,161 +0,0 @@
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 };