atsfill 0.0.4.dev132__tar.gz → 0.0.4.dev134__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.dev132 → atsfill-0.0.4.dev134}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/README.md +1 -1
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/successfactors.js +3 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/successfactors.py +67 -14
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/ukg.py +82 -18
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/accounts.py +9 -4
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/adapter.py +11 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/commands.py +18 -6
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/registry.toml +1 -1
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/LICENSE +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev132 → atsfill-0.0.4.dev134}/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.dev134
|
|
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
|
-
                                                       
|
|
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
|
-
                                                       
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
d.kind = 'picklist';
|
|
27
27
|
d.id = picklist.id;
|
|
28
28
|
d.owns = picklist.getAttribute('aria-owns');
|
|
29
|
+
d.placeholder = picklist.placeholder; // its empty item's text, in the page's language
|
|
30
|
+
const opener = box.querySelector('button.rcmpaginatedselectbutton');
|
|
31
|
+
d.disabled = !!opener && opener.disabled; // a cascading list before its parent is chosen
|
|
29
32
|
d.value = picklist.value === picklist.placeholder ? '' : picklist.value;
|
|
30
33
|
d.required = d.required || picklist.getAttribute('aria-required') === 'true';
|
|
31
34
|
} else if (radios.length) {
|
|
@@ -11,7 +11,7 @@ import re
|
|
|
11
11
|
import time
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any
|
|
14
|
-
from urllib.parse import urlsplit
|
|
14
|
+
from urllib.parse import parse_qs, urlsplit
|
|
15
15
|
|
|
16
16
|
from atsfill.core.adapter import (
|
|
17
17
|
Blocked,
|
|
@@ -22,7 +22,7 @@ from atsfill.core.adapter import (
|
|
|
22
22
|
Unsupported,
|
|
23
23
|
known_gap,
|
|
24
24
|
)
|
|
25
|
-
from atsfill.core.browser import Locator, Page, Request, Timeout
|
|
25
|
+
from atsfill.core.browser import Locator, Page, PageRequest, Request, Timeout
|
|
26
26
|
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
27
27
|
|
|
28
28
|
JOB = re.compile(r"^(?:/[^/]+)?/job/[^/]+/(?P<id>\d{5,})/?$")
|
|
@@ -30,13 +30,19 @@ DC_HOST = re.compile(r"^career\d*\.(?:sapsf|successfactors)\.(?:eu|com|cn)$")
|
|
|
30
30
|
APPLY = "a[href*='/talentcommunity/apply/']"
|
|
31
31
|
COMPANY_COOKIE = "careerSiteCompanyId"
|
|
32
32
|
PROFILE = "/career?company={company}&career_ns=job_listing_summary&navBarLevel=MY_PROFILE"
|
|
33
|
-
|
|
33
|
+
JOB_REQ = "career_job_req_id" # the job the apply link posts, the candidate pages' own id
|
|
34
|
+
APPLICATION = "/career?company={company}&career_ns=job_application&career_job_req_id={req}"
|
|
35
|
+
GUIDED = "body.cxApplyEnabled" # a site whose application is a step-by-step one, not sections
|
|
36
|
+
HOME = "body.careerUserLoggedIn" # any candidate page signed in, the candidate home's too
|
|
37
|
+
# The body's class for an account; a signed-out page also holds a log-out link and its label
|
|
38
|
+
SIGNED_IN = re.compile(r"<body[^>]*\bclass=[\"'][^\"']*\bcareerUserLoggedIn\b")
|
|
34
39
|
FIELDS = (Path(__file__).with_suffix(".js")).read_text()
|
|
35
40
|
# What the candidate pages show once loaded
|
|
36
41
|
SIGN_IN = "#username"
|
|
37
42
|
COUNTRY = "fbclc_country" # its options' values are ISO 3166 alpha-2 codes
|
|
38
43
|
PHONE, CALLING_CODE = "#fbclc_phoneNumber", "#fbclc_ituCode" # `CA`: `Canada (+1)`
|
|
39
44
|
SIGN_UP = "#fbclc_userName"
|
|
45
|
+
PASSCODE = "#passcode" # an OTP some sign-ups ask before the account exists
|
|
40
46
|
QUICK_APPLY = "#fbqa_apply"
|
|
41
47
|
FORM = ".RCMFormField"
|
|
42
48
|
LANDED = f"{SIGN_IN}, {SIGN_UP}, {FORM}"
|
|
@@ -47,6 +53,8 @@ SIGN_IN_MODAL = "a[onclick*=openSignInModal]" # a Quick Apply page's
|
|
|
47
53
|
REFUSAL = re.compile(r"required|exist|already|invalid|bereits|erforderlich|ungültig", re.I)
|
|
48
54
|
ACTIVATE = re.compile(r"activat|verify your|verification e-?mail|bestätig", re.I)
|
|
49
55
|
PRIVACY = "#dataPrivacyId"
|
|
56
|
+
VISIBILITY = "input[name=fbclc_searPref]" # who may see the profile
|
|
57
|
+
APPLIED_ONLY = "2" # its option for only the recruiters of the jobs applied to
|
|
50
58
|
EXPAND = "[id$=_expandAllSections]"
|
|
51
59
|
SUBMIT = "[id$=_submitBtn]"
|
|
52
60
|
OVERLAY = "#sfOverlayMgr" # a confirmation dialog
|
|
@@ -88,6 +96,15 @@ def _company(request: Request) -> list[tuple[str, str]]:
|
|
|
88
96
|
)
|
|
89
97
|
|
|
90
98
|
|
|
99
|
+
def _posts_job(request: PageRequest) -> bool:
|
|
100
|
+
"""Whether the apply link's request posts a job to the candidate pages."""
|
|
101
|
+
return (
|
|
102
|
+
request.method == "POST"
|
|
103
|
+
and bool(DC_HOST.match(urlsplit(request.url).hostname or ""))
|
|
104
|
+
and JOB_REQ in (request.post_data or "")
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
91
108
|
def _calling_code(select: Locator, details: SignUpDetails) -> tuple[str, str]:
|
|
92
109
|
"""The calling code's option for the E.164 phone, the one of `country` among the countries
|
|
93
110
|
sharing it, and the national number that follows the code."""
|
|
@@ -108,7 +125,10 @@ class SuccessFactors:
|
|
|
108
125
|
name = "successfactors"
|
|
109
126
|
per_employer = True
|
|
110
127
|
asks = {"name"}
|
|
111
|
-
known_gaps = {
|
|
128
|
+
known_gaps = {
|
|
129
|
+
"quick_apply_sign_up": Gap.FLOW,
|
|
130
|
+
"guided_application": Gap.FLOW,
|
|
131
|
+
}
|
|
112
132
|
side_effects = [
|
|
113
133
|
SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
|
|
114
134
|
SideEffect("fill", "hiring_system", "an attachment is uploaded to the account's profile"),
|
|
@@ -140,7 +160,7 @@ class SuccessFactors:
|
|
|
140
160
|
return self._postings.get(scope or "", f"https://{scope}/")
|
|
141
161
|
|
|
142
162
|
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
143
|
-
"""The profile page of the session's company
|
|
163
|
+
"""The profile page of the session's company marks its body for an account."""
|
|
144
164
|
for host, company in _company(request):
|
|
145
165
|
response = request.get(f"https://{host}{PROFILE.format(company=company)}")
|
|
146
166
|
if response.ok and SIGNED_IN.search(response.text()):
|
|
@@ -165,8 +185,9 @@ class SuccessFactors:
|
|
|
165
185
|
|
|
166
186
|
def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
|
|
167
187
|
"""Creates the account from the career site's sign-up: the name, the country of
|
|
168
|
-
residence from `country` (the list's first without it),
|
|
169
|
-
requires one, its calling code picked from the number
|
|
188
|
+
residence from `country` (the list's first without it), the phone where the site
|
|
189
|
+
requires one, its calling code picked from the number, and the profile visible only to
|
|
190
|
+
the recruiters of the jobs applied to where the site asks; a missing phone is refused
|
|
170
191
|
before anything is sent."""
|
|
171
192
|
self._candidate(page)
|
|
172
193
|
if page.locator(SIGN_IN).count():
|
|
@@ -206,6 +227,11 @@ class SuccessFactors:
|
|
|
206
227
|
if wanted and wanted not in values:
|
|
207
228
|
raise Unsupported(f"unhandled_rendering: the sign-up's countries lack {wanted}")
|
|
208
229
|
select.select_option(wanted or values[0])
|
|
230
|
+
if page.locator(f"{VISIBILITY}:visible").count():
|
|
231
|
+
least = page.locator(f"{VISIBILITY}[value='{APPLIED_ONLY}']")
|
|
232
|
+
if not least.count():
|
|
233
|
+
raise Unsupported("unhandled_rendering: the profile's visibility has no jobs only")
|
|
234
|
+
least.check()
|
|
209
235
|
if page.locator(PRIVACY).count():
|
|
210
236
|
page.locator(PRIVACY).click()
|
|
211
237
|
dialog = page.locator("[role=dialog]:visible").last
|
|
@@ -216,6 +242,8 @@ class SuccessFactors:
|
|
|
216
242
|
page.wait_for_timeout(POLL_MS)
|
|
217
243
|
if page.locator(FORM).count():
|
|
218
244
|
return
|
|
245
|
+
if page.locator(f"{PASSCODE}:visible").count():
|
|
246
|
+
raise Blocked("email_otp: the sign-up sent a passcode to the email")
|
|
219
247
|
if not page.locator(f"{SIGN_UP}:visible").count():
|
|
220
248
|
break
|
|
221
249
|
if self._message(page, REFUSAL):
|
|
@@ -226,7 +254,7 @@ class SuccessFactors:
|
|
|
226
254
|
if re.search(r"password|kennwort", message, re.IGNORECASE):
|
|
227
255
|
raise Refused(f"password_rule: {message}")
|
|
228
256
|
if message:
|
|
229
|
-
raise Unsupported(
|
|
257
|
+
raise Unsupported(f"unhandled_rendering: the sign-up refused: {message}")
|
|
230
258
|
text = page.locator("body").inner_text()
|
|
231
259
|
if ACTIVATE.search(text):
|
|
232
260
|
raise Blocked("email_link: the sign-up sent an activation email")
|
|
@@ -249,8 +277,8 @@ class SuccessFactors:
|
|
|
249
277
|
page.wait_for_timeout(SETTLE_MS)
|
|
250
278
|
fields = []
|
|
251
279
|
for d in page.evaluate(FIELDS):
|
|
252
|
-
if d["kind"] == "picklist":
|
|
253
|
-
d["options"] = self._options(page, d)
|
|
280
|
+
if d["kind"] == "picklist": # a disabled one waits for its parent: no items yet
|
|
281
|
+
d["options"] = [] if d["disabled"] else self._options(page, d)
|
|
254
282
|
self._fields[d["key"]] = d
|
|
255
283
|
fields.append(self._field(d))
|
|
256
284
|
return fields
|
|
@@ -288,7 +316,7 @@ class SuccessFactors:
|
|
|
288
316
|
items = self._open(page, d)
|
|
289
317
|
options = [t for t in items.evaluate_all("as => as.map(a => a.title.trim())") if t]
|
|
290
318
|
self._close(page, d)
|
|
291
|
-
return [o for o in options if o
|
|
319
|
+
return [o for o in options if o not in (NO_SELECTION, d["placeholder"])]
|
|
292
320
|
|
|
293
321
|
def _open(self, page: Page, d: dict[str, Any]) -> Locator:
|
|
294
322
|
"""Opens a picklist; a click can go unheard, or close a list another left open."""
|
|
@@ -343,6 +371,8 @@ class SuccessFactors:
|
|
|
343
371
|
self._upload(page, d, value)
|
|
344
372
|
|
|
345
373
|
def _pick(self, page: Page, d: dict[str, Any], value: str) -> None:
|
|
374
|
+
if d["disabled"]:
|
|
375
|
+
raise NotTaken(f"{d['key']} stays disabled: its parent's answer offers no items")
|
|
346
376
|
items = self._open(page, d)
|
|
347
377
|
titles = [t.strip() for t in items.evaluate_all("as => as.map(a => a.title)")]
|
|
348
378
|
wanted = next((i for i, t in enumerate(titles) if t == value), None)
|
|
@@ -364,8 +394,8 @@ class SuccessFactors:
|
|
|
364
394
|
confirm.first.wait_for(timeout=LIST_TIMEOUT_MS)
|
|
365
395
|
confirm.first.click()
|
|
366
396
|
deadline = time.monotonic() + UPLOAD_TIMEOUT_S
|
|
367
|
-
while self._current(page, d["key"])["multiple"]:
|
|
368
|
-
if time.monotonic() > deadline:
|
|
397
|
+
while (now := self._current(page, d["key"]))["multiple"] or now["id"] is None:
|
|
398
|
+
if time.monotonic() > deadline: # deleting, it shows neither button
|
|
369
399
|
raise NotTaken(f"{d['key']} kept its file")
|
|
370
400
|
page.wait_for_timeout(POLL_MS)
|
|
371
401
|
source = page.locator("[role=dialog]:visible input[type=file]")
|
|
@@ -407,16 +437,39 @@ class SuccessFactors:
|
|
|
407
437
|
return page.url
|
|
408
438
|
|
|
409
439
|
def _candidate(self, page: Page) -> None:
|
|
410
|
-
"""From the posting, follows its apply link to the candidate pages.
|
|
440
|
+
"""From the posting, follows its apply link to the candidate pages. The link posts the
|
|
441
|
+
job there; signed in, some sites drop it and land on the candidate home, so the
|
|
442
|
+
application is opened from the job the link posted."""
|
|
411
443
|
host = urlsplit(page.url).hostname or ""
|
|
444
|
+
posted: list[PageRequest] = []
|
|
412
445
|
if not DC_HOST.match(host):
|
|
413
446
|
apply = page.locator(APPLY)
|
|
414
447
|
if not apply.count():
|
|
415
448
|
raise Unsupported("posting_gone: the posting has no apply link")
|
|
449
|
+
|
|
450
|
+
def keep(request: PageRequest) -> None:
|
|
451
|
+
if _posts_job(request):
|
|
452
|
+
posted.append(request)
|
|
453
|
+
|
|
454
|
+
page.on("request", keep)
|
|
416
455
|
apply.first.evaluate("a => a.click()") # some sites open a dialog on a real click
|
|
417
456
|
try:
|
|
457
|
+
page.locator(f"{LANDED}, {HOME}").first.wait_for(
|
|
458
|
+
state="attached", timeout=LANDING_TIMEOUT_MS
|
|
459
|
+
)
|
|
460
|
+
page.wait_for_load_state(timeout=LANDING_TIMEOUT_MS)
|
|
461
|
+
if posted and JOB_REQ not in page.url and not page.locator(LANDED).count():
|
|
462
|
+
post = posted[0]
|
|
463
|
+
parts = urlsplit(post.url)
|
|
464
|
+
job = parse_qs(parts.query) | parse_qs(post.post_data or "")
|
|
465
|
+
application = APPLICATION.format(company=job["company"][0], req=job[JOB_REQ][0])
|
|
466
|
+
page.goto(f"https://{parts.hostname}{application}")
|
|
418
467
|
page.locator(LANDED).first.wait_for(state="attached", timeout=LANDING_TIMEOUT_MS)
|
|
419
468
|
except Timeout:
|
|
469
|
+
if page.locator(GUIDED).count():
|
|
470
|
+
raise Unsupported(
|
|
471
|
+
known_gap(self, "guided_application", "the application is a step-by-step one")
|
|
472
|
+
) from None
|
|
420
473
|
raise Unsupported(
|
|
421
474
|
f"unhandled_rendering: the apply link led to {urlsplit(page.url).path}"
|
|
422
475
|
) from None
|
|
@@ -35,6 +35,8 @@ SETTLE_MS = 500
|
|
|
35
35
|
YES_NO = ["Yes", "No"]
|
|
36
36
|
DATE_SHOWN = re.compile(r"(\d\d)/(\d\d)/(\d{4})")
|
|
37
37
|
COUNTRY, STATE = "Country", "State"
|
|
38
|
+
HISPANIC = "HispanicOrigin" # race (`EthnicOrigin`) is asked when not Hispanic or Latino
|
|
39
|
+
DECLINE_BOX = "input[type=checkbox][data-automation$='-decline-checkbox']"
|
|
38
40
|
DAYS, DOCUMENTS = "availability_days", "documents"
|
|
39
41
|
|
|
40
42
|
# Auth0's error codes on the sign-in and sign-up pages, by what atsfill raises
|
|
@@ -66,10 +68,25 @@ _READ = """root => {
|
|
|
66
68
|
return el.closest(".form-group")?.querySelector("label");
|
|
67
69
|
};
|
|
68
70
|
const required = (el, label) => el.required || !!label?.classList.contains("required");
|
|
71
|
+
const select = (el, field) => {
|
|
72
|
+
field.kind = "select";
|
|
73
|
+
field.options = [...el.options].filter(o => o.value).map(o => clean(o.textContent));
|
|
74
|
+
field.value = el.value ? clean(el.selectedOptions[0]?.textContent) : "";
|
|
75
|
+
const box = el.closest(".form-group")?.querySelector(
|
|
76
|
+
"input[type=checkbox][data-automation$='-decline-checkbox']");
|
|
77
|
+
if (box) {
|
|
78
|
+
field.decline = clean(box.closest("label")?.querySelector("span:not(.sr-only)")
|
|
79
|
+
?.textContent);
|
|
80
|
+
field.options.push(field.decline);
|
|
81
|
+
if (box.checked) field.value = field.decline;
|
|
82
|
+
}
|
|
83
|
+
return field;
|
|
84
|
+
};
|
|
69
85
|
const out = [], seen = new Set();
|
|
70
86
|
for (const el of root.querySelectorAll("input, select, textarea")) {
|
|
71
87
|
const type = (el.type || "").toLowerCase();
|
|
72
88
|
if (["hidden", "file", "submit", "button"].includes(type) || !shown(el)) continue;
|
|
89
|
+
if (el.matches("[data-automation$='-decline-checkbox']")) continue; // its select's option
|
|
73
90
|
if (el.closest("ukg-input, ukg-input-container, [data-automation=upload-resume]")) continue;
|
|
74
91
|
const id = type === "radio" ? el.name : el.id || el.name || el.dataset.automation;
|
|
75
92
|
if (!id || seen.has(id)) continue;
|
|
@@ -79,11 +96,8 @@ _READ = """root => {
|
|
|
79
96
|
kind: type, options: [], value: el.value,
|
|
80
97
|
readonly: el.readOnly || el.disabled, maxLength: el.maxLength > 0 ?
|
|
81
98
|
el.maxLength : null};
|
|
82
|
-
if (el.tagName === "SELECT")
|
|
83
|
-
|
|
84
|
-
field.options = [...el.options].filter(o => o.value).map(o => clean(o.textContent));
|
|
85
|
-
field.value = el.value ? clean(el.selectedOptions[0]?.textContent) : "";
|
|
86
|
-
} else if (el.tagName === "TEXTAREA") field.kind = "text";
|
|
99
|
+
if (el.tagName === "SELECT") select(el, field);
|
|
100
|
+
else if (el.tagName === "TEXTAREA") field.kind = "text";
|
|
87
101
|
else if (type === "radio") {
|
|
88
102
|
const all = [...root.querySelectorAll(`input[type=radio][name="${CSS.escape(id)}"]`)];
|
|
89
103
|
field.options = all.map(r => clean(r.closest("label")?.textContent));
|
|
@@ -109,6 +123,17 @@ _READ = """root => {
|
|
|
109
123
|
!!label.classList?.contains("required"),
|
|
110
124
|
options: [], value: box.getAttribute("value") || ""});
|
|
111
125
|
}
|
|
126
|
+
const race = root.querySelector("select#EthnicOrigin");
|
|
127
|
+
const hispanic = root.querySelector("select#HispanicOrigin");
|
|
128
|
+
if (race && hispanic && out.some(f => f.id === "HispanicOrigin")) {
|
|
129
|
+
const kept = out.findIndex(f => f.id === "EthnicOrigin");
|
|
130
|
+
if (kept >= 0) out.splice(kept, 1);
|
|
131
|
+
const label = labelOf(race);
|
|
132
|
+
out.splice(out.findIndex(f => f.id === "HispanicOrigin") + 1, 0, select(race, {
|
|
133
|
+
id: race.id, label: clean(label?.textContent), required: required(race, label),
|
|
134
|
+
readonly: false, maxLength: null,
|
|
135
|
+
shownWhen: clean([...hispanic.options].find(o => o.value === "No")?.textContent)}));
|
|
136
|
+
}
|
|
112
137
|
const days = [...root.querySelectorAll("[data-automation=day-btn]")].filter(shown);
|
|
113
138
|
if (days.length) {
|
|
114
139
|
const label = document.getElementById("AvailabilityDaysQuestion");
|
|
@@ -151,11 +176,19 @@ _READ_BACK = """(root, kinds) => {
|
|
|
151
176
|
.find(r => r.checked);
|
|
152
177
|
out[id] = checked ? clean(checked.closest("label")?.textContent) : null;
|
|
153
178
|
} else {
|
|
154
|
-
const
|
|
155
|
-
|
|
179
|
+
const named = CSS.escape(id);
|
|
180
|
+
const el = [...root.querySelectorAll(
|
|
181
|
+
`[id="${named}"], [name="${named}"], [data-automation="${named}"]`)].find(shown);
|
|
156
182
|
if (!el) continue;
|
|
157
|
-
|
|
158
|
-
|
|
183
|
+
const box = el.closest(".form-group")?.querySelector(
|
|
184
|
+
"input[type=checkbox][data-automation$='-decline-checkbox']");
|
|
185
|
+
if (kind === "select" && box?.checked) {
|
|
186
|
+
out[id] = clean(box.closest("label")?.querySelector("span:not(.sr-only)")?.textContent);
|
|
187
|
+
} else if (kind === "select") {
|
|
188
|
+
out[id] = el.value ? clean(el.selectedOptions[0]?.textContent) : null;
|
|
189
|
+
} else if (kind === "checkbox") out[id] = el.checked ? "Yes" : "No";
|
|
190
|
+
// a Numeric question's autoNumeric input groups its number: 50,000
|
|
191
|
+
else if (el.dataset.automation === "numeric-response") out[id] = el.value.replace(/,/g, "");
|
|
159
192
|
else out[id] = el.value;
|
|
160
193
|
}
|
|
161
194
|
}
|
|
@@ -228,6 +261,7 @@ class UKG:
|
|
|
228
261
|
def __init__(self) -> None:
|
|
229
262
|
self._boards: dict[str, Board] = {} # by tenant, the last posting's
|
|
230
263
|
self._kinds: dict[str, str] = {}
|
|
264
|
+
self._declines: dict[str, str] = {} # a select's decline box's label, by the select
|
|
231
265
|
|
|
232
266
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
233
267
|
return board(url) is not None
|
|
@@ -266,13 +300,18 @@ class UKG:
|
|
|
266
300
|
) # fmt: skip
|
|
267
301
|
|
|
268
302
|
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
269
|
-
page
|
|
270
|
-
page.locator("input#username").fill(email)
|
|
271
|
-
page.locator("input#password").fill(password)
|
|
272
|
-
page.locator("button[type=submit][name=action][data-action-button-primary]").click()
|
|
273
|
-
_left_sign_in(page)
|
|
303
|
+
_sign_in(page, email, password)
|
|
274
304
|
if "/AuthCode/Register" in page.url:
|
|
275
|
-
raise
|
|
305
|
+
raise Refused(
|
|
306
|
+
"sign_up_details: the account was left at the tenant's details step: "
|
|
307
|
+
"`login --create --details` finishes it"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def finish_sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
|
|
311
|
+
"""Signs in and, where the account was left without its tenant details, gives them."""
|
|
312
|
+
_sign_in(page, email, password)
|
|
313
|
+
if "/AuthCode/Register" in page.url:
|
|
314
|
+
_register(page, details)
|
|
276
315
|
|
|
277
316
|
def sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
|
|
278
317
|
page.locator("a[href*='/u/signup']").first.click()
|
|
@@ -302,7 +341,9 @@ class UKG:
|
|
|
302
341
|
raws = page.locator(FORM).evaluate(_READ)
|
|
303
342
|
required_resume = '"IsResumeRequired":true' in page.content()
|
|
304
343
|
states = None
|
|
305
|
-
|
|
344
|
+
# with the address optional, the contact information is the account's, a read-only
|
|
345
|
+
# panel whose Country and State are in a closed edit dialog
|
|
346
|
+
if page.locator(f"{FORM} select#{STATE}").count() and any(r["id"] == COUNTRY for r in raws):
|
|
306
347
|
states = page.locator(FORM).evaluate(_STATES, f"{found.base}/Candidate/GetStates")
|
|
307
348
|
if not any(r["id"] == STATE for r in raws): # shown once a country is picked
|
|
308
349
|
label = page.locator(f"{FORM} label[for={STATE}]").first
|
|
@@ -313,6 +354,7 @@ class UKG:
|
|
|
313
354
|
"required": "required" in (label.get_attribute("class") or ""),
|
|
314
355
|
}) # fmt: skip
|
|
315
356
|
self._kinds = {raw["id"]: raw["kind"] for raw in raws}
|
|
357
|
+
self._declines = {raw["id"]: raw["decline"] for raw in raws if raw.get("decline")}
|
|
316
358
|
fields = []
|
|
317
359
|
for raw in raws:
|
|
318
360
|
if raw["id"] == DOCUMENTS:
|
|
@@ -328,6 +370,11 @@ class UKG:
|
|
|
328
370
|
values = [answer] if isinstance(answer, str) else answer
|
|
329
371
|
root = page.locator(FORM)
|
|
330
372
|
match kind:
|
|
373
|
+
case "select" if field_id in self._declines:
|
|
374
|
+
declined = values[0] == self._declines[field_id]
|
|
375
|
+
_decline_box(root, field_id).set_checked(declined)
|
|
376
|
+
if not declined:
|
|
377
|
+
_control(root, field_id).select_option(label=values[0])
|
|
331
378
|
case "select":
|
|
332
379
|
control = _control(root, field_id)
|
|
333
380
|
control.select_option(label=values[0])
|
|
@@ -366,6 +413,14 @@ class UKG:
|
|
|
366
413
|
return page.url
|
|
367
414
|
|
|
368
415
|
|
|
416
|
+
def _sign_in(page: Page, email: str, password: str) -> None:
|
|
417
|
+
page.locator("input#username").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
418
|
+
page.locator("input#username").fill(email)
|
|
419
|
+
page.locator("input#password").fill(password)
|
|
420
|
+
page.locator("button[type=submit][name=action][data-action-button-primary]").click()
|
|
421
|
+
_left_sign_in(page)
|
|
422
|
+
|
|
423
|
+
|
|
369
424
|
def _left_sign_in(page: Page) -> None:
|
|
370
425
|
"""Waits to leave the sign-in host, or for the error the page shows."""
|
|
371
426
|
deadline = time.monotonic() + SIGN_IN_TIMEOUT_S
|
|
@@ -441,12 +496,13 @@ def _field(raw: dict[str, Any], states: dict[str, list[str]] | None) -> Field:
|
|
|
441
496
|
return Field(field_id, label, KINDS[kind], raw["required"], prefilled=value,
|
|
442
497
|
unfillable=reason) # fmt: skip
|
|
443
498
|
options = raw["options"] if kind in ("select", "radio") else None
|
|
499
|
+
shown_when = Is(HISPANIC, [raw["shownWhen"]]) if raw.get("shownWhen") else None
|
|
444
500
|
if field_id == STATE and states is not None:
|
|
445
501
|
sets = [OptionSet(Is(COUNTRY, [country]), names) for country, names in states.items()]
|
|
446
502
|
return Field(field_id, label, FieldType.CHOICE, raw["required"], [], option_sets=sets)
|
|
447
503
|
return Field(
|
|
448
504
|
field_id, label, KINDS[kind], raw["required"], options, raw.get("maxLength"),
|
|
449
|
-
default=value if kind in ("date", "select") else None,
|
|
505
|
+
default=value if kind in ("date", "select") else None, shown_when=shown_when,
|
|
450
506
|
) # fmt: skip
|
|
451
507
|
|
|
452
508
|
|
|
@@ -459,7 +515,15 @@ def _wait_for_states(page: Page) -> None:
|
|
|
459
515
|
|
|
460
516
|
|
|
461
517
|
def _control(root: Locator, field_id: str) -> Locator:
|
|
462
|
-
|
|
518
|
+
named = f'[id="{field_id}"]:visible, [name="{field_id}"]:visible'
|
|
519
|
+
return root.locator(f'{named}, [data-automation="{field_id}"]:visible').first
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _decline_box(root: Locator, field_id: str) -> Locator:
|
|
523
|
+
"""The "I decline to say" box under an equal-opportunity question's select: ticked, it
|
|
524
|
+
clears the select."""
|
|
525
|
+
group = "xpath=ancestor::div[contains(@class,'form-group')][1]"
|
|
526
|
+
return _control(root, field_id).locator(group).locator(DECLINE_BOX)
|
|
463
527
|
|
|
464
528
|
|
|
465
529
|
def _option(root: Locator, name: str, text: str) -> Locator:
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from atsfill.core import browser
|
|
9
9
|
from atsfill.core.adapter import (
|
|
10
10
|
CreatesAccount,
|
|
11
|
+
FinishesSignUp,
|
|
11
12
|
NeedsAccount,
|
|
12
13
|
SignsIn,
|
|
13
14
|
SignUpDetails,
|
|
@@ -60,15 +61,19 @@ def sign_in(
|
|
|
60
61
|
password: str,
|
|
61
62
|
session: Path,
|
|
62
63
|
details: SignUpDetails | None = None,
|
|
64
|
+
create: bool = False,
|
|
63
65
|
) -> None:
|
|
64
|
-
"""Signs in
|
|
65
|
-
|
|
66
|
+
"""Signs in from the system's login page and stores the session: with `create`, creates
|
|
67
|
+
the account from the user's sign-up `details`; without, `details` finish a sign-up the
|
|
68
|
+
account was left in, where the adapter knows that step.
|
|
66
69
|
Raises what the adapter raises, without the password in it."""
|
|
67
70
|
page.goto(adapter.login_url(scope))
|
|
68
71
|
try:
|
|
69
|
-
if
|
|
70
|
-
assert isinstance(adapter, CreatesAccount)
|
|
72
|
+
if create:
|
|
73
|
+
assert isinstance(adapter, CreatesAccount) and details is not None
|
|
71
74
|
adapter.sign_up(page, address, password, details)
|
|
75
|
+
elif details is not None and isinstance(adapter, FinishesSignUp):
|
|
76
|
+
adapter.finish_sign_up(page, address, password, details)
|
|
72
77
|
else:
|
|
73
78
|
adapter.sign_in(page, address, password)
|
|
74
79
|
except Exception as e:
|
|
@@ -183,6 +183,17 @@ class CreatesAccount(SignsIn, Protocol):
|
|
|
183
183
|
...
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
@runtime_checkable
|
|
187
|
+
class FinishesSignUp(CreatesAccount, Protocol):
|
|
188
|
+
"""An adapter whose system can keep an account half made: signed up, but a step the
|
|
189
|
+
sign-up shows after it left unfinished, which every sign-in then shows again."""
|
|
190
|
+
|
|
191
|
+
def finish_sign_up(self, page: Page, email: str, password: str, details: SignUpDetails) -> None:
|
|
192
|
+
"""Signs in as `sign_in` does and, where the system shows the unfinished step, gives it
|
|
193
|
+
`details` as `sign_up` would."""
|
|
194
|
+
...
|
|
195
|
+
|
|
196
|
+
|
|
186
197
|
@runtime_checkable
|
|
187
198
|
class ReadsByRequest(Adapter, Protocol):
|
|
188
199
|
"""An adapter with a request read: a form's fields listed without a browser (ADR 0011)."""
|
|
@@ -491,7 +491,8 @@ def login(
|
|
|
491
491
|
`email` is remembered for the account; `password` is used for this sign-in only. `create`
|
|
492
492
|
creates the account with a password atsfill generates and stores first (a side effect),
|
|
493
493
|
giving the sign-up the user's `details`; with a password already stored, the account may
|
|
494
|
-
exist, so it signs in with that password and keeps the account's email instead
|
|
494
|
+
exist, so it signs in with that password and keeps the account's email instead, the
|
|
495
|
+
`details` finishing a sign-up the account was left in."""
|
|
495
496
|
account, data = _account(adapter), paths.data_dir(data_dir)
|
|
496
497
|
scope = _scope(account, url)
|
|
497
498
|
mine = paths.user_dir(data, user)
|
|
@@ -513,6 +514,15 @@ def login(
|
|
|
513
514
|
return _create(report, account, url, mine, email, details or SignUpDetails(), data)
|
|
514
515
|
if kept:
|
|
515
516
|
report.password = "kept"
|
|
517
|
+
# the details finish a sign-up the account may have been left in
|
|
518
|
+
if (
|
|
519
|
+
details
|
|
520
|
+
and isinstance(account, CreatesAccount)
|
|
521
|
+
and (missing := details.missing(account.asks))
|
|
522
|
+
):
|
|
523
|
+
report.status = Status.REFUSED
|
|
524
|
+
report.reason = f"sign_up_details: no {', '.join(missing)}"
|
|
525
|
+
return report
|
|
516
526
|
if password and not (isinstance(account, SignsIn) and accounts.email(mine, adapter, scope)):
|
|
517
527
|
raise ValueError(f"signing in to {adapter} with a password needs --email")
|
|
518
528
|
try:
|
|
@@ -526,7 +536,7 @@ def login(
|
|
|
526
536
|
accounts.wait_for_person(account, scope, data, session),
|
|
527
537
|
)
|
|
528
538
|
else:
|
|
529
|
-
result = _headless(data, account, scope, credentials, session)
|
|
539
|
+
result = _headless(data, account, scope, credentials, session, details)
|
|
530
540
|
command = login_command(adapter, user, url)
|
|
531
541
|
report.status, report.reason = (
|
|
532
542
|
result.status,
|
|
@@ -563,7 +573,8 @@ def _create(
|
|
|
563
573
|
except passwords.PasswordError as e:
|
|
564
574
|
report.status, report.reason = Status.BLOCKED, f"password_source: {e}"
|
|
565
575
|
return report
|
|
566
|
-
|
|
576
|
+
credentials = (email, password)
|
|
577
|
+
result = _headless(data, account, report.scope, credentials, report.path, details, create=True)
|
|
567
578
|
command = login_command(account.name, report.user, url)
|
|
568
579
|
report.status, report.reason = result.status, _email_link(result.reason, email, command)
|
|
569
580
|
report.password = "stored"
|
|
@@ -582,16 +593,17 @@ def _headless(
|
|
|
582
593
|
credentials: Credentials,
|
|
583
594
|
session: Path,
|
|
584
595
|
details: SignUpDetails | None = None,
|
|
596
|
+
create: bool = False,
|
|
585
597
|
) -> Result:
|
|
586
|
-
"""Signs in, or creates the account
|
|
587
|
-
stored."""
|
|
598
|
+
"""Signs in, or creates the account with `create`, through the adapter: `ready` once the
|
|
599
|
+
session is stored."""
|
|
588
600
|
assert isinstance(account, SignsIn)
|
|
589
601
|
url = account.login_url(scope)
|
|
590
602
|
|
|
591
603
|
def run(_: list[str]) -> Result:
|
|
592
604
|
result = Result(Status.READY, url, account.name)
|
|
593
605
|
with open_page(data, None) as page, _stops(result):
|
|
594
|
-
accounts.sign_in(page, account, scope, *credentials, session, details)
|
|
606
|
+
accounts.sign_in(page, account, scope, *credentials, session, details, create)
|
|
595
607
|
return result
|
|
596
608
|
|
|
597
609
|
return _in_budget(url, run)
|
|
@@ -96,8 +96,8 @@ share = 0.0350
|
|
|
96
96
|
account = "to_read"
|
|
97
97
|
hosting = ["hosted"]
|
|
98
98
|
adapter = "successfactors"
|
|
99
|
-
unsupported = { date = 2026-10-26, version = "0.0.3", commit = "0d8baea", by = "agent", reason = "quick_apply_sign_up: a Quick Apply page creates the account and sends the application in one submit, its sign-in offering no sign-up; sign_up_fields: a sign-up asking who may see the profile" }
|
|
100
99
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
100
|
+
dry_run_verified = { date = 2026-10-26, version = "0.0.3", by = "adapter_gate", commit = "79c8327" }
|
|
101
101
|
|
|
102
102
|
[ukg]
|
|
103
103
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|