atsfill 0.0.4.dev32__tar.gz → 0.0.4.dev33__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.dev32 → atsfill-0.0.4.dev33}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/README.md +1 -1
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.4.dev33/src/atsfill/adapters/dayforce.py +891 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/registry.toml +2 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/LICENSE +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/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.dev33
|
|
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
|
|
@@ -53,7 +53,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
53
53
|
## Supported hiring systems
|
|
54
54
|
|
|
55
55
|
<!-- badges -->
|
|
56
|
-
            
|
|
56
|
+
             
|
|
57
57
|
<!-- /badges -->
|
|
58
58
|
|
|
59
59
|
| 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 |
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from atsfill.adapters.ashby import Ashby
|
|
2
|
+
from atsfill.adapters.dayforce import Dayforce
|
|
2
3
|
from atsfill.adapters.greenhouse import Greenhouse
|
|
3
4
|
from atsfill.adapters.hireology import Hireology
|
|
4
5
|
from atsfill.adapters.jazzhr import JazzHR
|
|
@@ -27,4 +28,5 @@ ADAPTERS: list[Adapter] = [
|
|
|
27
28
|
Paycor(),
|
|
28
29
|
JazzHR(),
|
|
29
30
|
Lever(),
|
|
31
|
+
Dayforce(),
|
|
30
32
|
]
|
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
"""Dayforce (`jobs.dayforcehcm.com` career sites and their guest application), as
|
|
2
|
+
docs/adapters/dayforce.md observes.
|
|
3
|
+
|
|
4
|
+
The application is a wizard: Candidate Info (personal information, files, history entries),
|
|
5
|
+
one page per questionnaire, then Submit. The site context and the application template, both
|
|
6
|
+
public JSON, list every field; the page adds the options of its own dropdowns. Nothing but the
|
|
7
|
+
files reaches Dayforce before submit, so the read-back is the page, taken from each page
|
|
8
|
+
before leaving it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import html
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from contextlib import suppress
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
from atsfill.core.adapter import Unsupported
|
|
20
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
21
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
22
|
+
|
|
23
|
+
HOST = "jobs.dayforcehcm.com"
|
|
24
|
+
JOB = re.compile(r"^/(?:([a-z]{2}(?:-[A-Za-z]{2,4})?)/)?([^/]+)/([^/]+)/jobs/(\d+)")
|
|
25
|
+
API = "https://jobs.dayforcehcm.com/api/geo/{ns}/"
|
|
26
|
+
FORM_ID = "jobPostingApplication_"
|
|
27
|
+
PERSONAL = f"#{FORM_ID}personalInfo_"
|
|
28
|
+
OPTION = ".ant-select-dropdown:has([id='{id}_list']) .ant-select-item-option" # a select's own
|
|
29
|
+
NEXT = "main [test-id=application-next-step]"
|
|
30
|
+
RESUME = "section:has([test-id=resume-upload-title]) input[type=file]"
|
|
31
|
+
FORM_TIMEOUT_MS = 30_000
|
|
32
|
+
SETTLE_MS = 2_500
|
|
33
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
34
|
+
STEP_TIMEOUT_MS = 20_000
|
|
35
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
36
|
+
UPDATE_TRIES = 3
|
|
37
|
+
FOLLOWUP_TIMEOUT_MS = 3_000
|
|
38
|
+
NEXT_TRIES = 3
|
|
39
|
+
STEP_POLLS = 16 # of POLL_MS, before Next is clicked again
|
|
40
|
+
POLL_MS = 500
|
|
41
|
+
YES_NO = ["Yes", "No"]
|
|
42
|
+
NO_YES = ["No", "Yes"] # a box left as it is first
|
|
43
|
+
LONG_LABEL = 300 # a question whose description is a whole form is named by its display name
|
|
44
|
+
# A dial code several countries share, and the country a number under it is most likely from.
|
|
45
|
+
MAIN_COUNTRY = {"1": "United States", "7": "Russia", "44": "United Kingdom", "61": "Australia"}
|
|
46
|
+
|
|
47
|
+
# Personal information: the template's field name, the form's key and the field type.
|
|
48
|
+
PERSONAL_FIELDS = {
|
|
49
|
+
"Prefix": ("prefixId", FieldType.CHOICE),
|
|
50
|
+
"FirstName": ("firstName", FieldType.TEXT),
|
|
51
|
+
"MiddleName": ("middleName", FieldType.TEXT),
|
|
52
|
+
"LastName": ("lastName", FieldType.TEXT),
|
|
53
|
+
"Suffix": ("suffixId", FieldType.CHOICE),
|
|
54
|
+
"LinkedIn": ("linkedInURL", FieldType.TEXT),
|
|
55
|
+
"Address1": ("address1", FieldType.TEXT),
|
|
56
|
+
"Address2": ("address2", FieldType.TEXT),
|
|
57
|
+
"Address3": ("address3", FieldType.TEXT),
|
|
58
|
+
"Country": ("countryCode", FieldType.CHOICE),
|
|
59
|
+
"StateProvince": ("stateCode", FieldType.TYPEAHEAD),
|
|
60
|
+
"City": ("city", FieldType.TEXT),
|
|
61
|
+
"ZipPostalCode": ("postalCode", FieldType.TEXT),
|
|
62
|
+
"County": ("county", FieldType.TEXT),
|
|
63
|
+
"PreferredContactMethod": ("preferredContactMethod", FieldType.CHOICE),
|
|
64
|
+
"MobilePhone": ("mobilePhone", FieldType.PHONE),
|
|
65
|
+
"HomePhone": ("homePhone", FieldType.PHONE),
|
|
66
|
+
"Fax": ("fax", FieldType.PHONE),
|
|
67
|
+
"Pager": ("pager", FieldType.PHONE),
|
|
68
|
+
"CandidateSource": ("candidateSource", FieldType.CHOICE),
|
|
69
|
+
}
|
|
70
|
+
# History entries, as the page's own bundle maps them: the field name upper-cased, without
|
|
71
|
+
# spaces, to the form's key and its kind.
|
|
72
|
+
ENTRY_KINDS = {
|
|
73
|
+
"text": FieldType.TEXT,
|
|
74
|
+
"name": FieldType.TEXT,
|
|
75
|
+
"digits": FieldType.TEXT,
|
|
76
|
+
"gpa": FieldType.TEXT,
|
|
77
|
+
"longText": FieldType.TEXT,
|
|
78
|
+
"date": FieldType.DATE,
|
|
79
|
+
"boolean": FieldType.CHOICE,
|
|
80
|
+
"country": FieldType.CHOICE,
|
|
81
|
+
"region": FieldType.TYPEAHEAD,
|
|
82
|
+
}
|
|
83
|
+
ENTRIES = {
|
|
84
|
+
"WORKHISTORY": ("workHistory", {
|
|
85
|
+
"EMPLOYERNAME": ("companyName", "text"), "POSITIONTITLE": ("title", "text"),
|
|
86
|
+
"DIVISIONDEPT": ("department", "text"), "STARTDATE": ("effectiveStart", "date"),
|
|
87
|
+
"ENDDATE": ("effectiveEnd", "date"), "CURRENTJOB": ("isCurrent", "boolean"),
|
|
88
|
+
"COUNTRY": ("countryCode", "country"), "STATEPROVINCE": ("stateCode", "region"),
|
|
89
|
+
"ADDRESS1": ("addressLine1", "text"), "ADDRESS2": ("addressLine2", "text"),
|
|
90
|
+
"ADDRESS3": ("addressLine3", "text"), "CITY": ("city", "text"),
|
|
91
|
+
"COUNTY": ("county", "text"), "ZIPPOSTALCODE": ("postalCode", "text"),
|
|
92
|
+
"SALARY": ("salary", "digits"), "SUPERVISOR": ("supervisor", "name"),
|
|
93
|
+
"REASONFORLEAVING": ("reasonForLeaving", "longText"),
|
|
94
|
+
"DUTIESANDRESPONSIBILITIES": ("description", "longText"),
|
|
95
|
+
}),
|
|
96
|
+
"EDUCATIONHISTORY": ("educationHistory", {
|
|
97
|
+
"SCHOOLNAME": ("schoolName", "text"), "DEGREE": ("degreeName", "text"),
|
|
98
|
+
"MAJOR": ("majorName", "text"), "MINOR": ("minorName", "text"),
|
|
99
|
+
"STARTDATE": ("effectiveStart", "date"), "ENDDATE": ("effectiveEnd", "date"),
|
|
100
|
+
"EDUCATIONNOTCOMPLETED": ("notCompleted", "boolean"),
|
|
101
|
+
"COUNTRY": ("countryCode", "country"), "STATEPROVINCE": ("stateCode", "region"),
|
|
102
|
+
"CITY": ("city", "text"), "GPA": ("gpa", "gpa"),
|
|
103
|
+
}),
|
|
104
|
+
} # fmt: skip
|
|
105
|
+
CANDIDATE_INFO = {"PERSONALINFORMATION", "RESUMEUPLOAD", "AVAILABILITY", "SUBMIT", *ENTRIES}
|
|
106
|
+
# Work hours: a box per day, and a start and an end time for each day ticked.
|
|
107
|
+
DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
|
108
|
+
HOURS = f"#{FORM_ID}workHoursAvailability_"
|
|
109
|
+
# Questionnaire answer types (the bundle's enum): 1 is a statement with nothing to answer.
|
|
110
|
+
STATEMENT, RADIO, TEXT, DROPDOWN, YES_NO_TYPE, CHECKBOX, MULTI = range(1, 8)
|
|
111
|
+
QUESTION_TYPES = {
|
|
112
|
+
RADIO: FieldType.CHOICE,
|
|
113
|
+
TEXT: FieldType.TEXT,
|
|
114
|
+
DROPDOWN: FieldType.CHOICE,
|
|
115
|
+
YES_NO_TYPE: FieldType.CHOICE,
|
|
116
|
+
CHECKBOX: FieldType.CHOICE,
|
|
117
|
+
MULTI: FieldType.MULTI_CHOICE,
|
|
118
|
+
}
|
|
119
|
+
DISABILITY = "DISABILITYQUESTIONNAIRE" # its text questions (name, date, id) are the page's
|
|
120
|
+
|
|
121
|
+
# Each option of a dropdown, by scrolling its virtual list: only a screenful is in the DOM.
|
|
122
|
+
_HARVEST = """async (id) => {
|
|
123
|
+
const holder = document.getElementById(id + '_list')?.closest('.rc-virtual-list-holder');
|
|
124
|
+
if (!holder) return [...document.querySelectorAll(
|
|
125
|
+
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')]
|
|
126
|
+
.map(o => o.getAttribute('title') || o.innerText.trim());
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
for (let top = 0; ; top += holder.clientHeight / 2) {
|
|
129
|
+
holder.scrollTop = top;
|
|
130
|
+
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
131
|
+
holder.querySelectorAll('.ant-select-item-option')
|
|
132
|
+
.forEach(o => seen.add(o.getAttribute('title') || o.innerText.trim()));
|
|
133
|
+
if (holder.scrollTop + holder.clientHeight >= holder.scrollHeight - 1) break;
|
|
134
|
+
}
|
|
135
|
+
holder.scrollTop = 0;
|
|
136
|
+
return [...seen];
|
|
137
|
+
}"""
|
|
138
|
+
|
|
139
|
+
# Scrolls an open dropdown until an option shows one of `texts`; true when one does.
|
|
140
|
+
_REVEAL = """async ([id, texts]) => {
|
|
141
|
+
const wanted = new Set(texts.map(t => t.trim().toLowerCase()));
|
|
142
|
+
const holder = document.getElementById(id + '_list')?.closest('.rc-virtual-list-holder');
|
|
143
|
+
const shown = () => [...(holder || document).querySelectorAll('.ant-select-item-option')]
|
|
144
|
+
.some(o => wanted.has((o.getAttribute('title') || o.innerText).trim().toLowerCase()));
|
|
145
|
+
if (!holder) return shown();
|
|
146
|
+
for (let top = 0; ; top += holder.clientHeight / 2) {
|
|
147
|
+
holder.scrollTop = top;
|
|
148
|
+
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
149
|
+
if (shown()) return true;
|
|
150
|
+
if (holder.scrollTop + holder.clientHeight >= holder.scrollHeight - 1) return false;
|
|
151
|
+
}
|
|
152
|
+
}"""
|
|
153
|
+
|
|
154
|
+
# Clicks the n-th option of the open dropdown if it still shows `text`.
|
|
155
|
+
_CLICK = """([selector, n, text]) => {
|
|
156
|
+
const o = document.querySelectorAll(selector)[n];
|
|
157
|
+
if (!o || (o.getAttribute('title') || o.innerText) !== text) return false;
|
|
158
|
+
o.scrollIntoView({block: 'nearest'});
|
|
159
|
+
o.click();
|
|
160
|
+
return true;
|
|
161
|
+
}"""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Dayforce:
|
|
165
|
+
name = "dayforce"
|
|
166
|
+
side_effects = [
|
|
167
|
+
SideEffect(
|
|
168
|
+
"load",
|
|
169
|
+
"hiring_system",
|
|
170
|
+
"an employer privacy policy shown before the form is accepted to open it",
|
|
171
|
+
),
|
|
172
|
+
SideEffect(
|
|
173
|
+
"upload", "hiring_system", "an attached file is stored and parsed by Dayforce at once"
|
|
174
|
+
),
|
|
175
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
def __init__(self) -> None:
|
|
179
|
+
self._fields: dict[str, Field] = {}
|
|
180
|
+
self._sections: list[dict[str, Any]] = [] # the questionnaires, in page order
|
|
181
|
+
self._options: dict[str, dict[str, str]] = {} # field id: page text to option
|
|
182
|
+
self._values: dict[str, Answer | None] = {} # read back from each page before leaving it
|
|
183
|
+
|
|
184
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
185
|
+
return _job(url) is not None
|
|
186
|
+
|
|
187
|
+
def posting(self, url: str) -> str:
|
|
188
|
+
found = _job(url)
|
|
189
|
+
return f"https://{HOST}/{found[1]}/{found[2]}/jobs/{found[3]}" if found else url
|
|
190
|
+
|
|
191
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
192
|
+
return None # the template leaves out the options of the country and contact dropdowns
|
|
193
|
+
|
|
194
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
195
|
+
found = _job(page.url)
|
|
196
|
+
if found is None:
|
|
197
|
+
raise Unsupported("posting_gone: not a Dayforce job page")
|
|
198
|
+
_open_form(page, found)
|
|
199
|
+
fields = self._read(found, lambda u: page.evaluate("u => fetch(u).then(r => r.json())", u))
|
|
200
|
+
_close_dialogs(page, sms=None)
|
|
201
|
+
for i, field in enumerate(fields):
|
|
202
|
+
key = _personal_key(field.id)
|
|
203
|
+
if field.type is FieldType.CHOICE and key and page.locator(_id(key)).count():
|
|
204
|
+
options = _harvest(page, _id(key)) or field.options
|
|
205
|
+
fields[i] = _with(field, options=options)
|
|
206
|
+
elif field.type is FieldType.TEXT and key and page.locator(_id(key)).count():
|
|
207
|
+
limit = page.locator(_id(key)).first.get_attribute("maxlength")
|
|
208
|
+
fields[i] = _with(field, max_length=int(limit) if limit else None)
|
|
209
|
+
elif field.type is FieldType.FILE and page.locator(_file(field.id)).count():
|
|
210
|
+
accept = page.locator(_file(field.id)).first.get_attribute("accept") or ""
|
|
211
|
+
accepted = [f".{a.strip()}" for a in accept.split(",") if a.strip()] or None
|
|
212
|
+
fields[i] = _with(field, accept=accepted)
|
|
213
|
+
for prefix, _ in ENTRIES.values(): # an entry's controls show once it is added
|
|
214
|
+
texts = [i for i, f in enumerate(fields)
|
|
215
|
+
if f.id.startswith(f"{prefix}.") and f.type is FieldType.TEXT] # fmt: skip
|
|
216
|
+
if texts and page.locator(f"[test-id=add-{prefix.lower()}-record]").count():
|
|
217
|
+
base = _open_entry(page, prefix)
|
|
218
|
+
for i in texts:
|
|
219
|
+
control = page.locator(_id(base + fields[i].id.split(".")[1]))
|
|
220
|
+
limit = control.first.get_attribute("maxlength") if control.count() else None
|
|
221
|
+
fields[i] = _with(fields[i], max_length=int(limit) if limit else None)
|
|
222
|
+
countries = next((f.options for f in fields if f.id == "countryCode"), None)
|
|
223
|
+
fields = [
|
|
224
|
+
_with(f, options=countries) if f.id.endswith(".countryCode") else f for f in fields
|
|
225
|
+
]
|
|
226
|
+
self._fields = {f.id: f for f in fields}
|
|
227
|
+
return fields
|
|
228
|
+
|
|
229
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
230
|
+
found = _job(page.url)
|
|
231
|
+
if found is None:
|
|
232
|
+
raise Unsupported("posting_gone: not a Dayforce job page")
|
|
233
|
+
page.evaluate("key => sessionStorage.removeItem(key)", _draft(found))
|
|
234
|
+
_open_form(page, found)
|
|
235
|
+
self._values = {}
|
|
236
|
+
self._dialogs(page, answers)
|
|
237
|
+
self._fill_candidate_info(page, answers)
|
|
238
|
+
if not _next(page):
|
|
239
|
+
return
|
|
240
|
+
self._fill_questionnaires(page, answers)
|
|
241
|
+
if page.locator("[test-id=embeddedassessment-openassessment-button]").count():
|
|
242
|
+
raise Unsupported("external_step: an assessment on another site comes before submit")
|
|
243
|
+
box = page.locator(f"#{FORM_ID}userAcknowledged")
|
|
244
|
+
wanted = answers.get("userAcknowledged")
|
|
245
|
+
if wanted and box.count() and box.is_visible() and box.is_checked() != (wanted == "Yes"):
|
|
246
|
+
box.click()
|
|
247
|
+
|
|
248
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
249
|
+
values = {k: v for k, v in self._values.items() if v is not None}
|
|
250
|
+
box = page.locator(f"#{FORM_ID}userAcknowledged")
|
|
251
|
+
if box.count():
|
|
252
|
+
values["userAcknowledged"] = "Yes" if box.is_checked() else "No"
|
|
253
|
+
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
254
|
+
|
|
255
|
+
def submit(self, page: Page) -> str | None:
|
|
256
|
+
before = page.url
|
|
257
|
+
page.locator("main button", has_text=re.compile(r"^\s*Submit\s*$")).last.click()
|
|
258
|
+
page.wait_for_url(lambda url: url != before, timeout=SUBMIT_TIMEOUT_MS)
|
|
259
|
+
return page.url
|
|
260
|
+
|
|
261
|
+
def _read(self, found: tuple[str, ...], get: Callable[[str], Any]) -> list[Field]:
|
|
262
|
+
culture, ns, site, job = found
|
|
263
|
+
culture = culture or "en-US"
|
|
264
|
+
api = API.format(ns=ns)
|
|
265
|
+
context = get(f"{api}sitecontext/{ns}/{site}/{culture}?jobPostingId={job}")
|
|
266
|
+
template = get(f"{api}jobapplication/{ns}/{culture}/{context['jobBoardId']}/{job}")
|
|
267
|
+
sections = sorted(template["sections"], key=lambda s: s.get("sequence") or 0)
|
|
268
|
+
by_ref = {s.get("xRefCode"): s for s in sections}
|
|
269
|
+
fields = [Field("email", "Email Address", FieldType.TEXT, True),
|
|
270
|
+
Field("confirmEmail", "Confirm Email Address", FieldType.TEXT, True)] # fmt: skip
|
|
271
|
+
personal = by_ref.get("PERSONALINFORMATION") or {}
|
|
272
|
+
sources, extras = [], {}
|
|
273
|
+
if any(f["fieldName"] == "CandidateSource" for f in personal.get("fields") or []):
|
|
274
|
+
listed = get(f"{api}candidatesource/{ns}/{culture}")["candidateSources"]
|
|
275
|
+
sources = [s["displayName"] for s in listed]
|
|
276
|
+
explained = [s["displayName"] for s in listed if s.get("requiresAdditionalExplanation")]
|
|
277
|
+
if {"Prefix", "Suffix"} & {f["fieldName"] for f in personal.get("fields") or []}:
|
|
278
|
+
extras = get(f"{api}applicationformfieldsoptions/{ns}/{culture}")
|
|
279
|
+
for f in _visible(personal):
|
|
280
|
+
key, kind = PERSONAL_FIELDS.get(f["fieldName"], (None, None))
|
|
281
|
+
if key is None or kind is None:
|
|
282
|
+
if f["isRequired"]:
|
|
283
|
+
raise Unsupported(f"form_shape: personal field {f['fieldName']!r}")
|
|
284
|
+
continue
|
|
285
|
+
options = {
|
|
286
|
+
"candidateSource": sources,
|
|
287
|
+
"prefixId": [o["displayName"] for o in extras.get("prefix") or []],
|
|
288
|
+
"suffixId": [o["displayName"] for o in extras.get("suffix") or []],
|
|
289
|
+
}.get(key) or None
|
|
290
|
+
fields.append(Field(key, f["displayName"], kind, bool(f["isRequired"]), options))
|
|
291
|
+
if key == "candidateSource" and explained:
|
|
292
|
+
asked = f"asked when {f['displayName']!r} is {' or '.join(explained)}"
|
|
293
|
+
label = f"Additional details ({asked})"
|
|
294
|
+
fields.append(Field("candidateSourceDescription", label, FieldType.TEXT, False))
|
|
295
|
+
if context.get("hasActiveSMSProvider"):
|
|
296
|
+
fields.append(Field("smsConsent", "Consent to receive SMS messages", FieldType.CHOICE,
|
|
297
|
+
False, NO_YES)) # fmt: skip
|
|
298
|
+
if files := by_ref.get("RESUMEUPLOAD"):
|
|
299
|
+
fields += [
|
|
300
|
+
Field("resume", files["displayName"], FieldType.FILE, bool(files["isRequired"])),
|
|
301
|
+
Field("coverLetter", "Cover Letter", FieldType.FILE, False),
|
|
302
|
+
Field("additionalDocument", "Additional Documents", FieldType.FILE, False),
|
|
303
|
+
]
|
|
304
|
+
for ref, (prefix, keys) in ENTRIES.items():
|
|
305
|
+
if section := by_ref.get(ref):
|
|
306
|
+
fields += _entry_fields(section, prefix, keys)
|
|
307
|
+
hours = by_ref.get("AVAILABILITY") or {}
|
|
308
|
+
if any(f["fieldName"] == "WorkHoursAvailability" for f in _visible(hours)):
|
|
309
|
+
name, required = hours["displayName"], bool(hours["isRequired"])
|
|
310
|
+
fields += [
|
|
311
|
+
Field("workHours.days", f"{name}: days", FieldType.MULTI_CHOICE, required, DAYS),
|
|
312
|
+
Field("workHours.start", f"{name}: start time (HH:MM)", FieldType.TEXT, required),
|
|
313
|
+
Field("workHours.end", f"{name}: end time (HH:MM)", FieldType.TEXT, required),
|
|
314
|
+
Field("workHours.comment", f"{name}: comments", FieldType.TEXT, False),
|
|
315
|
+
]
|
|
316
|
+
for section in sections:
|
|
317
|
+
ref = section.get("xRefCode")
|
|
318
|
+
if (
|
|
319
|
+
not section.get("questionnaire")
|
|
320
|
+
and ref not in CANDIDATE_INFO
|
|
321
|
+
and section.get("isRequired")
|
|
322
|
+
):
|
|
323
|
+
raise Unsupported(f"form_shape: {section['displayName']!r} section is required")
|
|
324
|
+
self._sections = [
|
|
325
|
+
s for s in sections if s.get("questionnaire") and s.get("xRefCode") != "SUBMIT"
|
|
326
|
+
]
|
|
327
|
+
for section in self._sections:
|
|
328
|
+
for _, question in _questions(section):
|
|
329
|
+
fields += _question_fields(section, question)
|
|
330
|
+
if template.get("candidateAcknowledgement"):
|
|
331
|
+
label = "Candidate Acknowledgement: " + _plain(template["candidateAcknowledgement"])
|
|
332
|
+
fields.append(Field("userAcknowledged", label[:LONG_LABEL], FieldType.CHOICE, True,
|
|
333
|
+
YES_NO)) # fmt: skip
|
|
334
|
+
self._fields = {f.id: f for f in fields}
|
|
335
|
+
return fields
|
|
336
|
+
|
|
337
|
+
def _fill_candidate_info(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
338
|
+
if "resume" in answers: # first: the resume is imported over the personal information
|
|
339
|
+
_attach(page, RESUME, _one(answers["resume"]))
|
|
340
|
+
self._dialogs(page, answers)
|
|
341
|
+
for file_id in ("coverLetter", "additionalDocument"):
|
|
342
|
+
if file_id in answers:
|
|
343
|
+
_attach(page, _file(file_id), _one(answers[file_id]))
|
|
344
|
+
for file_id in ("resume", "coverLetter", "additionalDocument"):
|
|
345
|
+
if file_id in self._fields and (name := _file_name(page, file_id)):
|
|
346
|
+
self._values[file_id] = name
|
|
347
|
+
order = sorted( # the country first: the state's list is the country's
|
|
348
|
+
(f for f in self._fields.values() if _personal_key(f.id)),
|
|
349
|
+
key=lambda f: f.id != "countryCode",
|
|
350
|
+
)
|
|
351
|
+
for field in order:
|
|
352
|
+
if field.id in answers:
|
|
353
|
+
_fill_control(page, _id(field.id), field, answers[field.id])
|
|
354
|
+
if field.id == "candidateSource" and "candidateSourceDescription" in answers:
|
|
355
|
+
description = page.locator(_id("candidateSourceDescription"))
|
|
356
|
+
with suppress(Timeout): # it shows once a source asking for details is picked
|
|
357
|
+
description.wait_for(timeout=FOLLOWUP_TIMEOUT_MS)
|
|
358
|
+
for _ in range(UPDATE_TRIES if description.count() else 0):
|
|
359
|
+
description.fill(_one(answers["candidateSourceDescription"]))
|
|
360
|
+
page.wait_for_timeout(POLL_MS) # the box can render again once, empty
|
|
361
|
+
if description.input_value() == answers["candidateSourceDescription"]:
|
|
362
|
+
break
|
|
363
|
+
for field in order:
|
|
364
|
+
self._values[field.id] = _read_control(page, _id(field.id), field)
|
|
365
|
+
description = page.locator(_id("candidateSourceDescription"))
|
|
366
|
+
if description.count():
|
|
367
|
+
self._values["candidateSourceDescription"] = description.input_value()
|
|
368
|
+
_update(page, f"{PERSONAL}email")
|
|
369
|
+
if page.locator(f"{HOURS}sunday_isChecked").count():
|
|
370
|
+
self._fill_hours(page, answers)
|
|
371
|
+
for prefix, _ in ENTRIES.values():
|
|
372
|
+
entry = {k: v for k, v in answers.items() if k.startswith(f"{prefix}.")}
|
|
373
|
+
if entry:
|
|
374
|
+
self._fill_entry(page, prefix, entry)
|
|
375
|
+
|
|
376
|
+
def _dialogs(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
377
|
+
if (sms := _close_dialogs(page, answers.get("smsConsent"))) is not None:
|
|
378
|
+
self._values["smsConsent"] = sms
|
|
379
|
+
|
|
380
|
+
def _fill_hours(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
381
|
+
"""Ticks the days answered and gives each the same hours; an open section keeps Next
|
|
382
|
+
from going on, and one with no day ticked does not save."""
|
|
383
|
+
days = answers.get("workHours.days") or []
|
|
384
|
+
for day in DAYS:
|
|
385
|
+
box = page.locator(f"{HOURS}{day.lower()}_isChecked")
|
|
386
|
+
if box.is_checked() != (day in days):
|
|
387
|
+
box.click()
|
|
388
|
+
if day in days:
|
|
389
|
+
for end in ("start", "end"):
|
|
390
|
+
if f"workHours.{end}" in answers:
|
|
391
|
+
page.locator(f"{HOURS}{day.lower()}_{end}").fill(
|
|
392
|
+
_one(answers[f"workHours.{end}"])
|
|
393
|
+
)
|
|
394
|
+
if "workHours.comment" in answers:
|
|
395
|
+
page.locator(f"{HOURS}comment").fill(_one(answers["workHours.comment"]))
|
|
396
|
+
ticked = [d for d in DAYS if page.locator(f"{HOURS}{d.lower()}_isChecked").is_checked()]
|
|
397
|
+
self._values["workHours.days"] = ticked
|
|
398
|
+
for end in ("start", "end"):
|
|
399
|
+
shown = [page.locator(f"{HOURS}{d.lower()}_{end}").input_value() for d in ticked]
|
|
400
|
+
self._values[f"workHours.{end}"] = shown[0] if len(set(shown)) == 1 else None
|
|
401
|
+
self._values["workHours.comment"] = page.locator(f"{HOURS}comment").input_value()
|
|
402
|
+
_update(page, f"{HOURS}sunday_isChecked")
|
|
403
|
+
|
|
404
|
+
def _fill_entry(self, page: Page, prefix: str, answers: dict[str, Answer]) -> None:
|
|
405
|
+
"""Adds one entry to a history section and fills it."""
|
|
406
|
+
base = _open_entry(page, prefix)
|
|
407
|
+
fields = [f for f in self._fields.values() if f.id.startswith(f"{prefix}.")]
|
|
408
|
+
for field in fields:
|
|
409
|
+
if field.id in answers:
|
|
410
|
+
_fill_control(page, _id(base + field.id.split(".")[1]), field, answers[field.id])
|
|
411
|
+
for field in fields:
|
|
412
|
+
self._values[field.id] = _read_control(page, _id(base + field.id.split(".")[1]), field)
|
|
413
|
+
_update(page, f"main [id^='{base}']") # not its dropdowns' lists, kept after main
|
|
414
|
+
|
|
415
|
+
def _fill_questionnaires(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
416
|
+
left = list(self._sections)
|
|
417
|
+
for n in range(len(self._sections)):
|
|
418
|
+
title = _questionnaire_title(page) or "" # some sites show no counter
|
|
419
|
+
section = next((s for s in left if _same(s["displayName"], title)), left[0])
|
|
420
|
+
left.remove(section)
|
|
421
|
+
for j, question in _questions(section):
|
|
422
|
+
base = f"{FORM_ID}sections_{n}_questionnaire_questions_{j}_"
|
|
423
|
+
self._fill_question(page, base, question, answers)
|
|
424
|
+
if not _next(page):
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
def _fill_question(
|
|
428
|
+
self, page: Page, base: str, question: dict[str, Any], answers: dict[str, Answer]
|
|
429
|
+
) -> None:
|
|
430
|
+
field_id = f"q{question['questionId']}"
|
|
431
|
+
field = self._fields.get(field_id)
|
|
432
|
+
if field is None:
|
|
433
|
+
return
|
|
434
|
+
target = _question_target(page, base, question)
|
|
435
|
+
if target is None:
|
|
436
|
+
return
|
|
437
|
+
if field_id in answers and not field.prefilled and target.is_enabled():
|
|
438
|
+
self._set_question(page, target, field, question, answers[field_id])
|
|
439
|
+
self._values[field_id] = self._get_question(page, target, field, question)
|
|
440
|
+
followup = f"{field_id}.followup"
|
|
441
|
+
if followup in self._fields:
|
|
442
|
+
boxes = page.locator( # a box group's is one per option asking for comments
|
|
443
|
+
f"[id='{base}answerFollowup'],"
|
|
444
|
+
f" :is(input, textarea)[id^='{base}answerFollowups_'],"
|
|
445
|
+
f" [id*='questionnaireFollowUp-{question['questionId']}-']"
|
|
446
|
+
)
|
|
447
|
+
if followup in answers:
|
|
448
|
+
with suppress(Timeout): # it shows once an option asking for comments is picked
|
|
449
|
+
boxes.first.wait_for(timeout=FOLLOWUP_TIMEOUT_MS)
|
|
450
|
+
for box in boxes.all():
|
|
451
|
+
if followup in answers and box.is_visible():
|
|
452
|
+
box.fill(_one(answers[followup]))
|
|
453
|
+
shown = [b.input_value() for b in boxes.all() if b.is_visible()]
|
|
454
|
+
self._values[followup] = shown[0] if shown else ""
|
|
455
|
+
|
|
456
|
+
def _set_question(
|
|
457
|
+
self, page: Page, target: Locator, field: Field, question: dict[str, Any], answer: Answer
|
|
458
|
+
) -> None:
|
|
459
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
460
|
+
texts = _option_texts(question)
|
|
461
|
+
match question["answerType"]:
|
|
462
|
+
case 6:
|
|
463
|
+
box = target
|
|
464
|
+
if box.is_checked() != (values[0] == "Yes"):
|
|
465
|
+
box.click()
|
|
466
|
+
case 3:
|
|
467
|
+
target.fill(values[0])
|
|
468
|
+
case 7:
|
|
469
|
+
for wrapper in target.locator(".ant-checkbox-wrapper").all():
|
|
470
|
+
wanted = _option_of(wrapper.inner_text(), texts) in values
|
|
471
|
+
box = wrapper.locator("input[type=checkbox]")
|
|
472
|
+
if box.is_checked() != wanted:
|
|
473
|
+
wrapper.click()
|
|
474
|
+
case _ if target.get_attribute("role") == "combobox":
|
|
475
|
+
_pick(page, target, [t for t, o in texts.items() if o == values[0]])
|
|
476
|
+
case _:
|
|
477
|
+
for wrapper in target.locator(".ant-radio-wrapper").all():
|
|
478
|
+
if _option_of(wrapper.inner_text(), texts) == values[0]:
|
|
479
|
+
wrapper.click()
|
|
480
|
+
break
|
|
481
|
+
|
|
482
|
+
def _get_question(
|
|
483
|
+
self, page: Page, target: Locator, field: Field, question: dict[str, Any]
|
|
484
|
+
) -> Answer | None:
|
|
485
|
+
texts = _option_texts(question)
|
|
486
|
+
match question["answerType"]:
|
|
487
|
+
case 6:
|
|
488
|
+
return "Yes" if target.is_checked() else "No"
|
|
489
|
+
case 3:
|
|
490
|
+
return target.input_value()
|
|
491
|
+
case 7:
|
|
492
|
+
return [
|
|
493
|
+
_option_of(w.inner_text(), texts) or w.inner_text().strip()
|
|
494
|
+
for w in target.locator(".ant-checkbox-wrapper").all()
|
|
495
|
+
if w.locator("input[type=checkbox]").is_checked()
|
|
496
|
+
]
|
|
497
|
+
case _ if target.get_attribute("role") == "combobox":
|
|
498
|
+
shown = _selected(target)
|
|
499
|
+
return _option_of(shown, texts) if shown else None
|
|
500
|
+
case _:
|
|
501
|
+
checked = target.locator(".ant-radio-wrapper-checked")
|
|
502
|
+
return _option_of(checked.first.inner_text(), texts) if checked.count() else None
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _job(url: str) -> tuple[str, ...] | None:
|
|
506
|
+
"""(culture or "", namespace, career site, job posting id) of a Dayforce job URL."""
|
|
507
|
+
parts = urlsplit(url)
|
|
508
|
+
if parts.hostname != HOST or not (match := JOB.match(parts.path)):
|
|
509
|
+
return None
|
|
510
|
+
return (match[1] or "", match[2], match[3], match[4])
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _draft(found: tuple[str, ...]) -> str:
|
|
514
|
+
"""The sessionStorage key the page keeps its draft under."""
|
|
515
|
+
return f"jobPostingApplication-{found[3]}-{found[1]}"
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _plain(text: str) -> str:
|
|
519
|
+
text = re.sub(r"<[^>]+>", " ", text or "")
|
|
520
|
+
return re.sub(r"\s+", " ", html.unescape(text)).strip()
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _same(a: str, b: str) -> bool:
|
|
524
|
+
return _plain(a).casefold() == _plain(b).casefold()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _with(field: Field, **changes: Any) -> Field:
|
|
528
|
+
return dataclasses.replace(field, **changes)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _one(answer: Answer) -> str:
|
|
532
|
+
return answer if isinstance(answer, str) else answer[0]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _visible(section: dict[str, Any]) -> list[dict[str, Any]]:
|
|
536
|
+
return sorted(
|
|
537
|
+
(f for f in section.get("fields") or [] if f.get("isVisible")),
|
|
538
|
+
key=lambda f: f.get("sequence") or 0,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _entry_fields(
|
|
543
|
+
section: dict[str, Any], prefix: str, keys: dict[str, tuple[str, str]]
|
|
544
|
+
) -> list[Field]:
|
|
545
|
+
"""One entry of a history section; its fields are required only when the section is."""
|
|
546
|
+
fields = []
|
|
547
|
+
for f in _visible(section):
|
|
548
|
+
key, kind = keys.get(re.sub(r"\s", "", f["fieldName"]).upper(), (None, None))
|
|
549
|
+
if key is None or kind is None:
|
|
550
|
+
if f["isRequired"] and section["isRequired"]:
|
|
551
|
+
raise Unsupported(f"form_shape: {prefix} field {f['fieldName']!r}")
|
|
552
|
+
continue
|
|
553
|
+
required = bool(f["isRequired"] and section["isRequired"])
|
|
554
|
+
options = NO_YES if kind == "boolean" else None
|
|
555
|
+
label = f"{section['displayName']}: {f['displayName']}"
|
|
556
|
+
fields.append(Field(f"{prefix}.{key}", label, ENTRY_KINDS[kind], required, options))
|
|
557
|
+
return fields
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _questions(section: dict[str, Any]) -> list[tuple[int, dict[str, Any]]]:
|
|
561
|
+
"""The questions that take an answer, with their index in the page's control ids: a
|
|
562
|
+
statement has no control but keeps its index, except on the disability form, laid out
|
|
563
|
+
on its own."""
|
|
564
|
+
questions = (section.get("questionnaire") or {}).get("questions") or []
|
|
565
|
+
if section.get("xRefCode") == DISABILITY:
|
|
566
|
+
questions = [q for q in questions if q["answerType"] != STATEMENT]
|
|
567
|
+
return [(j, q) for j, q in enumerate(questions) if q["answerType"] != STATEMENT]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _option_texts(question: dict[str, Any]) -> dict[str, str]:
|
|
571
|
+
"""Each text the page may show for an option, mapped to the option's label."""
|
|
572
|
+
texts: dict[str, str] = {}
|
|
573
|
+
for option in question["options"]:
|
|
574
|
+
label = _option_label(option)
|
|
575
|
+
for text in (option.get("description"), option.get("displayName")):
|
|
576
|
+
if text and _plain(text):
|
|
577
|
+
texts.setdefault(_plain(text).casefold(), label)
|
|
578
|
+
return texts
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _option_label(option: dict[str, Any]) -> str:
|
|
582
|
+
return _plain(option.get("description") or "") or _plain(option.get("displayName") or "")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _option_of(text: str, texts: dict[str, str]) -> str | None:
|
|
586
|
+
return texts.get(_plain(text).casefold())
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _question_fields(section: dict[str, Any], question: dict[str, Any]) -> list[Field]:
|
|
590
|
+
kind = QUESTION_TYPES.get(question["answerType"])
|
|
591
|
+
label = _plain(question.get("description") or "")
|
|
592
|
+
if not label or len(label) > LONG_LABEL:
|
|
593
|
+
label = _plain(question["displayName"])
|
|
594
|
+
if kind is None:
|
|
595
|
+
if question["isRequired"]:
|
|
596
|
+
raise Unsupported(f"form_shape: {label!r} has answer type {question['answerType']}")
|
|
597
|
+
return []
|
|
598
|
+
field_id = f"q{question['questionId']}"
|
|
599
|
+
options = None
|
|
600
|
+
if question["answerType"] == CHECKBOX:
|
|
601
|
+
options = YES_NO
|
|
602
|
+
elif kind in (FieldType.CHOICE, FieldType.MULTI_CHOICE):
|
|
603
|
+
options = [_option_label(o) for o in question["options"]]
|
|
604
|
+
prefilled = section.get("xRefCode") == DISABILITY and question["answerType"] == TEXT
|
|
605
|
+
fields = [Field(field_id, label, kind, bool(question["isRequired"]), options,
|
|
606
|
+
prefilled=prefilled)] # fmt: skip
|
|
607
|
+
asking = [_option_label(o) for o in question["options"] if o.get("hasFollowUp")]
|
|
608
|
+
if asking:
|
|
609
|
+
followup = f"Additional comments (asked when {label!r} is {' or '.join(asking)})"
|
|
610
|
+
fields.append(Field(f"{field_id}.followup", followup, FieldType.TEXT, False))
|
|
611
|
+
return fields
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _personal_key(field_id: str) -> str | None:
|
|
615
|
+
keys = {key for key, _ in PERSONAL_FIELDS.values()} | {"email", "confirmEmail"}
|
|
616
|
+
return field_id if field_id in keys else None
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _id(key: str) -> str:
|
|
620
|
+
return f"[id='{FORM_ID}personalInfo_{key}']" if "_" not in key else f"[id='{key}']"
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _file(file_id: str) -> str:
|
|
624
|
+
return RESUME if file_id == "resume" else f"[id='{FORM_ID}files_{file_id}']"
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _open_form(page: Page, found: tuple[str, ...]) -> None:
|
|
628
|
+
"""Goes to the guest application of the posting, past the cookie banner."""
|
|
629
|
+
culture, ns, site, job = found
|
|
630
|
+
path = f"/{culture}" if culture else ""
|
|
631
|
+
page.goto(f"https://{HOST}{path}/{ns}/{site}/jobs/{job}/apply/manualApplication"
|
|
632
|
+
"?applicationSource=Manual") # fmt: skip
|
|
633
|
+
try:
|
|
634
|
+
page.locator(f"{PERSONAL}email").wait_for(timeout=FORM_TIMEOUT_MS)
|
|
635
|
+
except Timeout:
|
|
636
|
+
raise Unsupported("posting_gone: no application form for this posting") from None
|
|
637
|
+
page.wait_for_timeout(SETTLE_MS) # the dialogs open once the site context has loaded
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _close_dialogs(page: Page, sms: Answer | None) -> str | None:
|
|
641
|
+
"""Accepts the employer's privacy policy, answers the SMS consent (no, unless `sms` is
|
|
642
|
+
yes) and turns the optional cookies off: each dialog covers the form. Returns the SMS
|
|
643
|
+
consent as the dialog held it, None when it did not open."""
|
|
644
|
+
held = None
|
|
645
|
+
agree = page.locator("[test-id=client-privacy-policy-modal-agree-checkbox]")
|
|
646
|
+
if agree.count() and agree.is_visible():
|
|
647
|
+
agree.check()
|
|
648
|
+
page.locator("[test-id=client-privacy-policy-modal-save-button]").click()
|
|
649
|
+
sms_save = page.locator("[test-id=sms-consent-dialog-save-button]")
|
|
650
|
+
if sms_save.count() and sms_save.is_visible():
|
|
651
|
+
value = "yes" if sms == "Yes" else "no"
|
|
652
|
+
page.locator(f"[role=dialog] input[type=radio][value={value}]").check()
|
|
653
|
+
checked = page.locator("[role=dialog] input[type=radio]:checked")
|
|
654
|
+
held = {"yes": "Yes", "no": "No"}.get(checked.first.get_attribute("value") or "")
|
|
655
|
+
sms_save.click()
|
|
656
|
+
for button in ("cookie-preferences-banner-reject-button", "disable-all-cookie-details-button",
|
|
657
|
+
"save-button-cookie-details"): # fmt: skip
|
|
658
|
+
target = page.locator(f"[test-id={button}]")
|
|
659
|
+
if target.count() and target.first.is_visible():
|
|
660
|
+
target.first.click()
|
|
661
|
+
return held
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _selector(target: Locator) -> Locator:
|
|
665
|
+
return target.locator("xpath=ancestor::div[contains(@class,'ant-select-selector')]").first
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _open(target: Locator) -> None:
|
|
669
|
+
"""Opens a dropdown in the middle of the window: the step's button bar covers the bottom."""
|
|
670
|
+
target.evaluate("e => e.scrollIntoView({block: 'center'})")
|
|
671
|
+
_selector(target).click()
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _harvest(page: Page, selector: str) -> list[str]:
|
|
675
|
+
target = page.locator(selector).first
|
|
676
|
+
if target.is_disabled():
|
|
677
|
+
return []
|
|
678
|
+
_open(target)
|
|
679
|
+
options = page.evaluate(_HARVEST, target.get_attribute("id"))
|
|
680
|
+
target.press("Escape")
|
|
681
|
+
return [o for o in options if o]
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _selected(target: Locator) -> str:
|
|
685
|
+
shown = target.locator(
|
|
686
|
+
"xpath=ancestor::div[contains(@class,'ant-select-selector')]//*[contains(@class,"
|
|
687
|
+
"'ant-select-selection-item')]"
|
|
688
|
+
)
|
|
689
|
+
if not shown.count():
|
|
690
|
+
return ""
|
|
691
|
+
return shown.first.get_attribute("title") or shown.first.inner_text().strip()
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _pick(page: Page, target: Locator, texts: list[str]) -> bool:
|
|
695
|
+
"""Types each text into the dropdown and clicks the option it names exactly."""
|
|
696
|
+
for text in texts:
|
|
697
|
+
_open(target)
|
|
698
|
+
options = page.locator(_option(target))
|
|
699
|
+
if target.get_attribute("readonly") is None:
|
|
700
|
+
target.fill("")
|
|
701
|
+
target.press_sequentially(text[:40], delay=10)
|
|
702
|
+
with suppress(Timeout):
|
|
703
|
+
options.first.wait_for(timeout=5_000)
|
|
704
|
+
page.evaluate(_REVEAL, [target.get_attribute("id"), [text]])
|
|
705
|
+
if _click_option(page, target, _plain(text).casefold().__eq__):
|
|
706
|
+
return True
|
|
707
|
+
target.press("Escape")
|
|
708
|
+
return False
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _option(target: Locator) -> str:
|
|
712
|
+
"""The options of the dropdown `target` opens, not of another one still closing."""
|
|
713
|
+
return OPTION.format(id=target.get_attribute("id"))
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _click_option(page: Page, target: Locator, wanted: Callable[[str], bool]) -> bool:
|
|
717
|
+
"""Clicks the first option of the open dropdown whose text `wanted` takes, read in one go:
|
|
718
|
+
the virtual list renders its options anew as it moves."""
|
|
719
|
+
selector = _option(target)
|
|
720
|
+
shown = page.locator(selector).evaluate_all(
|
|
721
|
+
"os => os.map(o => o.getAttribute('title') || o.innerText)"
|
|
722
|
+
)
|
|
723
|
+
n = next((i for i, text in enumerate(shown) if wanted(_plain(text).casefold())), None)
|
|
724
|
+
if n is None:
|
|
725
|
+
return False
|
|
726
|
+
return page.evaluate(_CLICK, [selector, n, shown[n]])
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _fill_control(page: Page, selector: str, field: Field, answer: Answer) -> None:
|
|
730
|
+
value = _one(answer)
|
|
731
|
+
target = page.locator(selector).first
|
|
732
|
+
if field.type is FieldType.PHONE:
|
|
733
|
+
_fill_phone(page, selector, value)
|
|
734
|
+
elif not target.count() or target.is_disabled():
|
|
735
|
+
return
|
|
736
|
+
elif target.get_attribute("type") == "checkbox":
|
|
737
|
+
if target.is_checked() != (value == "Yes"):
|
|
738
|
+
target.click()
|
|
739
|
+
elif target.get_attribute("role") == "combobox":
|
|
740
|
+
if field.type is FieldType.TYPEAHEAD:
|
|
741
|
+
_pick_suggestion(page, target, value)
|
|
742
|
+
else:
|
|
743
|
+
_pick(page, target, [value])
|
|
744
|
+
else:
|
|
745
|
+
target.fill(value)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _pick_suggestion(page: Page, target: Locator, value: str) -> None:
|
|
749
|
+
"""Types the answer and clicks the first option that starts with it."""
|
|
750
|
+
_open(target)
|
|
751
|
+
target.press_sequentially(value[:40], delay=10)
|
|
752
|
+
with suppress(Timeout):
|
|
753
|
+
page.locator(_option(target)).first.wait_for(timeout=5_000)
|
|
754
|
+
if not _click_option(page, target, lambda shown: shown.startswith(_plain(value).casefold())):
|
|
755
|
+
target.press("Escape")
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _fill_phone(page: Page, selector: str, value: str) -> None:
|
|
759
|
+
"""Picks the dial code the number starts with, then types the rest."""
|
|
760
|
+
key = selector.split("personalInfo_")[1].rstrip("']")
|
|
761
|
+
item = page.locator(f".ant-form-item:has(label[for='{FORM_ID}personalInfo_{key}'])").first
|
|
762
|
+
dial = item.locator("input[role=combobox]").first
|
|
763
|
+
digits = re.sub(r"\D", "", value)
|
|
764
|
+
if value.startswith("+") and dial.count(): # fax and pager have no dial code
|
|
765
|
+
for size in (3, 2, 1):
|
|
766
|
+
code = digits[:size]
|
|
767
|
+
_open(dial)
|
|
768
|
+
dial.fill("")
|
|
769
|
+
dial.press_sequentially(f"+{code}", delay=10)
|
|
770
|
+
options = page.locator(_option(dial)).filter(has_text=re.compile(rf"\+{code}(\s|$)"))
|
|
771
|
+
with suppress(Timeout):
|
|
772
|
+
options.first.wait_for(timeout=2_000)
|
|
773
|
+
if options.count():
|
|
774
|
+
main = options.filter(has_text=MAIN_COUNTRY.get(code, "\0"))
|
|
775
|
+
(main if main.count() else options).first.click()
|
|
776
|
+
digits = digits[size:]
|
|
777
|
+
break
|
|
778
|
+
dial.press("Escape")
|
|
779
|
+
page.locator(selector).first.fill(digits)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _read_control(page: Page, selector: str, field: Field) -> Answer | None:
|
|
783
|
+
target = page.locator(selector).first
|
|
784
|
+
if field.type is FieldType.PHONE:
|
|
785
|
+
key = selector.split("personalInfo_")[1].rstrip("']")
|
|
786
|
+
item = page.locator(f".ant-form-item:has(label[for='{FORM_ID}personalInfo_{key}'])").first
|
|
787
|
+
code = re.search(r"\+\d+", _selected(item.locator("input[role=combobox]").first) or "")
|
|
788
|
+
number = target.input_value() if target.count() else ""
|
|
789
|
+
return f"{code.group(0) if code else ''}{number}" if number else None
|
|
790
|
+
if not target.count():
|
|
791
|
+
return None
|
|
792
|
+
if target.get_attribute("type") == "checkbox":
|
|
793
|
+
return "Yes" if target.is_checked() else "No"
|
|
794
|
+
if target.get_attribute("role") == "combobox":
|
|
795
|
+
return _selected(target) or None
|
|
796
|
+
return target.input_value()
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _file_name(page: Page, file_id: str) -> str | None:
|
|
800
|
+
"""The attached file's name, shown in the file input's form item."""
|
|
801
|
+
item = page.locator(_file(file_id)).first.locator(
|
|
802
|
+
"xpath=ancestor::*[contains(@class,'ant-form-item')][1]"
|
|
803
|
+
)
|
|
804
|
+
if not item.count():
|
|
805
|
+
return None
|
|
806
|
+
names = re.findall(r"[^\n/\\]+\.\w{2,4}", item.inner_text())
|
|
807
|
+
return names[-1].strip() if names else None
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _attach(page: Page, selector: str, path: str) -> None:
|
|
811
|
+
"""Sets the file and waits for its upload: a resume's import then rewrites the personal
|
|
812
|
+
information, so nothing is typed before it is done."""
|
|
813
|
+
with (
|
|
814
|
+
suppress(Timeout),
|
|
815
|
+
page.expect_response(
|
|
816
|
+
lambda r: "/FileService/" in r.url and r.request.method == "POST",
|
|
817
|
+
timeout=UPLOAD_TIMEOUT_MS,
|
|
818
|
+
),
|
|
819
|
+
):
|
|
820
|
+
page.locator(selector).first.set_input_files(path)
|
|
821
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _open_entry(page: Page, prefix: str) -> str:
|
|
825
|
+
"""Adds the first entry of a history section, unless it is open; its controls' id prefix."""
|
|
826
|
+
base = f"{FORM_ID}{prefix}_0_"
|
|
827
|
+
if not page.locator(f"[id^='{base}']").count():
|
|
828
|
+
page.locator(f"[test-id=add-{prefix.lower()}-record]").first.click()
|
|
829
|
+
page.locator(f"[id^='{base}']").first.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
830
|
+
return base
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _update(page: Page, field: str) -> None:
|
|
834
|
+
"""Saves an open section with its Update button, the first after the section's `field`,
|
|
835
|
+
until the section closes: a click while a dropdown is still closing is lost."""
|
|
836
|
+
for _ in range(UPDATE_TRIES):
|
|
837
|
+
first = page.locator(field).first
|
|
838
|
+
if not first.count():
|
|
839
|
+
return
|
|
840
|
+
button = first.locator("xpath=following::button[@type='submit'][1]")
|
|
841
|
+
page.keyboard.press("Escape")
|
|
842
|
+
button.evaluate("e => e.scrollIntoView({block: 'center'})")
|
|
843
|
+
button.click()
|
|
844
|
+
with suppress(Timeout):
|
|
845
|
+
first.wait_for(state="detached", timeout=STEP_TIMEOUT_MS // 4)
|
|
846
|
+
return
|
|
847
|
+
if page.locator(".ant-form-item-explain-error:visible").count():
|
|
848
|
+
return # the section refuses its answers: Next reports the stop
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _next(page: Page) -> bool:
|
|
852
|
+
"""Goes to the next step; False when the page stays (a validation error), or when the
|
|
853
|
+
next button is Submit, which `fill` never presses."""
|
|
854
|
+
button = page.locator(NEXT)
|
|
855
|
+
if not button.count() or not re.search(r"next", button.first.inner_text(), re.IGNORECASE):
|
|
856
|
+
return False
|
|
857
|
+
before = _controls(page)
|
|
858
|
+
for _ in range(NEXT_TRIES): # a click right after a section's save can go unheeded
|
|
859
|
+
if not button.count():
|
|
860
|
+
return _controls(page) != before
|
|
861
|
+
button.first.click()
|
|
862
|
+
for _ in range(STEP_POLLS):
|
|
863
|
+
page.wait_for_timeout(POLL_MS)
|
|
864
|
+
if _controls(page) != before:
|
|
865
|
+
page.wait_for_timeout(POLL_MS) # the step renders its controls in turn
|
|
866
|
+
return True
|
|
867
|
+
if page.locator(".ant-form-item-explain-error:visible").count():
|
|
868
|
+
return False
|
|
869
|
+
return False
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _controls(page: Page) -> list[str]:
|
|
873
|
+
"""The ids of the step's controls: what changes when the wizard moves on, not a control's
|
|
874
|
+
error (`<id>_help`) showing."""
|
|
875
|
+
return page.locator(f"main [id^='{FORM_ID}']:not([id$='_help'])").evaluate_all(
|
|
876
|
+
"es => es.map(e => e.id)"
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _questionnaire_title(page: Page) -> str | None:
|
|
881
|
+
"""The current questionnaire's name, under its "Questionnaire n of m" counter."""
|
|
882
|
+
text = page.locator("main").inner_text()
|
|
883
|
+
match = re.search(r"(?m)^\s*[^\W\d]+ \d+ [^\W\d]+ \d+\s*$\n+\s*([^\n]+)", text)
|
|
884
|
+
return match[1].strip() if match else None
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _question_target(page: Page, base: str, question: dict[str, Any]) -> Locator | None:
|
|
888
|
+
"""The control of one question: a select's input, a radio or box group, a text, a box."""
|
|
889
|
+
suffix = {TEXT: "answerText", MULTI: "selectedOptionIds", CHECKBOX: "isChecked"}
|
|
890
|
+
target = page.locator(f"[id='{base}{suffix.get(question['answerType'], 'selectedOption')}']")
|
|
891
|
+
return target.first if target.count() else None
|
|
@@ -135,7 +135,9 @@ kind = "ats"
|
|
|
135
135
|
share = 0.0165
|
|
136
136
|
account = "none"
|
|
137
137
|
hosting = ["hosted"]
|
|
138
|
+
adapter = "dayforce"
|
|
138
139
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
140
|
+
dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "b3f33ce" }
|
|
139
141
|
|
|
140
142
|
[workable]
|
|
141
143
|
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
|