atsfill 0.0.4.dev24__tar.gz → 0.0.4.dev25__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 (28) hide show
  1. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/PKG-INFO +2 -2
  2. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/README.md +1 -1
  3. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/pyproject.toml +1 -1
  4. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.4.dev25/src/atsfill/adapters/paylocity.py +632 -0
  7. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/registry.toml +2 -0
  8. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/LICENSE +0 -0
  9. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/__init__.py +0 -0
  10. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/adapters/ashby.py +0 -0
  11. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/adapters/greenhouse.py +0 -0
  12. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/adapters/manatal.py +0 -0
  13. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/adapters/oracle.py +0 -0
  14. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/adapters/smartrecruiters.py +0 -0
  15. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/adapters/teamtailor.py +0 -0
  16. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/adapters/wttj.py +0 -0
  17. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/cli.py +0 -0
  18. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/core/__init__.py +0 -0
  19. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/core/accounts.py +0 -0
  20. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/core/adapter.py +0 -0
  21. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/core/attempts.py +0 -0
  22. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/core/browser.py +0 -0
  23. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/core/commands.py +0 -0
  24. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/core/matching.py +0 -0
  25. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/core/paths.py +0 -0
  26. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/core/registry.py +0 -0
  27. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/src/atsfill/core/results.py +0 -0
  28. {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev25}/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.dev24
