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.
Files changed (34) hide show
  1. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/README.md +1 -1
  3. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev33/src/atsfill/adapters/dayforce.py +891 -0
  7. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/registry.toml +2 -0
  8. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/greenhouse.py +0 -0
  12. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/hireology.py +0 -0
  13. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/jazzhr.py +0 -0
  14. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/lever.py +0 -0
  15. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/manatal.py +0 -0
  16. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/oracle.py +0 -0
  17. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/paycor.py +0 -0
  18. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/paylocity.py +0 -0
  19. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/smartrecruiters.py +0 -0
  20. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/teamtailor.py +0 -0
  21. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/workable.py +0 -0
  22. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/adapters/wttj.py +0 -0
  23. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/cli.py +0 -0
  24. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/__init__.py +0 -0
  25. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/accounts.py +0 -0
  26. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/adapter.py +0 -0
  27. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/attempts.py +0 -0
  28. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/browser.py +0 -0
  29. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/commands.py +0 -0
  30. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/matching.py +0 -0
  31. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/paths.py +0 -0
  32. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/registry.py +0 -0
  33. {atsfill-0.0.4.dev32 → atsfill-0.0.4.dev33}/src/atsfill/core/results.py +0 -0
  34. {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.dev32
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
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
56
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
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
- ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
32
+ ![oracle: dry run verified 2026-10-20](https://img.shields.io/badge/oracle-dry%20run%20verified%202026--10--20-7aa2f7) ![smartrecruiters: dry run verified 2026-10-20](https://img.shields.io/badge/smartrecruiters-dry%20run%20verified%202026--10--20-7aa2f7) ![greenhouse: dry run verified 2026-10-19](https://img.shields.io/badge/greenhouse-dry%20run%20verified%202026--10--19-7aa2f7) ![manatal: dry run verified 2026-10-20](https://img.shields.io/badge/manatal-dry%20run%20verified%202026--10--20-7aa2f7) ![paylocity: dry run verified 2026-10-20](https://img.shields.io/badge/paylocity-dry%20run%20verified%202026--10--20-7aa2f7) ![dayforce: dry run verified 2026-10-20](https://img.shields.io/badge/dayforce-dry%20run%20verified%202026--10--20-7aa2f7) ![workable: dry run verified 2026-10-20](https://img.shields.io/badge/workable-dry%20run%20verified%202026--10--20-7aa2f7) ![ashby: send verified 2026-10-19](https://img.shields.io/badge/ashby-send%20verified%202026--10--19-9ece6a) ![teamtailor: send verified 2026-10-20](https://img.shields.io/badge/teamtailor-send%20verified%202026--10--20-9ece6a) ![jazzhr: dry run verified 2026-10-20](https://img.shields.io/badge/jazzhr-dry%20run%20verified%202026--10--20-7aa2f7) ![lever: dry run verified 2026-10-20](https://img.shields.io/badge/lever-dry%20run%20verified%202026--10--20-7aa2f7) ![hireology: dry run verified 2026-10-20](https://img.shields.io/badge/hireology-dry%20run%20verified%202026--10--20-7aa2f7) ![paycor: dry run verified 2026-10-20](https://img.shields.io/badge/paycor-dry%20run%20verified%202026--10--20-7aa2f7) ![wttj: send verified 2026-10-20](https://img.shields.io/badge/wttj-send%20verified%202026--10--20-9ece6a)
33
33
  <!-- /badges -->
34
34
 
35
35
  | System | Account needed | `read` without a browser |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev32"
3
+ version = "0.0.4.dev33"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.4.dev32"
3
+ version = "0.0.4.dev33"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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