halo-agent 2.6.0 → 2.6.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/package.json +1 -2
- package/poller.js +0 -37
- package/scanAccessibility.js +180 -1
- package/smartFill.js +30 -0
- package/resolveLinkedIn.js +0 -161
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "halo-agent",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.2",
|
|
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}`);
|
package/scanAccessibility.js
CHANGED
|
@@ -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,
|
|
@@ -636,6 +643,107 @@ async function scanAccessibility(page) {
|
|
|
636
643
|
}
|
|
637
644
|
}
|
|
638
645
|
|
|
646
|
+
// ── Toggle-group coalescing (Ashby, similar custom designs) ─────────────
|
|
647
|
+
// Some ATSes render Yes/No (and similar pick-one) questions as a pair of
|
|
648
|
+
// <button> elements above a hidden <input type="checkbox">. The buttons
|
|
649
|
+
// have no role attribute, no name connecting them, and they're scanned
|
|
650
|
+
// as independent fields with label "Yes" or "No" — the planner can't tell
|
|
651
|
+
// them apart from a true Yes/No without knowing the QUESTION.
|
|
652
|
+
//
|
|
653
|
+
// We detect these groups ONLY via two well-defined hooks:
|
|
654
|
+
// 1. Ashby's `_yesno_` container class (the explicit yes/no widget)
|
|
655
|
+
// 2. The generic shape: a `_fieldEntry_` wrapper containing a question
|
|
656
|
+
// label + a sibling div whose ONLY children are 2-3 <button> elements
|
|
657
|
+
// with short text labels (Yes/No or short option text).
|
|
658
|
+
//
|
|
659
|
+
// For each detected group we:
|
|
660
|
+
// - Pick a "primary" mmid (the first button) and rewrite its label to
|
|
661
|
+
// the QUESTION text, role to 'radio', options to ['Yes','No'], and
|
|
662
|
+
// groupLabel to the question. The planner now sees this as a single
|
|
663
|
+
// radio question with two options.
|
|
664
|
+
// - Mark the OTHER button(s) in the group as droppable so the dedupe
|
|
665
|
+
// loop below skips them — the planner only sees one synthetic radio
|
|
666
|
+
// per group.
|
|
667
|
+
//
|
|
668
|
+
// Scoped tightly to avoid touching Greenhouse / Lever / Workday, all of
|
|
669
|
+
// which use native <input type="radio"> with `name` attributes for their
|
|
670
|
+
// pick-one questions.
|
|
671
|
+
let toggleGroupInfo = { primaries: {}, drop: new Set() };
|
|
672
|
+
try {
|
|
673
|
+
toggleGroupInfo = await page.evaluate(({ mmidAttr }) => {
|
|
674
|
+
function safeText(t) { return (t || '').replace(/\s+/g, ' ').trim(); }
|
|
675
|
+
const out = { primaries: {}, drop: [] };
|
|
676
|
+
|
|
677
|
+
// Pass 1: Ashby's explicit _yesno_ widget. CSS module hashing means
|
|
678
|
+
// the class name is like "_yesno_1e3gg_148" — match by substring.
|
|
679
|
+
const ashbyYesno = document.querySelectorAll('[class*="_yesno_"]');
|
|
680
|
+
ashbyYesno.forEach((container) => {
|
|
681
|
+
const buttons = Array.from(container.querySelectorAll('button'));
|
|
682
|
+
if (buttons.length < 2 || buttons.length > 4) return;
|
|
683
|
+
// The question label lives on a sibling <label> inside the same
|
|
684
|
+
// _fieldEntry_ wrapper.
|
|
685
|
+
const wrapper = container.closest('[class*="_fieldEntry_"], [data-field-path], .ashby-application-form-field-entry');
|
|
686
|
+
let question = '';
|
|
687
|
+
if (wrapper) {
|
|
688
|
+
const lbl = wrapper.querySelector('label');
|
|
689
|
+
if (lbl) question = safeText(lbl.textContent || '').replace(/\s*\*\s*$/, '').trim();
|
|
690
|
+
}
|
|
691
|
+
if (!question) return;
|
|
692
|
+
const mmids = buttons.map((b) => b.getAttribute(mmidAttr)).filter(Boolean);
|
|
693
|
+
if (mmids.length < 2) return;
|
|
694
|
+
const options = buttons.map((b) => safeText(b.textContent || '').trim()).filter(Boolean);
|
|
695
|
+
// First button is the primary — it'll be rewritten into the radio
|
|
696
|
+
// question. The rest get dropped from the field list.
|
|
697
|
+
out.primaries[mmids[0]] = { question, options };
|
|
698
|
+
for (let i = 1; i < mmids.length; i++) out.drop.push(mmids[i]);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// Pass 2: generic shape — _fieldEntry_ wrapper with a label + an
|
|
702
|
+
// option container whose direct children are only buttons (no inputs,
|
|
703
|
+
// no other interactive elements). Catches less-tagged variants of the
|
|
704
|
+
// same pattern. Conservative: only if EVERY direct interactive child
|
|
705
|
+
// is a button with short text.
|
|
706
|
+
const wrappers = document.querySelectorAll('[class*="_fieldEntry_"], [data-field-path], .ashby-application-form-field-entry');
|
|
707
|
+
wrappers.forEach((wrapper) => {
|
|
708
|
+
// Skip wrappers we've already coalesced via Pass 1.
|
|
709
|
+
if (wrapper.querySelector('[class*="_yesno_"]')) return;
|
|
710
|
+
const lbl = wrapper.querySelector('label');
|
|
711
|
+
if (!lbl) return;
|
|
712
|
+
const question = safeText(lbl.textContent || '').replace(/\s*\*\s*$/, '').trim();
|
|
713
|
+
if (!question || question.length > 200) return;
|
|
714
|
+
// Look for a button-only group sibling/descendant.
|
|
715
|
+
const candidates = Array.from(wrapper.querySelectorAll('div'))
|
|
716
|
+
.filter((d) => {
|
|
717
|
+
const kids = Array.from(d.children);
|
|
718
|
+
if (kids.length < 2 || kids.length > 4) return false;
|
|
719
|
+
return kids.every((k) => {
|
|
720
|
+
if (k.tagName !== 'BUTTON') return false;
|
|
721
|
+
const t = safeText(k.textContent || '').trim();
|
|
722
|
+
return t.length > 0 && t.length < 80;
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
if (!candidates.length) return;
|
|
726
|
+
const buttons = Array.from(candidates[0].children);
|
|
727
|
+
const mmids = buttons.map((b) => b.getAttribute(mmidAttr)).filter(Boolean);
|
|
728
|
+
if (mmids.length < 2) return;
|
|
729
|
+
// Skip if we already have a primary registered for these (Pass 1 ran).
|
|
730
|
+
if (out.primaries[mmids[0]]) return;
|
|
731
|
+
const options = buttons.map((b) => safeText(b.textContent || '').trim());
|
|
732
|
+
out.primaries[mmids[0]] = { question, options };
|
|
733
|
+
for (let i = 1; i < mmids.length; i++) out.drop.push(mmids[i]);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
return out;
|
|
737
|
+
}, { mmidAttr: MMID_ATTR });
|
|
738
|
+
} catch (e) {
|
|
739
|
+
console.warn(`[scanAx] toggle-group detection failed: ${e.message}`);
|
|
740
|
+
}
|
|
741
|
+
const dropSet = new Set(toggleGroupInfo.drop || []);
|
|
742
|
+
const primaries = toggleGroupInfo.primaries || {};
|
|
743
|
+
if (Object.keys(primaries).length > 0) {
|
|
744
|
+
console.log(`[scanAx] coalesced ${Object.keys(primaries).length} toggle-button group(s) into radio questions`);
|
|
745
|
+
}
|
|
746
|
+
|
|
639
747
|
// Normalize, dedupe by mmid (frames could collide, though we offset above)
|
|
640
748
|
const seen = new Set();
|
|
641
749
|
const out = [];
|
|
@@ -643,6 +751,10 @@ async function scanAccessibility(page) {
|
|
|
643
751
|
if (seen.has(f.mmid)) continue;
|
|
644
752
|
seen.add(f.mmid);
|
|
645
753
|
|
|
754
|
+
// Drop secondary buttons in coalesced toggle groups — the primary will
|
|
755
|
+
// be emitted as a synthetic radio question covering all options.
|
|
756
|
+
if (dropSet.has(f.mmid)) continue;
|
|
757
|
+
|
|
646
758
|
// Frame-DOM fields are ALREADY normalized (scanFrameDom produced the
|
|
647
759
|
// public shape: role/label/options as strings). Re-running pickLabel /
|
|
648
760
|
// normalizeRole / options.map(o=>o.label) on them would break (those
|
|
@@ -651,6 +763,21 @@ async function scanAccessibility(page) {
|
|
|
651
763
|
if (f.frameUrl) {
|
|
652
764
|
const fl = (f.label || '').trim();
|
|
653
765
|
if (f.role === 'button' && (/^remove\s+\S/i.test(fl) || /^clear\s+selection/i.test(fl) || fl === '×' || fl === '✕' || fl === 'x')) continue;
|
|
766
|
+
// Apply the same nav-button filter for in-frame buttons.
|
|
767
|
+
if (f.role === 'button' && f.tag === 'button') {
|
|
768
|
+
const ll = fl.toLowerCase().trim();
|
|
769
|
+
const navWords = [
|
|
770
|
+
'back', 'cancel', 'close', 'menu', 'open menu', 'dismiss',
|
|
771
|
+
'submit', 'submit application', 'apply', 'apply now',
|
|
772
|
+
'sign in', 'sign up', 'log in', 'login', 'logout',
|
|
773
|
+
'next', 'previous', 'continue', 'edit', 'save',
|
|
774
|
+
'save and continue', 'save & continue',
|
|
775
|
+
'toggle theme', 'change theme', 'show password', 'hide password',
|
|
776
|
+
];
|
|
777
|
+
if (navWords.includes(ll)) continue;
|
|
778
|
+
if (f.inputType === 'submit') continue;
|
|
779
|
+
if (fl.length > 80) continue;
|
|
780
|
+
}
|
|
654
781
|
if (!fl && !['button', 'link'].includes(f.role)) continue;
|
|
655
782
|
out.push(f); // already in public shape, carries frameUrl
|
|
656
783
|
continue;
|
|
@@ -673,6 +800,58 @@ async function scanAccessibility(page) {
|
|
|
673
800
|
)) {
|
|
674
801
|
continue;
|
|
675
802
|
}
|
|
803
|
+
// Filter out navigation / submit / close buttons surfaced by the
|
|
804
|
+
// broader generic-button selector. Answer-choice buttons (Yes / No /
|
|
805
|
+
// short option text) stay — they're what powers Ashby's toggle pairs.
|
|
806
|
+
if (normalizeRole(f) === 'button' && f.tag === 'button') {
|
|
807
|
+
const ll = label.toLowerCase().trim();
|
|
808
|
+
// Drop common nav buttons by exact-ish label
|
|
809
|
+
const navWords = [
|
|
810
|
+
'back', 'cancel', 'close', 'menu', 'open menu', 'dismiss',
|
|
811
|
+
'submit', 'submit application', 'apply', 'apply now',
|
|
812
|
+
'sign in', 'sign up', 'log in', 'login', 'logout',
|
|
813
|
+
'next', 'previous', 'continue', // multi-step nav — filter; the form's own pagination handles
|
|
814
|
+
'edit', 'save', 'save and continue', 'save & continue',
|
|
815
|
+
'toggle theme', 'change theme', 'show password', 'hide password',
|
|
816
|
+
'view jd', 'view job', 'open application',
|
|
817
|
+
];
|
|
818
|
+
if (navWords.includes(ll)) continue;
|
|
819
|
+
// Drop submit-type buttons even without a matching label
|
|
820
|
+
if (f.inputType === 'submit') continue;
|
|
821
|
+
// Drop very long labels — answer choices are short. >80 chars is
|
|
822
|
+
// almost certainly help text, fineprint, or accidentally-captured
|
|
823
|
+
// descendant content.
|
|
824
|
+
if (label.length > 80) continue;
|
|
825
|
+
}
|
|
826
|
+
// Coalesce toggle-button group: rewrite this primary mmid into a single
|
|
827
|
+
// synthetic radio question so the planner sees ONE field with the real
|
|
828
|
+
// question as its label and ['Yes','No'] (or similar) as options.
|
|
829
|
+
const primary = primaries[f.mmid];
|
|
830
|
+
if (primary) {
|
|
831
|
+
out.push({
|
|
832
|
+
mmid: f.mmid,
|
|
833
|
+
role: 'radio',
|
|
834
|
+
label: primary.question,
|
|
835
|
+
description: '',
|
|
836
|
+
required: f.required,
|
|
837
|
+
disabled: f.disabled,
|
|
838
|
+
options: primary.options,
|
|
839
|
+
selectorHint: f.selectorHint,
|
|
840
|
+
inputType: 'radio',
|
|
841
|
+
currentValue: '',
|
|
842
|
+
isTypeahead: false,
|
|
843
|
+
groupLabel: primary.question,
|
|
844
|
+
textContent: primary.options.join(' / '),
|
|
845
|
+
filledAlready: false,
|
|
846
|
+
frameUrl: null,
|
|
847
|
+
// Signal to the executor: this is a toggle-button group, so
|
|
848
|
+
// set_radio should locate buttons by their visible text within
|
|
849
|
+
// the same _fieldEntry_ wrapper.
|
|
850
|
+
isToggleButtonGroup: true,
|
|
851
|
+
});
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
|
|
676
855
|
out.push({
|
|
677
856
|
mmid: f.mmid,
|
|
678
857
|
role: normalizeRole(f),
|
package/smartFill.js
CHANGED
|
@@ -224,6 +224,36 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
|
|
|
224
224
|
}).catch(() => null);
|
|
225
225
|
|
|
226
226
|
const groupRoot = groupId ? root.locator(`[data-halo-rg="${groupId}"]`) : root;
|
|
227
|
+
// If the scanner coalesced this from a toggle-button group (Ashby
|
|
228
|
+
// _yesno_), prefer a direct button-by-text lookup — these are plain
|
|
229
|
+
// <button> elements with no role=radio attribute.
|
|
230
|
+
if (field.isToggleButtonGroup) {
|
|
231
|
+
// Match buttons whose entire trimmed text equals the value (avoid
|
|
232
|
+
// matching descendants by accident).
|
|
233
|
+
const btn = groupRoot.locator('button').filter({ hasText: new RegExp(`^\\s*${v.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\s*$`, 'i') }).first();
|
|
234
|
+
if (await btn.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
235
|
+
await btn.click({ timeout: 1500 }).catch(async () => {
|
|
236
|
+
await btn.click({ force: true, timeout: 1500 });
|
|
237
|
+
});
|
|
238
|
+
await page.waitForTimeout(150);
|
|
239
|
+
// Verify via aria-pressed / aria-checked / explicit selected class.
|
|
240
|
+
const stuck = await btn.evaluate((el) => {
|
|
241
|
+
if (el.getAttribute('aria-pressed') === 'true') return true;
|
|
242
|
+
if (el.getAttribute('aria-checked') === 'true') return true;
|
|
243
|
+
const cls = String(el.className || '');
|
|
244
|
+
if (/selected|active|checked/i.test(cls)) return true;
|
|
245
|
+
// Also check the sibling hidden checkbox the widget controls.
|
|
246
|
+
const grp = el.closest('[class*="_yesno_"], [class*="_fieldEntry_"]');
|
|
247
|
+
if (grp) {
|
|
248
|
+
const hidden = grp.querySelector('input[type="checkbox"], input[type="hidden"]');
|
|
249
|
+
if (hidden && hidden.value && String(hidden.value).length > 0) return true;
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}).catch(() => true);
|
|
253
|
+
return { ok: true, reason: `toggle button: ${v}${stuck ? '' : ' (unverified)'}` };
|
|
254
|
+
}
|
|
255
|
+
// Fall through to the generic lookup below if direct button lookup missed.
|
|
256
|
+
}
|
|
227
257
|
// Within the group, find the option whose visible label matches value.
|
|
228
258
|
// Try role=radio, label, and a generic clickable row with the text.
|
|
229
259
|
const optInGroup = groupRoot.locator(
|
package/resolveLinkedIn.js
DELETED
|
@@ -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 };
|