3
+ Version: 0.0.4.dev25
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) ![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) ![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) ![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) ![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) ![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) ![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) ![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) ![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.dev24"
3
+ version = "0.0.4.dev25"
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.dev24"
3
+ version = "0.0.4.dev25"
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"
@@ -2,6 +2,7 @@ from atsfill.adapters.ashby import Ashby
2
2
  from atsfill.adapters.greenhouse import Greenhouse
3
3
  from atsfill.adapters.manatal import Manatal
4
4
  from atsfill.adapters.oracle import Oracle
5
+ from atsfill.adapters.paylocity import Paylocity
5
6
  from atsfill.adapters.smartrecruiters import SmartRecruiters
6
7
  from atsfill.adapters.teamtailor import Teamtailor
7
8
  from atsfill.adapters.wttj import WelcomeToTheJungle
@@ -15,4 +16,5 @@ ADAPTERS: list[Adapter] = [
15
16
  SmartRecruiters(),
16
17
  Oracle(),
17
18
  Manatal(),
19
+ Paylocity(),
18
20
  ]
@@ -0,0 +1,632 @@
1
+ """Paylocity Recruiting (`recruiting.paylocity.com/Recruiting/Jobs/...`), as
2
+ docs/adapters/paylocity.md observes.
3
+
4
+ The apply page carries the whole form's configuration in `window.pageData`: the sections and
5
+ their fields, the screener questions and the option lists. `read_api` fetches it over HTTP,
6
+ `read_form` takes it from the page, and both list the fields from it. The form is one page or
7
+ a wizard of steps that render one at a time, so `fill` keeps each step's read-back before
8
+ moving on; nothing reaches the employer before Submit.
9
+ """
10
+
11
+ import json
12
+ import re
13
+ import urllib.request
14
+ from contextlib import suppress
15
+ from dataclasses import dataclass
16
+ from datetime import date
17
+ from pathlib import Path
18
+ from typing import Any
19
+ from urllib.parse import urlsplit
20
+
21
+ from atsfill.core.adapter import Unsupported
22
+ from atsfill.core.browser import Locator, Page, Timeout
23
+ from atsfill.core.results import Answer, Field, FieldType, SideEffect
24
+
25
+ HOST = "recruiting.paylocity.com"
26
+ JOB = re.compile(r"^/Recruiting/Jobs/(?:Details|Apply)/(\d+)", re.IGNORECASE)
27
+ APPLY = f"https://{HOST}/Recruiting/Jobs/Apply/"
28
+ PAGE_DATA = "window.pageData ="
29
+ NEXT = "[data-automation-id=btnNext]"
30
+ SUBMIT = "[data-automation-id=btnSubmit]"
31
+ STEP = "#appDetailDiv .type-bold.float-right" # "Step 2 of 5"
32
+ FORCED_RESUME = ".modal.in #btn-forceResume"
33
+ FORM_TIMEOUT_MS = 30_000
34
+ STEP_TIMEOUT_MS = 20_000
35
+ UPLOAD_TIMEOUT_MS = 30_000
36
+ SUBMIT_TIMEOUT_MS = 90_000
37
+ YES_NO = ["Yes", "No"]
38
+ ADDRESS = "public-site-address" # an address block: its inputs' ids start with this
39
+ COMPANY = "public-candidate-work-history-address-0"
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Spec:
44
+ """One control a pageData field renders as. `required` None takes the field's own."""
45
+
46
+ dom: str # the element id; a radio group's name; `{n}` for an entry's index
47
+ label: str
48
+ kind: str # a key of TYPES
49
+ required: bool | None = None
50
+ options: str | list[str] | None = None # a list, or the pageData key of one
51
+ when: tuple[str, str] | None = None # asked only when (control, answer)
52
+ max_length: int | None = None
53
+
54
+
55
+ def _yes_no(dom: str, label: str) -> Spec:
56
+ return Spec(dom, label, "dropdown", options=YES_NO)
57
+
58
+
59
+ WORKED_BEFORE = ("info.haveYouWorkedWithUsBefore", "Yes")
60
+ INFO: dict[str, list[Spec]] = {
61
+ "name": [
62
+ Spec("info.firstName", "First Name", "text", True),
63
+ Spec("info.lastName", "Last Name", "text", True),
64
+ Spec("info.middleName", "Middle Name", "text", False),
65
+ Spec("info.preferredName", "Preferred First Name", "text", False),
66
+ ],
67
+ "emailAddress": [Spec("info.email", "Email Address", "text")],
68
+ "cellPhone": [Spec("info.cellPhone", "Mobile Number", "phone")],
69
+ "homePhone": [Spec("info.phone", "Home Phone Number", "phone")],
70
+ "smsOptedIn": [_yes_no("info.smsOptedIn", "")],
71
+ "appliedBefore": [_yes_no("info.haveYouAppliedWithUsBefore", "")],
72
+ "workedHereBefore": [
73
+ _yes_no(WORKED_BEFORE[0], ""),
74
+ Spec("info.priorEmploymentPositionHeld", "Position", "text", False, when=WORKED_BEFORE),
75
+ Spec("info.priorEmploymentStartDate", "Start Date", "date", False, when=WORKED_BEFORE),
76
+ Spec("info.priorEmploymentEndDate", "End Date", "date", False, when=WORKED_BEFORE),
77
+ ],
78
+ "address": [
79
+ Spec(f"{ADDRESS}-address-1", "Address Line 1", "address", max_length=50),
80
+ Spec(f"{ADDRESS}-county", "County", "text", max_length=50),
81
+ ],
82
+ "address2": [Spec(f"{ADDRESS}-address-2", "Address Line 2", "text", max_length=50)],
83
+ "city": [Spec(f"{ADDRESS}-city", "City", "text", max_length=50)],
84
+ "state": [Spec(f"{ADDRESS}-us-state", "State", "state", options="statesOptions")],
85
+ "postalCode": [Spec(f"{ADDRESS}-zip", "Zip Code", "text", max_length=20)],
86
+ "dateAvailable": [Spec("info.dateAvailableToStart", "Available to Start", "date")],
87
+ "linkedInUrl": [Spec("info.linkedIn", "LinkedIn Profile URL", "text")],
88
+ "howDidYouHear": [Spec("info.howDidYouHearAboutUs", "", "radio", options="values")],
89
+ "referredBy": [Spec("info.referredBy", "Referred by", "text")],
90
+ "skills": [Spec("info.skills", "Skills", "tags")],
91
+ "uploadCoverLetter": [Spec("btn-coverLetter", "Cover Letter", "file")],
92
+ "uploadAdditionalFiles": [Spec("btn-additionalFiles", "Additional Files", "file")],
93
+ }
94
+ SALARY = { # by desiredSalaryInputType
95
+ 1: [
96
+ Spec("info.minimumDesiredSalary", "Desired Salary (minimum)", "text"),
97
+ Spec("info.maximumDesiredSalary", "Desired Salary (maximum)", "text"),
98
+ ],
99
+ 2: [Spec("info.desiredSalaryDescription", "Desired Salary", "text")],
100
+ }
101
+ # Shown on their own: the country (United States until changed), the EEO identity questions.
102
+ OPTIONAL_ONLY = {"country", "dni", "ethnicity", "genderIdentity", "personalPronoun",
103
+ "sexualOrientation", "eVerify", "priorConviction"} # fmt: skip
104
+ CONTACT = ("workHistory.mayWeContactSupervisor.0", "Yes")
105
+ GRADUATED = ("educationHistory.didYouGraduate.0", "Yes")
106
+ WORK: dict[str, list[Spec]] = {
107
+ "companyName": [Spec("workHistory.companyName.0", "Company Name", "text")],
108
+ "position": [Spec("workHistory.position.0", "Position", "text")],
109
+ "companyPhone": [Spec("workHistory.companyPhone.0", "Company Phone Number", "phone")],
110
+ "responsibilities": [Spec("workHistory.responsibilities.0", "Responsibilities", "text")],
111
+ "startEndDate": [
112
+ Spec("txt-workHistory-startDate-0", "Start Date (MM/YYYY)", "month"),
113
+ Spec("txt-workHistory-endDate-0", "End Date (MM/YYYY)", "month"),
114
+ ],
115
+ "reasonForLeaving": [Spec("workHistory.reasonForLeaving.0", "Reason for Leaving", "text")],
116
+ "companyLocation": [
117
+ Spec(f"{COMPANY}-address-1", "Company Address Line 1", "address", max_length=50),
118
+ Spec(f"{COMPANY}-address-2", "Company Address Line 2", "text", False, max_length=50),
119
+ Spec(f"{COMPANY}-city", "Company City", "text", max_length=50),
120
+ Spec(f"{COMPANY}-county", "Company County", "text", max_length=50),
121
+ Spec(f"{COMPANY}-us-state", "Company State", "state", options="statesOptions"),
122
+ Spec(f"{COMPANY}-zip", "Company Zip Code", "text", max_length=20),
123
+ ],
124
+ "currentlyWorkingHere": [
125
+ Spec(
126
+ "workHistory.currentlyWorkingHere.0",
127
+ "Currently Working Here",
128
+ "check",
129
+ False,
130
+ ["No", "Yes"],
131
+ ) # fmt: skip
132
+ ],
133
+ "mayWeContact": [_yes_no(CONTACT[0], "May We Contact for Reference?")],
134
+ "contactSupervisorName": [
135
+ Spec("workHistory.supervisorName.0", "Supervisor Name", "text", False, when=CONTACT)
136
+ ],
137
+ "contactSupervisorPosition": [
138
+ Spec("workHistory.supervisorPosition.0", "Supervisor Position", "text", False, when=CONTACT)
139
+ ],
140
+ "contactSupervisorPhone": [
141
+ Spec(
142
+ "workHistory.supervisorPhone.0", "Supervisor Phone Number", "phone", False, when=CONTACT
143
+ )
144
+ ], # fmt: skip
145
+ "contactSupervisorEmail": [
146
+ Spec(
147
+ "workHistory.supervisorEmail.0", "Supervisor Email Address", "text", False, when=CONTACT
148
+ )
149
+ ], # fmt: skip
150
+ }
151
+ EDUCATION: dict[str, list[Spec]] = {
152
+ "certsOrAwards": [
153
+ Spec("educationHistory.certificationsAndAwards", "Certifications & Awards", "tags")
154
+ ],
155
+ "schoolName": [Spec("educationHistory.name.0", "School Name", "text")],
156
+ "areaOfStudy": [Spec("educationHistory.areaOfStudy.0", "Area of Study", "text")],
157
+ "didYouGraduate": [
158
+ Spec(GRADUATED[0], "Did you Graduate?", "dropdown", options="graduationOptions")
159
+ ],
160
+ "degreeObtained": [
161
+ Spec(
162
+ "educationHistory.degreeId.0",
163
+ "Degree Obtained",
164
+ "dropdown",
165
+ False,
166
+ "degreeOptions",
167
+ GRADUATED,
168
+ )
169
+ ], # fmt: skip
170
+ "schoolType": [
171
+ Spec("educationHistory.type.0", "School Type", "dropdown", options="schoolTypeOptions")
172
+ ],
173
+ "schoolLocation": [
174
+ Spec("educationHistory.city.0", "School City", "text"),
175
+ Spec("educationHistory.state.0", "School State", "dropdown", options="statesOptions"),
176
+ ],
177
+ }
178
+ REFERENCE: dict[str, list[Spec]] = {
179
+ "referenceName": [Spec("references.name.{n}", "Name", "text")],
180
+ "referenceEmail": [Spec("references.email.{n}", "Email Address", "text")],
181
+ "referencePhoneNumber": [Spec("references.phone.{n}", "Phone Number", "phone")],
182
+ "referencePersonalOrWork": [
183
+ Spec(
184
+ "references.referenceType.{n}",
185
+ "Personal or Work Reference?",
186
+ "dropdown",
187
+ options="referenceTypeOptions",
188
+ )
189
+ ], # fmt: skip
190
+ "referenceYearsKnown": [
191
+ Spec(
192
+ "references.yearsKnown.{n}",
193
+ "Years Known",
194
+ "dropdown",
195
+ options=["1 year", *(f"{n} years" for n in range(2, 6))],
196
+ )
197
+ ], # fmt: skip
198
+ }
199
+ ACKNOWLEDGEMENTS: dict[str, list[Spec]] = {
200
+ "eeoGenderEthnicity": [
201
+ Spec("acknowledgements.eeoGender", "Gender", "dropdown", options="genderOptions"),
202
+ Spec(
203
+ "acknowledgements.racialOrEthnicGroup",
204
+ "Race and/or Ethnicity",
205
+ "dropdown",
206
+ options="raceOptions",
207
+ ),
208
+ ], # fmt: skip
209
+ "ofccp": [
210
+ Spec(
211
+ "acknowledgements.militaryService",
212
+ "Veteran Status",
213
+ "dropdown",
214
+ options="militaryServiceOptions",
215
+ )
216
+ ], # fmt: skip
217
+ "eeoDisability": [
218
+ Spec(
219
+ "acknowledgements.disability",
220
+ "Disability Status",
221
+ "dropdown",
222
+ options="disabilityOptions",
223
+ )
224
+ ], # fmt: skip
225
+ "priorFelony": [_yes_no("acknowledgements.priorFelony", "")],
226
+ "priorMisdemeanor": [_yes_no("acknowledgements.priorMisdemeanor", "")],
227
+ "authorizedToWork": [_yes_no("acknowledgements.authorizedToWorkInUs", "")],
228
+ }
229
+ # Fields shown only when the form also has another, which switches them on.
230
+ SHOWN_WITH = {
231
+ "priorFelony": "priorConviction",
232
+ "priorMisdemeanor": "priorConviction",
233
+ **{f"contactSupervisor{k}": "mayWeContact" for k in ("Name", "Position", "Phone", "Email")},
234
+ }
235
+ SECTIONS = {
236
+ "info": INFO,
237
+ "workHistory": WORK,
238
+ "educationHistory": EDUCATION,
239
+ "references": REFERENCE,
240
+ "acknowledgements": ACKNOWLEDGEMENTS,
241
+ "expandedIdentityQuestions": {},
242
+ }
243
+ ENTRIES = ("workHistory", "educationHistory") # one entry shown when required, none else
244
+ ENTRY = re.compile(r"[.-]0(-|$)") # an entry's control, by its index
245
+ RESUME = Spec("btn-resume", "Resume", "file")
246
+ ACKNOWLEDGE = Spec("applyAcknowledgement", "I have read and accept the above acknowledgement",
247
+ "check", True, YES_NO) # fmt: skip
248
+ FILE_IDS = {"btn-resume": "resume", "btn-coverLetter": "coverLetter",
249
+ "btn-additionalFiles": "additionalFiles"} # fmt: skip
250
+ TYPES = {
251
+ "text": FieldType.TEXT,
252
+ "address": FieldType.TEXT,
253
+ "month": FieldType.TEXT,
254
+ "tags": FieldType.TEXT,
255
+ "phone": FieldType.PHONE,
256
+ "date": FieldType.DATE,
257
+ "dropdown": FieldType.CHOICE,
258
+ "state": FieldType.CHOICE,
259
+ "radio": FieldType.CHOICE,
260
+ "check": FieldType.CHOICE,
261
+ "screener": FieldType.CHOICE,
262
+ "screener_multi": FieldType.MULTI_CHOICE,
263
+ "screener_text": FieldType.TEXT,
264
+ "file": FieldType.FILE,
265
+ }
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class Control:
270
+ kind: str
271
+ dom: str
272
+ options: list[str] | None = None
273
+
274
+
275
+ class Paylocity:
276
+ name = "paylocity"
277
+ side_effects = [
278
+ SideEffect("upload", "hiring_system", "an attached file is stored by Paylocity at once"),
279
+ SideEffect("fill", "hiring_system", "each step's answers go to Paylocity for validation"),
280
+ SideEffect("submit", "employer", "the application reaches the employer"),
281
+ ] # fmt: skip
282
+
283
+ def __init__(self) -> None:
284
+ self._form: list[tuple[Field, Control]] = []
285
+ self._values: dict[str, Answer] = {} # the read-back of the steps left behind
286
+
287
+ def detect(self, url: str, page: Page | None) -> bool:
288
+ return _job(url) is not None
289
+
290
+ def posting(self, url: str) -> str:
291
+ job = _job(url)
292
+ return f"https://{HOST}/Recruiting/Jobs/Details/{job}" if job else url
293
+
294
+ def read_api(self, url: str) -> list[Field] | None:
295
+ if (job := _job(url)) is None:
296
+ return None
297
+ request = urllib.request.Request(APPLY + job, headers={"User-Agent": "atsfill"})
298
+ try:
299
+ with urllib.request.urlopen(request, timeout=20) as response:
300
+ html = response.read().decode()
301
+ return [f for f, _ in _form(_page_data(html))]
302
+ except OSError, ValueError, KeyError, TypeError, Unsupported:
303
+ return None
304
+
305
+ def read_form(self, page: Page) -> list[Field]:
306
+ job = _job(page.url)
307
+ if job and "/apply/" not in page.url.lower():
308
+ page.goto(APPLY + job)
309
+ try:
310
+ page.locator(f"{NEXT}, {SUBMIT}").first.wait_for(
311
+ state="attached", timeout=FORM_TIMEOUT_MS
312
+ )
313
+ self._form = _form(_page_data(page.content()))
314
+ except Timeout, ValueError, KeyError:
315
+ raise Unsupported("posting_gone: no application form on the page") from None
316
+ return [f for f, _ in self._form]
317
+
318
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
319
+ self._values = {}
320
+ page.locator("form, #app").first.wait_for(timeout=FORM_TIMEOUT_MS)
321
+ _dismiss_cookies(page)
322
+ if "resume" in answers: # first: a parsed resume fills fields the answers then override
323
+ _upload_resume(page, _one(answers["resume"]))
324
+ while True:
325
+ for field, control in self._form:
326
+ if field.id in answers and field.id != "resume" and _present(page, control):
327
+ _fill(page, control, answers[field.id])
328
+ self._values.update(self._read(page))
329
+ if not page.locator(NEXT).count() or not _next(page):
330
+ return
331
+
332
+ def read_back(self, page: Page) -> dict[str, Answer]:
333
+ values = {**self._values, **self._read(page)}
334
+ return {k: v for k, v in values.items() if v not in (None, "", [])}
335
+
336
+ def submit(self, page: Page) -> str | None:
337
+ before = page.url
338
+ page.locator(SUBMIT).click()
339
+ page.wait_for_url(lambda url: url != before, timeout=SUBMIT_TIMEOUT_MS)
340
+ return page.url
341
+
342
+ def _read(self, page: Page) -> dict[str, Answer]:
343
+ values: dict[str, Answer] = {}
344
+ for field, control in self._form:
345
+ if _present(page, control) and (value := _read(page, control)) is not None:
346
+ values[field.id] = value
347
+ return values
348
+
349
+
350
+ def _job(url: str) -> str | None:
351
+ parts = urlsplit(url)
352
+ if parts.hostname != HOST or not (match := JOB.match(parts.path)):
353
+ return None
354
+ return match[1]
355
+
356
+
357
+ def _page_data(html: str) -> dict[str, Any]:
358
+ """The apply page's `window.pageData`, from its inline script."""
359
+ start = html.index(PAGE_DATA) + len(PAGE_DATA)
360
+ return json.JSONDecoder().raw_decode(html[start:].lstrip())[0]
361
+
362
+
363
+ def _form(data: dict[str, Any]) -> list[tuple[Field, Control]]:
364
+ """The fields and their controls, in the page's order, from the apply page's pageData."""
365
+ custom = data["customJobApplication"]
366
+ out: list[tuple[Field, Control]] = []
367
+ labels: dict[str, str] = {}
368
+
369
+ def add(spec: Spec, field: dict[str, Any], prefix: str = "", n: int = 0) -> None:
370
+ dom = spec.dom.format(n=n)
371
+ options = spec.options
372
+ if options == "values":
373
+ options = [v["value"] for v in field["values"]]
374
+ elif isinstance(options, str):
375
+ listed = [o for o in data.get(options) or [] if o.get("text")]
376
+ options = [o["value" if spec.kind == "state" else "text"] for o in listed]
377
+ label = prefix + (spec.label or _plain(field["displayName"]))
378
+ required = field["isRequired"] if spec.required is None else spec.required
379
+ if spec.when:
380
+ trigger, value = spec.when
381
+ label += f" (asked when {labels.get(trigger, trigger)!r} is {value})"
382
+ labels[dom] = label
383
+ field_id = FILE_IDS.get(dom, dom)
384
+ out.append((Field(field_id, label, TYPES[spec.kind], bool(required), options,
385
+ spec.max_length),
386
+ Control(spec.kind, dom, options))) # fmt: skip
387
+
388
+ add(RESUME, {"isRequired": bool(custom.get("requireResume") or data.get("requireResume"))})
389
+ for section in custom["sections"]:
390
+ if not section["isIncluded"]:
391
+ continue
392
+ name = section["name"]
393
+ entries = name not in ENTRIES or section["isOneRequired"]
394
+ count = 1
395
+ if name == "references":
396
+ if not custom.get("referencesRequired"):
397
+ continue
398
+ count = custom.get("referencesRequiredCount") or 1
399
+ vocabulary = SECTIONS.get(name)
400
+ if vocabulary is None:
401
+ raise Unsupported(f"form_shape: unknown section {name!r}")
402
+ included = {f["name"] for f in section["fields"] if f["isIncluded"]}
403
+ for n in range(count):
404
+ prefix = f"Reference {n + 1}: " if name == "references" else ""
405
+ for field in section["fields"]:
406
+ if not field["isIncluded"]:
407
+ continue
408
+ specs = vocabulary.get(field["name"])
409
+ if field["name"] == "desiredSalary":
410
+ specs = SALARY.get(custom.get("desiredSalaryInputType"))
411
+ if specs is None:
412
+ if entries and field["isRequired"] and field["name"] not in OPTIONAL_ONLY:
413
+ raise Unsupported(
414
+ f"form_shape: {_plain(field['displayName'])!r} ({field['name']}) "
415
+ "is not handled"
416
+ )
417
+ continue
418
+ if SHOWN_WITH.get(field["name"], field["name"]) not in included:
419
+ continue
420
+ for spec in specs:
421
+ if entries or not ENTRY.search(spec.dom): # the section's own fields show
422
+ add(spec, field, prefix, n)
423
+ screener = data.get("screener") or {}
424
+ for i, question in enumerate(screener.get("questions") or []):
425
+ kind = question.get("questionType")
426
+ answers = [a["title"] for a in question.get("answers") or []]
427
+ if kind == "multi":
428
+ kind = "screener_multi" if question.get("allowsMultipleResponses") else "screener"
429
+ dom = f"multiQuestionAnswer{question['screenerQuestionId']}"
430
+ elif kind == "text":
431
+ kind, answers, dom = "screener_text", None, f"screener-question-{i}-textarea"
432
+ else:
433
+ raise Unsupported(f"form_shape: a {kind} screener question")
434
+ field_id = f"screener.{question['screenerQuestionId']}"
435
+ out.append((Field(field_id, _plain(question["title"]), TYPES[kind],
436
+ bool(question.get("isRequired")), answers),
437
+ Control(kind, dom, answers))) # fmt: skip
438
+ add(ACKNOWLEDGE, {"isRequired": True})
439
+ return out
440
+
441
+
442
+ def _plain(text: str) -> str:
443
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", text or "")).strip()
444
+
445
+
446
+ def _one(answer: Answer) -> str:
447
+ return answer if isinstance(answer, str) else answer[0]
448
+
449
+
450
+ def _by_id(page: Page, dom: str) -> Locator:
451
+ """The control: the last step reviews the others' answers in elements with their ids."""
452
+ return page.locator(f"input[id='{dom}'], textarea[id='{dom}'], .rw-dropdownlist[id='{dom}']")
453
+
454
+
455
+ def _present(page: Page, control: Control) -> bool:
456
+ match control.kind:
457
+ case "radio":
458
+ return page.locator(f"input[name='{control.dom}']").count() > 0
459
+ case "screener" | "screener_multi":
460
+ label = page.locator(f"label[for='{control.dom}_0']")
461
+ return label.count() > 0 and label.first.is_visible()
462
+ case "file":
463
+ return _by_id(page, control.dom).count() > 0 or (
464
+ control.dom == "btn-resume" and page.locator("#remove-resume").count() > 0
465
+ )
466
+ case "state":
467
+ return page.locator(f"#{control.dom}-select-wrapper").count() > 0
468
+ case _:
469
+ target = _by_id(page, control.dom)
470
+ return target.count() == 1 and target.is_visible()
471
+
472
+
473
+ def _dismiss_cookies(page: Page) -> None:
474
+ close = page.locator("#onetrust-banner-sdk .onetrust-close-btn-handler")
475
+ with suppress(Timeout):
476
+ close.first.wait_for(state="visible", timeout=5_000)
477
+ close.first.click()
478
+
479
+
480
+ def _upload_resume(page: Page, path: str) -> None:
481
+ forced = page.locator(FORCED_RESUME)
482
+ if forced.count():
483
+ target = forced
484
+ else:
485
+ parse = page.locator("#useAttachedResumeToFillOutApplication")
486
+ if parse.count() and parse.is_checked():
487
+ parse.uncheck()
488
+ target = page.locator("#btn-resume")
489
+ if not target.count():
490
+ return
491
+ target.set_input_files(path)
492
+ with suppress(Timeout):
493
+ page.locator("#remove-resume").wait_for(timeout=UPLOAD_TIMEOUT_MS)
494
+ page.locator(".modal.in").wait_for(state="detached", timeout=UPLOAD_TIMEOUT_MS)
495
+
496
+
497
+ def _next(page: Page) -> bool:
498
+ """Goes on to the next step; False when the page stays (an answer it does not accept)."""
499
+ step = page.locator(STEP)
500
+ before = step.inner_text() if step.count() else None
501
+ page.locator(NEXT).click()
502
+ try:
503
+ page.wait_for_function(
504
+ """before => {
505
+ const s = document.querySelector("#appDetailDiv .type-bold.float-right");
506
+ return (s ? s.textContent : null) !== before;
507
+ }""",
508
+ arg=before,
509
+ timeout=STEP_TIMEOUT_MS,
510
+ )
511
+ except Timeout:
512
+ return False
513
+ page.wait_for_timeout(500)
514
+ return True
515
+
516
+
517
+ def _pick(page: Page, dom: str, value: str) -> None:
518
+ """A react-widgets dropdown: its list opens on a click."""
519
+ _by_id(page, dom).click()
520
+ options = page.locator(f"[id='{dom}__listbox'] [role=option]")
521
+ for n in range(options.count()):
522
+ if options.nth(n).inner_text().strip().rstrip("*") == value:
523
+ options.nth(n).click()
524
+ return
525
+ page.keyboard.press("Escape")
526
+
527
+
528
+ def _fill(page: Page, control: Control, answer: Answer) -> None:
529
+ value = _one(answer)
530
+ target = _by_id(page, control.dom)
531
+ match control.kind:
532
+ case "text" | "phone" | "screener_text":
533
+ target.fill(value)
534
+ target.blur()
535
+ case "address": # a suggestion list that drops keys typed while it loads
536
+ target.click()
537
+ target.fill("")
538
+ target.press_sequentially(value, delay=80)
539
+ page.keyboard.press("Escape")
540
+ if target.input_value() != value:
541
+ target.fill(value)
542
+ target.blur()
543
+ case "date":
544
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
545
+ y, m, d = value.split("-")
546
+ target.click()
547
+ target.press_sequentially(f"{m}/{d}/{y}", delay=30)
548
+ target.blur()
549
+ case "month":
550
+ target.click()
551
+ target.press_sequentially(value, delay=30)
552
+ target.blur()
553
+ case "dropdown":
554
+ _pick(page, control.dom, value)
555
+ case "state":
556
+ page.locator(f"#{control.dom}-select-wrapper").click()
557
+ page.keyboard.type(value, delay=40)
558
+ item = page.locator(
559
+ f"#{control.dom}-dropdown-list-container [role=listitem][title='{value}']"
560
+ )
561
+ try:
562
+ item.first.click(timeout=5_000)
563
+ except Timeout:
564
+ page.keyboard.press("Escape")
565
+ case "radio":
566
+ page.locator(f"input[name='{control.dom}'][value='{value}']").check(force=True)
567
+ case "check":
568
+ if target.is_checked() != (value == "Yes"):
569
+ target.check(force=True) if value == "Yes" else target.uncheck(force=True)
570
+ case "tags": # takes typed keys only
571
+ target.click()
572
+ target.press_sequentially(value, delay=20)
573
+ target.press("Enter")
574
+ case "file":
575
+ target.set_input_files(answer if isinstance(answer, list) else value)
576
+ listed = target.locator("xpath=..").locator("tbody td span")
577
+ with suppress(Timeout):
578
+ listed.filter(has_text=Path(value).name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
579
+ case "screener" | "screener_multi":
580
+ values = [answer] if isinstance(answer, str) else answer
581
+ for n, option in enumerate(control.options or []):
582
+ box = page.locator(f"[id='{control.dom}_{n}']")
583
+ if (option in values) != box.is_checked():
584
+ page.locator(f"label[for='{control.dom}_{n}']").click()
585
+
586
+
587
+ def _read(page: Page, control: Control) -> Answer | None:
588
+ target = _by_id(page, control.dom)
589
+ match control.kind:
590
+ case "date": # a native date input, else a masked MM/DD/YYYY one
591
+ value = target.input_value()
592
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
593
+ return value
594
+ digits = re.findall(r"\d+", value)
595
+ if len(digits) != 3:
596
+ return None
597
+ m, d, y = (int(x) for x in digits)
598
+ with suppress(ValueError):
599
+ return date(y, m, d).isoformat()
600
+ return None
601
+ case "dropdown":
602
+ text = target.locator(".rw-input").first.inner_text().strip().rstrip("*")
603
+ return "" if text == "--" else text
604
+ case "state":
605
+ text = page.locator(f"#{control.dom}-select-wrapper .input-select-input-single-value")
606
+ value = text.first.inner_text().strip() if text.count() else ""
607
+ return value if value in (control.options or []) else ""
608
+ case "radio":
609
+ checked = page.locator(f"input[name='{control.dom}']:checked")
610
+ return checked.first.get_attribute("value") if checked.count() else ""
611
+ case "check":
612
+ return "Yes" if target.is_checked() else "No"
613
+ case "tags":
614
+ tags = target.locator("xpath=..").locator(".react-tagsinput-tag")
615
+ return tags.first.inner_text().strip() if tags.count() else ""
616
+ case "file":
617
+ if control.dom == "btn-resume":
618
+ name = page.locator(".rounded-box:has(#remove-resume) .no-wrap")
619
+ return name.first.inner_text().strip() if name.count() else ""
620
+ names = target.locator("xpath=..").locator("tbody td span") # the files listed
621
+ return names.first.inner_text().strip() if names.count() else ""
622
+ case "screener" | "screener_multi":
623
+ picked = [
624
+ option
625
+ for n, option in enumerate(control.options or [])
626
+ if page.locator(f"[id='{control.dom}_{n}']").is_checked()
627
+ ]
628
+ if control.kind == "screener_multi":
629
+ return picked
630
+ return picked[0] if picked else ""
631
+ case _:
632
+ return target.input_value()
@@ -126,7 +126,9 @@ kind = "ats"
126
126
  share = 0.0167
127
127
  account = "none"
128
128
  hosting = ["hosted"]
129
+ adapter = "paylocity"
129
130
  mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
131
+ dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "d3edb25" }
130
132
 
131
133
  [dayforce]
132
134
  kind = "ats"
File without changes