atsfill 0.0.4.dev110__tar.gz → 0.0.4.dev111__tar.gz
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.
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/README.md +1 -1
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev111/src/atsfill/adapters/ukg.py +485 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/LICENSE +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev110 → atsfill-0.0.4.dev111}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev111
|
|
4
4
|
Summary: Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle.
|
|
5
5
|
Keywords: job-applications,ats,ashby,greenhouse,teamtailor,ai-agents,cli
|
|
6
6
|
Author: shbernal
|
|
@@ -57,7 +57,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
57
57
|
## Supported hiring systems
|
|
58
58
|
|
|
59
59
|
<!-- badges -->
|
|
60
|
-
                        
|
|
60
|
+
                         
|
|
61
61
|
<!-- /badges -->
|
|
62
62
|
|
|
63
63
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
                        
|
|
32
|
+
                         
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -19,6 +19,7 @@ from atsfill.adapters.recruitee import Recruitee
|
|
|
19
19
|
from atsfill.adapters.rippling import Rippling
|
|
20
20
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
21
21
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
22
|
+
from atsfill.adapters.ukg import UKG
|
|
22
23
|
from atsfill.adapters.workable import Workable
|
|
23
24
|
from atsfill.adapters.workday import Workday
|
|
24
25
|
from atsfill.adapters.wttj import WelcomeToTheJungle
|
|
@@ -53,6 +54,7 @@ def adapters() -> list[Adapter]:
|
|
|
53
54
|
HiringThing(),
|
|
54
55
|
Eightfold(),
|
|
55
56
|
Workday(),
|
|
57
|
+
UKG(),
|
|
56
58
|
Phenom(), # last: it claims a URL by its path alone
|
|
57
59
|
]
|
|
58
60
|
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""UKG Pro Recruiting (`recruiting[2].ultipro.com`, `recruiting.ultipro.ca`,
|
|
2
|
+
`<employer>.rec.pro.ukg.net`: `/<tenant>/JobBoard/<board>/OpportunityDetail?opportunityId=…`),
|
|
3
|
+
as docs/adapters/ukg.md observes.
|
|
4
|
+
|
|
5
|
+
Applying needs an account, one per tenant: the account scope. The form is one page, a
|
|
6
|
+
Knockout view of the posting, read from the page; the read-back is the page too.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import parse_qs, quote, urlsplit
|
|
15
|
+
|
|
16
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported
|
|
17
|
+
from atsfill.core.browser import Locator, Page, Request, Timeout
|
|
18
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, OptionSet, SideEffect
|
|
19
|
+
|
|
20
|
+
HOST = re.compile(r"^(recruiting\d*\.ultipro\.(com|ca)|[a-z0-9-]+\.rec\.pro\.ukg\.net)$")
|
|
21
|
+
PATH = re.compile(
|
|
22
|
+
r"^/(?P<tenant>[A-Za-z0-9]+)/JobBoard/(?P<board>[0-9a-fA-F-]{36})/Opportunity(Detail|Apply)$"
|
|
23
|
+
)
|
|
24
|
+
SIGN_IN_HOST = re.compile(r"^signin-[a-z0-9]+\.ultipro\.com$")
|
|
25
|
+
SESSION_COOKIE = ".AspNetCore.Cookies"
|
|
26
|
+
FORM = "#OpportunityApply"
|
|
27
|
+
READY = f"{FORM} [data-automation=btn-submit]"
|
|
28
|
+
FORM_TIMEOUT_MS = 30_000
|
|
29
|
+
SIGN_IN_TIMEOUT_S = 30
|
|
30
|
+
SIGN_IN_POLL_MS = 500
|
|
31
|
+
STATES_TIMEOUT_MS = 10_000
|
|
32
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
33
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
34
|
+
SETTLE_MS = 500
|
|
35
|
+
YES_NO = ["Yes", "No"]
|
|
36
|
+
DATE_SHOWN = re.compile(r"(\d\d)/(\d\d)/(\d{4})")
|
|
37
|
+
COUNTRY, STATE = "Country", "State"
|
|
38
|
+
DAYS, DOCUMENTS = "availability_days", "documents"
|
|
39
|
+
PHONE_GAP = "sign_up_phone"
|
|
40
|
+
|
|
41
|
+
# Auth0's error codes on the sign-in and sign-up pages, by what atsfill raises
|
|
42
|
+
ERRORS = [
|
|
43
|
+
(re.compile(r"wrong-(credentials|email-credentials)|invalid-(password|email-password)"),
|
|
44
|
+
Refused, "wrong_password"),
|
|
45
|
+
(re.compile(r"user-exists|email-in-use|already"), Refused, "email_in_use"),
|
|
46
|
+
(re.compile(r"password"), Refused, "password_rule"),
|
|
47
|
+
(re.compile(r"captcha"), Blocked, "captcha"),
|
|
48
|
+
(re.compile(r"too-many|blocked|rate"), Blocked, "rate_limited"),
|
|
49
|
+
] # fmt: skip
|
|
50
|
+
|
|
51
|
+
# Each visible field of the form, in page order: a control's id (a radio group's name), its
|
|
52
|
+
# label, how it renders, whether it is required, its options and value.
|
|
53
|
+
_READ = """root => {
|
|
54
|
+
const clean = s => (s || "").replace(/\\s+/g, " ").trim();
|
|
55
|
+
const shown = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) &&
|
|
56
|
+
!el.closest(".modal");
|
|
57
|
+
const labelOf = el => {
|
|
58
|
+
if (el.id) {
|
|
59
|
+
const l = root.querySelector(`label[for="${CSS.escape(el.id)}"]`);
|
|
60
|
+
if (l) return l;
|
|
61
|
+
}
|
|
62
|
+
const group = el.closest("[aria-labelledby]");
|
|
63
|
+
if (group) {
|
|
64
|
+
const l = document.getElementById(group.getAttribute("aria-labelledby").split(" ")[0]);
|
|
65
|
+
if (l) return l;
|
|
66
|
+
}
|
|
67
|
+
return el.closest(".form-group")?.querySelector("label");
|
|
68
|
+
};
|
|
69
|
+
const required = (el, label) => el.required || !!label?.classList.contains("required");
|
|
70
|
+
const out = [], seen = new Set();
|
|
71
|
+
for (const el of root.querySelectorAll("input, select, textarea")) {
|
|
72
|
+
const type = (el.type || "").toLowerCase();
|
|
73
|
+
if (["hidden", "file", "submit", "button"].includes(type) || !shown(el)) continue;
|
|
74
|
+
if (el.closest("ukg-input, ukg-input-container, [data-automation=upload-resume]")) continue;
|
|
75
|
+
const id = type === "radio" ? el.name : el.id || el.name || el.dataset.automation;
|
|
76
|
+
if (!id || seen.has(id)) continue;
|
|
77
|
+
seen.add(id);
|
|
78
|
+
const label = labelOf(el);
|
|
79
|
+
const field = {id, label: clean(label?.textContent), required: required(el, label),
|
|
80
|
+
kind: type, options: [], value: el.value,
|
|
81
|
+
readonly: el.readOnly || el.disabled, maxLength: el.maxLength > 0 ?
|
|
82
|
+
el.maxLength : null};
|
|
83
|
+
if (el.tagName === "SELECT") {
|
|
84
|
+
field.kind = "select";
|
|
85
|
+
field.options = [...el.options].filter(o => o.value).map(o => clean(o.textContent));
|
|
86
|
+
field.value = el.value ? clean(el.selectedOptions[0]?.textContent) : "";
|
|
87
|
+
} else if (el.tagName === "TEXTAREA") field.kind = "text";
|
|
88
|
+
else if (type === "radio") {
|
|
89
|
+
const all = [...root.querySelectorAll(`input[type=radio][name="${CSS.escape(id)}"]`)];
|
|
90
|
+
field.options = all.map(r => clean(r.closest("label")?.textContent));
|
|
91
|
+
field.required = all.some(r => r.required) || field.required;
|
|
92
|
+
field.value = clean(all.find(r => r.checked)?.closest("label")?.textContent);
|
|
93
|
+
} else if (type === "checkbox") {
|
|
94
|
+
field.label = clean(el.closest("label")?.textContent) || field.label;
|
|
95
|
+
field.value = el.checked ? "Yes" : "No";
|
|
96
|
+
} else if (type === "tel") field.kind = "phone";
|
|
97
|
+
else if (["text", "email", "number", ""].includes(type)) field.kind = "text";
|
|
98
|
+
out.push(field);
|
|
99
|
+
}
|
|
100
|
+
for (const box of root.querySelectorAll("ukg-input[type=date]")) {
|
|
101
|
+
if (!shown(box)) continue;
|
|
102
|
+
const bridge = box.closest("react-ko-es6-bridge") || box.parentElement;
|
|
103
|
+
const named = bridge.querySelector("[id]");
|
|
104
|
+
const id = named?.id;
|
|
105
|
+
if (!id || seen.has(id)) continue;
|
|
106
|
+
seen.add(id);
|
|
107
|
+
const label = bridge.closest(".form-group")?.querySelector("label") || named;
|
|
108
|
+
out.push({id, label: clean(label.textContent), kind: "date",
|
|
109
|
+
required: box.getAttribute("required") === "true" ||
|
|
110
|
+
!!label.classList?.contains("required"),
|
|
111
|
+
options: [], value: box.getAttribute("value") || ""});
|
|
112
|
+
}
|
|
113
|
+
const days = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown);
|
|
114
|
+
if (days.length) {
|
|
115
|
+
const label = document.getElementById("AvailabilityDaysQuestion");
|
|
116
|
+
out.push({id: "availability_days", kind: "days", required: false,
|
|
117
|
+
label: clean(label?.textContent) || "Which days are you available to work?",
|
|
118
|
+
options: days.map(d => clean(d.textContent)),
|
|
119
|
+
value: days.filter(d => d.classList.contains("selected"))
|
|
120
|
+
.map(d => clean(d.textContent))});
|
|
121
|
+
}
|
|
122
|
+
const upload = [...root.querySelectorAll("form[action*='/Documents/UploadFile']")]
|
|
123
|
+
.find(f => shown(f.closest(".panel, section, div") || f));
|
|
124
|
+
if (upload) {
|
|
125
|
+
const input = upload.querySelector("input[type=file]");
|
|
126
|
+
out.push({id: "documents", kind: "file", label: "Documents", required: false,
|
|
127
|
+
options: [], accept: (input?.accept || "").split(",").filter(Boolean),
|
|
128
|
+
value: ""});
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}"""
|
|
132
|
+
|
|
133
|
+
# What each listed field shows now, by its kind.
|
|
134
|
+
_READ_BACK = """(root, kinds) => {
|
|
135
|
+
const clean = s => (s || "").replace(/\\s+/g, " ").trim();
|
|
136
|
+
const shown = el => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
|
137
|
+
const out = {};
|
|
138
|
+
for (const [id, kind] of Object.entries(kinds)) {
|
|
139
|
+
if (kind === "days") {
|
|
140
|
+
out[id] = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown)
|
|
141
|
+
.filter(d => d.classList.contains("selected")).map(d => clean(d.textContent));
|
|
142
|
+
} else if (kind === "file") {
|
|
143
|
+
out[id] = [...root.querySelectorAll("[data-automation=file-name]")].filter(shown)
|
|
144
|
+
.map(f => clean([...f.querySelectorAll("[id^=FileName], [data-automation=file-link]")]
|
|
145
|
+
.filter(shown).map(e => e.textContent).join(""))).filter(Boolean).pop() || null;
|
|
146
|
+
} else if (kind === "date") {
|
|
147
|
+
const named = root.querySelector(`[id="${CSS.escape(id)}"]`);
|
|
148
|
+
const box = named?.closest("react-ko-es6-bridge, div")?.querySelector("ukg-input");
|
|
149
|
+
out[id] = box?.getAttribute("value") || null;
|
|
150
|
+
} else if (kind === "radio") {
|
|
151
|
+
const checked = [...root.querySelectorAll(`input[type=radio][name="${CSS.escape(id)}"]`)]
|
|
152
|
+
.find(r => r.checked);
|
|
153
|
+
out[id] = checked ? clean(checked.closest("label")?.textContent) : null;
|
|
154
|
+
} else {
|
|
155
|
+
const el = [...root.querySelectorAll(`[id="${CSS.escape(id)}"], [name="${CSS.escape(id)}"]`)]
|
|
156
|
+
.find(shown);
|
|
157
|
+
if (!el) continue;
|
|
158
|
+
if (kind === "select") out[id] = el.value ? clean(el.selectedOptions[0]?.textContent) : null;
|
|
159
|
+
else if (kind === "checkbox") out[id] = el.checked ? "Yes" : "No";
|
|
160
|
+
else out[id] = el.value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}"""
|
|
165
|
+
|
|
166
|
+
# Each country's states from the board's states URL, by the country's name.
|
|
167
|
+
_STATES = """async (root, url) => {
|
|
168
|
+
const select = root.querySelector("select#Country");
|
|
169
|
+
const countries = [...(select?.options || [])].filter(o => o.value);
|
|
170
|
+
const lists = await Promise.all(countries.map(async o => {
|
|
171
|
+
const response = await fetch(`${url}?countryId=${o.value}`, {credentials: "include"});
|
|
172
|
+
return response.ok ? await response.json() : [];
|
|
173
|
+
}));
|
|
174
|
+
return Object.fromEntries(countries.map((o, i) => [
|
|
175
|
+
o.textContent.replace(/\\s+/g, " ").trim(), (lists[i] || []).map(s => s.Name)]));
|
|
176
|
+
}"""
|
|
177
|
+
|
|
178
|
+
KINDS = {
|
|
179
|
+
"text": FieldType.TEXT,
|
|
180
|
+
"phone": FieldType.PHONE,
|
|
181
|
+
"select": FieldType.CHOICE,
|
|
182
|
+
"radio": FieldType.CHOICE,
|
|
183
|
+
"checkbox": FieldType.CHOICE,
|
|
184
|
+
"date": FieldType.DATE,
|
|
185
|
+
"days": FieldType.MULTI_CHOICE,
|
|
186
|
+
"file": FieldType.FILE,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class Board:
|
|
192
|
+
"""A tenant's job board, where its postings and its sign-in are served."""
|
|
193
|
+
|
|
194
|
+
host: str
|
|
195
|
+
tenant: str # lowercase
|
|
196
|
+
board: str
|
|
197
|
+
opportunity: str
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def base(self) -> str:
|
|
201
|
+
return f"https://{self.host}/{self.tenant}/JobBoard/{self.board}"
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def apply(self) -> str:
|
|
205
|
+
return f"{self.base}/OpportunityApply?opportunityId={self.opportunity}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def board(url: str) -> Board | None:
|
|
209
|
+
parts = urlsplit(url)
|
|
210
|
+
host = (parts.hostname or "").lower()
|
|
211
|
+
match = PATH.match(parts.path)
|
|
212
|
+
opportunity = parse_qs(parts.query).get("opportunityId", [""])[0].lower()
|
|
213
|
+
if not HOST.match(host) or not match or not opportunity:
|
|
214
|
+
return None
|
|
215
|
+
return Board(host, match["tenant"].lower(), match["board"].lower(), opportunity)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class UKG:
|
|
219
|
+
name = "ukg"
|
|
220
|
+
per_employer = True
|
|
221
|
+
asks_name = True
|
|
222
|
+
known_gaps = {PHONE_GAP: Gap.FLOW}
|
|
223
|
+
side_effects = [
|
|
224
|
+
SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
|
|
225
|
+
SideEffect("upload", "hiring_system", "a document is stored in the account's documents"),
|
|
226
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
def __init__(self) -> None:
|
|
230
|
+
self._boards: dict[str, Board] = {} # by tenant, the last posting's
|
|
231
|
+
self._kinds: dict[str, str] = {}
|
|
232
|
+
|
|
233
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
234
|
+
return board(url) is not None
|
|
235
|
+
|
|
236
|
+
def posting(self, url: str) -> str:
|
|
237
|
+
found = board(url)
|
|
238
|
+
return f"{found.base}/OpportunityDetail?opportunityId={found.opportunity}" if found else url
|
|
239
|
+
|
|
240
|
+
def account_scope(self, url: str) -> str | None:
|
|
241
|
+
found = board(url)
|
|
242
|
+
if found is None:
|
|
243
|
+
return None
|
|
244
|
+
self._boards[found.tenant] = found
|
|
245
|
+
return found.tenant
|
|
246
|
+
|
|
247
|
+
def login_url(self, scope: str | None) -> str:
|
|
248
|
+
"""The board's sign-in, back to the last posting's form once signed in."""
|
|
249
|
+
if scope not in self._boards:
|
|
250
|
+
raise ValueError(f"no UKG posting seen for the tenant {scope!r}")
|
|
251
|
+
found = self._boards[scope]
|
|
252
|
+
back = urlsplit(found.apply)
|
|
253
|
+
return f"{found.base}/Account/Login?redirectUrl={quote(f'{back.path}?{back.query}')}"
|
|
254
|
+
|
|
255
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
256
|
+
"""The posting's form loads, instead of redirecting to the sign-in or to the tenant's
|
|
257
|
+
name step; without a posting
|
|
258
|
+
seen, the tenant's session cookie is there and unexpired."""
|
|
259
|
+
if scope in self._boards:
|
|
260
|
+
response = request.get(self._boards[scope].apply)
|
|
261
|
+
return response.ok and urlsplit(response.url).path.endswith("/OpportunityApply")
|
|
262
|
+
cookies = request.storage_state()["cookies"]
|
|
263
|
+
return any(
|
|
264
|
+
c["name"] == SESSION_COOKIE and HOST.match(c["domain"].lstrip("."))
|
|
265
|
+
and (c["expires"] < 0 or c["expires"] > time.time())
|
|
266
|
+
for c in cookies
|
|
267
|
+
) # fmt: skip
|
|
268
|
+
|
|
269
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
270
|
+
page.locator("input#username").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
271
|
+
page.locator("input#username").fill(email)
|
|
272
|
+
page.locator("input#password").fill(password)
|
|
273
|
+
page.locator("button[type=submit][name=action][data-action-button-primary]").click()
|
|
274
|
+
_left_sign_in(page)
|
|
275
|
+
if "/AuthCode/Register" in page.url:
|
|
276
|
+
raise Unsupported("unhandled_rendering: the tenant asks the account's name")
|
|
277
|
+
|
|
278
|
+
def sign_up(self, page: Page, email: str, password: str, name: str | None) -> None:
|
|
279
|
+
page.locator("a[href*='/u/signup']").first.click()
|
|
280
|
+
page.locator("input#email").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
281
|
+
page.locator("input#email").fill(email)
|
|
282
|
+
page.locator("input#password").fill(password)
|
|
283
|
+
page.locator("button[type=submit][name=action][data-action-button-primary]").click()
|
|
284
|
+
_left_sign_in(page)
|
|
285
|
+
if "/AuthCode/Register" not in page.url:
|
|
286
|
+
return
|
|
287
|
+
_register(page, name or "")
|
|
288
|
+
|
|
289
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
290
|
+
found = board(page.url)
|
|
291
|
+
if found is None or not urlsplit(page.url).path.endswith("/OpportunityApply"):
|
|
292
|
+
found = found or next(iter(self._boards.values()), None)
|
|
293
|
+
if found is None:
|
|
294
|
+
raise Unsupported("unhandled_rendering: not a UKG posting page")
|
|
295
|
+
page.goto(found.apply)
|
|
296
|
+
if SIGN_IN_HOST.match(urlsplit(page.url).hostname or ""):
|
|
297
|
+
raise Blocked("login_wall: the form redirects to the sign-in")
|
|
298
|
+
try:
|
|
299
|
+
page.locator(READY).wait_for(state="attached", timeout=FORM_TIMEOUT_MS)
|
|
300
|
+
except Timeout:
|
|
301
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
302
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
303
|
+
raws = page.locator(FORM).evaluate(_READ)
|
|
304
|
+
required_resume = '"IsResumeRequired":true' in page.content()
|
|
305
|
+
states = None
|
|
306
|
+
if page.locator(f"{FORM} select#{STATE}").count():
|
|
307
|
+
states = page.locator(FORM).evaluate(_STATES, f"{found.base}/Candidate/GetStates")
|
|
308
|
+
if not any(r["id"] == STATE for r in raws): # shown once a country is picked
|
|
309
|
+
label = page.locator(f"{FORM} label[for={STATE}]").first
|
|
310
|
+
at = next((i + 1 for i, r in enumerate(raws) if r["id"] == "City"), len(raws))
|
|
311
|
+
raws.insert(at, {
|
|
312
|
+
"id": STATE, "label": " ".join((label.text_content() or "").split()),
|
|
313
|
+
"kind": "select", "options": [], "value": "",
|
|
314
|
+
"required": "required" in (label.get_attribute("class") or ""),
|
|
315
|
+
}) # fmt: skip
|
|
316
|
+
self._kinds = {raw["id"]: raw["kind"] for raw in raws}
|
|
317
|
+
fields = []
|
|
318
|
+
for raw in raws:
|
|
319
|
+
if raw["id"] == DOCUMENTS:
|
|
320
|
+
raw["required"] = required_resume
|
|
321
|
+
fields.append(_field(raw, states))
|
|
322
|
+
return fields
|
|
323
|
+
|
|
324
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
325
|
+
for field_id, kind in self._kinds.items():
|
|
326
|
+
if field_id not in answers:
|
|
327
|
+
continue
|
|
328
|
+
answer = answers[field_id]
|
|
329
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
330
|
+
root = page.locator(FORM)
|
|
331
|
+
match kind:
|
|
332
|
+
case "select":
|
|
333
|
+
control = _control(root, field_id)
|
|
334
|
+
control.select_option(label=values[0])
|
|
335
|
+
if field_id == COUNTRY:
|
|
336
|
+
_wait_for_states(page)
|
|
337
|
+
case "radio":
|
|
338
|
+
_option(root, field_id, values[0]).check()
|
|
339
|
+
case "checkbox":
|
|
340
|
+
_control(root, field_id).set_checked(values[0] == "Yes")
|
|
341
|
+
case "date":
|
|
342
|
+
_date(root, field_id, values[0])
|
|
343
|
+
case "days":
|
|
344
|
+
for day in root.locator("[data-automation=day-btn]:visible").all():
|
|
345
|
+
text = (day.text_content() or "").strip()
|
|
346
|
+
if ("selected" in (day.get_attribute("class") or "")) != (text in values):
|
|
347
|
+
day.click()
|
|
348
|
+
case "file":
|
|
349
|
+
_upload(page, values[0])
|
|
350
|
+
case _:
|
|
351
|
+
_control(root, field_id).fill(values[0])
|
|
352
|
+
|
|
353
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
354
|
+
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
355
|
+
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
356
|
+
|
|
357
|
+
def submit(self, page: Page) -> str | None:
|
|
358
|
+
"""Clicks Submit; the application posts and the page moves to ApplicationSubmitted,
|
|
359
|
+
the evidence. A tenant may have Cloudflare Turnstile check the visitor first."""
|
|
360
|
+
page.locator(READY).click()
|
|
361
|
+
try:
|
|
362
|
+
page.wait_for_url(re.compile(r"/ApplicationSubmitted"), timeout=SUBMIT_TIMEOUT_MS)
|
|
363
|
+
except Timeout:
|
|
364
|
+
if page.locator("iframe[src*='challenges.cloudflare.com']").is_visible():
|
|
365
|
+
raise Blocked("captcha: Cloudflare Turnstile asked for a challenge") from None
|
|
366
|
+
raise
|
|
367
|
+
return page.url
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _left_sign_in(page: Page) -> None:
|
|
371
|
+
"""Waits to leave the sign-in host, or for the error the page shows."""
|
|
372
|
+
deadline = time.monotonic() + SIGN_IN_TIMEOUT_S
|
|
373
|
+
said = page.locator(".ulp-input-error-message:visible, #prompt-alert:visible, "
|
|
374
|
+
"[id^=error-element-]:visible") # fmt: skip
|
|
375
|
+
while time.monotonic() < deadline:
|
|
376
|
+
host = urlsplit(page.url).hostname or ""
|
|
377
|
+
path = urlsplit(page.url).path
|
|
378
|
+
if not SIGN_IN_HOST.match(host):
|
|
379
|
+
page.wait_for_load_state()
|
|
380
|
+
return
|
|
381
|
+
if re.search(r"/u/(mfa|email-verification|login-email-verification)", path):
|
|
382
|
+
raise Blocked("email_otp: the sign-in asks for a code sent by email")
|
|
383
|
+
if page.locator("[data-captcha-provider]:visible, .ulp-captcha:visible").count():
|
|
384
|
+
raise Blocked("captcha: the sign-in page shows a captcha")
|
|
385
|
+
if said.count():
|
|
386
|
+
text = " ".join(said.all_inner_texts()).strip()
|
|
387
|
+
codes = (
|
|
388
|
+
" ".join(filter(None, [e.get_attribute("data-error-code") for e in said.all()]))
|
|
389
|
+
+ " "
|
|
390
|
+
+ " ".join(filter(None, [e.get_attribute("id") for e in said.all()]))
|
|
391
|
+
)
|
|
392
|
+
for pattern, error, code in ERRORS:
|
|
393
|
+
if pattern.search(codes.lower()) or pattern.search(text.lower()):
|
|
394
|
+
raise error(f"{code}: {text}")
|
|
395
|
+
raise Unsupported(f"unhandled_rendering: the sign-in page says {text!r}")
|
|
396
|
+
page.wait_for_timeout(SIGN_IN_POLL_MS)
|
|
397
|
+
raise Unsupported("unhandled_rendering: the sign-in page answered nothing")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _register(page: Page, name: str) -> None:
|
|
401
|
+
"""The tenant's own step after the sign-up: the name, and the privacy policy's box on some
|
|
402
|
+
tenants; the box for text messages is left. Its inputs take typed keys only: a filled value
|
|
403
|
+
leaves Create account disabled."""
|
|
404
|
+
page.locator("input[name=firstName]").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
405
|
+
page.wait_for_load_state("networkidle")
|
|
406
|
+
if page.locator("ukg-input[name=phoneNumber][required=true]").count():
|
|
407
|
+
raise Unsupported(f"{Gap.FLOW}: {PHONE_GAP}: the tenant's sign-up asks a phone number")
|
|
408
|
+
first, _, last = name.strip().partition(" ")
|
|
409
|
+
for field, value in (("firstName", first), ("lastName", last or first)):
|
|
410
|
+
page.locator(f"input[name={field}]").click()
|
|
411
|
+
page.locator(f"input[name={field}]").press_sequentially(value)
|
|
412
|
+
privacy = page.locator("ukg-checkbox[data-automation*=privacy]:visible")
|
|
413
|
+
for box in privacy.all():
|
|
414
|
+
if box.get_attribute("aria-checked") != "true":
|
|
415
|
+
box.click()
|
|
416
|
+
page.get_by_role("button", name="Create account").click()
|
|
417
|
+
page.wait_for_url(lambda u: "/AuthCode/Register" not in u, timeout=FORM_TIMEOUT_MS)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _field(raw: dict[str, Any], states: dict[str, list[str]] | None) -> Field:
|
|
421
|
+
kind, field_id = raw["kind"], raw["id"]
|
|
422
|
+
label = raw["label"] or field_id
|
|
423
|
+
if kind not in KINDS:
|
|
424
|
+
reason = f"unhandled_rendering: {field_id} is a {kind} input"
|
|
425
|
+
return Field(field_id, label, FieldType.TEXT, raw["required"], unfillable=reason)
|
|
426
|
+
value = raw.get("value") or None
|
|
427
|
+
if kind == "checkbox":
|
|
428
|
+
return Field(field_id, label, FieldType.CHOICE, raw["required"], YES_NO, default="No")
|
|
429
|
+
if kind == "days":
|
|
430
|
+
return Field(field_id, label, FieldType.MULTI_CHOICE, False, raw["options"])
|
|
431
|
+
if kind == "file":
|
|
432
|
+
return Field(field_id, label, FieldType.FILE, raw["required"], accept=raw["accept"])
|
|
433
|
+
if raw.get("readonly"): # the account's name, changed on its profile only
|
|
434
|
+
reason = f"unsupported_rendering: {field_id} is read-only" if not value else None
|
|
435
|
+
return Field(field_id, label, KINDS[kind], raw["required"], prefilled=value,
|
|
436
|
+
unfillable=reason) # fmt: skip
|
|
437
|
+
options = raw["options"] if kind in ("select", "radio") else None
|
|
438
|
+
if field_id == STATE and states is not None:
|
|
439
|
+
sets = [OptionSet(Is(COUNTRY, [country]), names) for country, names in states.items()]
|
|
440
|
+
return Field(field_id, label, FieldType.CHOICE, raw["required"], [], option_sets=sets)
|
|
441
|
+
return Field(
|
|
442
|
+
field_id, label, KINDS[kind], raw["required"], options, raw.get("maxLength"),
|
|
443
|
+
default=value if kind in ("date", "select") else None,
|
|
444
|
+
) # fmt: skip
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _wait_for_states(page: Page) -> None:
|
|
448
|
+
with suppress(Timeout): # a country without states
|
|
449
|
+
page.wait_for_function(
|
|
450
|
+
"() => document.querySelectorAll('#OpportunityApply select#State option').length > 1",
|
|
451
|
+
timeout=STATES_TIMEOUT_MS,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _control(root: Locator, field_id: str) -> Locator:
|
|
456
|
+
return root.locator(f'[id="{field_id}"]:visible, [name="{field_id}"]:visible').first
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _option(root: Locator, name: str, text: str) -> Locator:
|
|
460
|
+
for radio in root.locator(f'input[type=radio][name="{name}"]').all():
|
|
461
|
+
label = radio.locator("xpath=ancestor::label[1]").text_content() or ""
|
|
462
|
+
if " ".join(label.split()) == text:
|
|
463
|
+
return radio
|
|
464
|
+
raise NotTaken(f"no option {text!r} for {name}")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _date(root: Locator, field_id: str, value: str) -> None:
|
|
468
|
+
"""Types the date into the picker's month, day and year inputs."""
|
|
469
|
+
year, month, day = value.split("-")
|
|
470
|
+
bridge = root.locator(f'[id="{field_id}"]').locator("xpath=ancestor::react-ko-es6-bridge[1]")
|
|
471
|
+
for part, typed in (("month", month), ("day", day), ("year", year)):
|
|
472
|
+
segment = bridge.locator(f"[data-automation=ukg-date-input-text-{part}]").first
|
|
473
|
+
segment.click()
|
|
474
|
+
segment.press("Control+a")
|
|
475
|
+
segment.press_sequentially(typed)
|
|
476
|
+
bridge.locator("[data-automation=ukg-date-input-text-year]").first.press("Tab")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _upload(page: Page, path: str) -> None:
|
|
480
|
+
form = page.locator(f"{FORM} form[action*='/Documents/UploadFile']").first
|
|
481
|
+
name = path.rsplit("/", 1)[-1]
|
|
482
|
+
form.locator("input[type=file]").set_input_files(path)
|
|
483
|
+
page.locator(f"{FORM} [data-automation=file-name]:visible", has_text=name).first.wait_for(
|
|
484
|
+
timeout=UPLOAD_TIMEOUT_MS
|
|
485
|
+
)
|
|
@@ -102,7 +102,9 @@ kind = "ats"
|
|
|
102
102
|
share = 0.0349
|
|
103
103
|
account = "to_read"
|
|
104
104
|
hosting = ["hosted"]
|
|
105
|
+
adapter = "ukg"
|
|
105
106
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
107
|
+
unsupported = { date = 2026-10-26, version = "0.0.3", commit = "a60a17d", by = "agent", reason = "unsupported_flow: sign_up_phone: the tenant's details step after the sign-up requires a phone number, which login does not take (10 of 17 tenants)" }
|
|
106
108
|
|
|
107
109
|
[paycom]
|
|
108
110
|
kind = "ats"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|