atsfill 0.0.4.dev24__tar.gz → 0.0.4.dev26__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.dev24 → atsfill-0.0.4.dev26}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/README.md +1 -1
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/adapters/__init__.py +4 -0
- atsfill-0.0.4.dev26/src/atsfill/adapters/paylocity.py +632 -0
- atsfill-0.0.4.dev26/src/atsfill/adapters/workable.py +423 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/registry.toml +4 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/LICENSE +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev24 → atsfill-0.0.4.dev26}/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.dev26
|
|
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 |
|
|
@@ -2,8 +2,10 @@ 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
|
|
8
|
+
from atsfill.adapters.workable import Workable
|
|
7
9
|
from atsfill.adapters.wttj import WelcomeToTheJungle
|
|
8
10
|
from atsfill.core.adapter import Adapter
|
|
9
11
|
|
|
@@ -15,4 +17,6 @@ ADAPTERS: list[Adapter] = [
|
|
|
15
17
|
SmartRecruiters(),
|
|
16
18
|
Oracle(),
|
|
17
19
|
Manatal(),
|
|
20
|
+
Paylocity(),
|
|
21
|
+
Workable(),
|
|
18
22
|
]
|
|
@@ -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()
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Workable (`apply.workable.com` job pages and their application form), as
|
|
2
|
+
docs/adapters/workable.md observes.
|
|
3
|
+
|
|
4
|
+
Every field sits under an element whose `data-ui` is its id in Workable's form API, which
|
|
5
|
+
`read_api` reads. Nothing but an attached file reaches Workable before submit, so the
|
|
6
|
+
read-back is the page.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import urllib.request
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from datetime import date
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
from atsfill.core import browser
|
|
18
|
+
from atsfill.core.adapter import Unsupported
|
|
19
|
+
from atsfill.core.browser import Locator, Page, Timeout
|
|
20
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
21
|
+
|
|
22
|
+
HOSTS = re.compile(r"^(apply|[\w-]+)\.workable\.com$")
|
|
23
|
+
JOB = re.compile(r"^(?:/[^/]+)?/j/([0-9A-F]{10})\b")
|
|
24
|
+
API = "https://apply.workable.com/api/v1/jobs/{code}/form"
|
|
25
|
+
APPLIED = re.compile(r"/api/v1/jobs/[0-9A-F]{10}/apply$")
|
|
26
|
+
FORM = "form[data-ui=application-form]"
|
|
27
|
+
COOKIES = "[data-ui=cookie-consent-decline]"
|
|
28
|
+
FORM_TIMEOUT_MS = 30_000
|
|
29
|
+
COOKIE_TIMEOUT_MS = 5_000
|
|
30
|
+
OPTIONS_TIMEOUT_MS = 5_000
|
|
31
|
+
CHECK_TIMEOUT_MS = 3_000
|
|
32
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
33
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
34
|
+
YES_NO = ["Yes", "No"]
|
|
35
|
+
API_TYPES = {
|
|
36
|
+
"text": FieldType.TEXT,
|
|
37
|
+
"email": FieldType.TEXT,
|
|
38
|
+
"paragraph": FieldType.TEXT,
|
|
39
|
+
"number": FieldType.TEXT,
|
|
40
|
+
"phone": FieldType.PHONE,
|
|
41
|
+
"file": FieldType.FILE,
|
|
42
|
+
"date": FieldType.DATE,
|
|
43
|
+
"boolean": FieldType.CHOICE,
|
|
44
|
+
"dropdown": FieldType.CHOICE,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# The fields in page order: each outermost `data-ui` element of a section, by what it holds.
|
|
48
|
+
_READ = """form => {
|
|
49
|
+
const text = el => {
|
|
50
|
+
if (!el) return "";
|
|
51
|
+
const copy = el.cloneNode(true);
|
|
52
|
+
copy.querySelectorAll("small").forEach(e => e.remove());
|
|
53
|
+
return copy.textContent.replace(/\\s+/g, " ").trim();
|
|
54
|
+
};
|
|
55
|
+
const optionLabel = el =>
|
|
56
|
+
text(document.getElementById((el.getAttribute("aria-labelledby") || "").split(" ")[1]));
|
|
57
|
+
const found = [];
|
|
58
|
+
for (const section of form.querySelectorAll("[data-ui=section-fields]")) {
|
|
59
|
+
for (const el of section.querySelectorAll("[data-ui]:not([data-ui=calendar-icon])")) {
|
|
60
|
+
if (el.parentElement.closest("[data-ui]") !== section) continue;
|
|
61
|
+
const id = el.getAttribute("data-ui");
|
|
62
|
+
const wrapper = [...section.children].find(c => c.contains(el));
|
|
63
|
+
const named = el.matches("[aria-labelledby]") ? el : el.querySelector("[aria-labelledby]");
|
|
64
|
+
const labelId = (named?.getAttribute("aria-labelledby") || "").split(" ")[0];
|
|
65
|
+
const field = {id, label: text(document.getElementById(labelId) ||
|
|
66
|
+
document.getElementById(`${id}_label`)),
|
|
67
|
+
required: [...wrapper.querySelectorAll("strong")].some(s => s.textContent.trim() === "*")};
|
|
68
|
+
const input = el.matches("input, textarea") ? el : null;
|
|
69
|
+
if (el.querySelector("[data-ui=add-section]")) {
|
|
70
|
+
field.kind = "group";
|
|
71
|
+
field.required = !document.getElementById(`${id}_label`)?.querySelector("small");
|
|
72
|
+
} else if (input?.type === "file") {
|
|
73
|
+
field.kind = "file";
|
|
74
|
+
field.accept = input.accept ? input.accept.split(",").filter(a => a.startsWith(".")) : null;
|
|
75
|
+
} else if (el.querySelector("input[type=tel]")) field.kind = "phone";
|
|
76
|
+
else if (el.matches("[data-input-type=select]")) field.kind = "select";
|
|
77
|
+
else if (el.matches("[role=radiogroup]")) {
|
|
78
|
+
const radios = [...el.querySelectorAll("[role=radio]")];
|
|
79
|
+
const values = radios.map(r => r.querySelector("input").value);
|
|
80
|
+
const yesNo = values.length === 2 && values.includes("true") && values.includes("false");
|
|
81
|
+
field.kind = yesNo ? "yes_no" : "radio";
|
|
82
|
+
field.options = yesNo ? null : radios.map(optionLabel);
|
|
83
|
+
} else if (el.matches("[role=group]")) {
|
|
84
|
+
field.kind = "checkboxes";
|
|
85
|
+
field.options = [...el.querySelectorAll("[role=checkbox]")].map(optionLabel);
|
|
86
|
+
} else if (el.querySelector("input[type=checkbox]")) {
|
|
87
|
+
field.kind = "consent";
|
|
88
|
+
field.required = true; // unmarked, but the API's: it takes only a Yes
|
|
89
|
+
field.label = text(document.getElementById(labelId));
|
|
90
|
+
} else if (input && el.parentElement.querySelector("[data-ui=calendar-icon]")) {
|
|
91
|
+
field.kind = "date";
|
|
92
|
+
field.format = input.placeholder;
|
|
93
|
+
} else if (input) {
|
|
94
|
+
field.kind = input.id ? "text" : "number"; // a number input alone has no id
|
|
95
|
+
field.maxLength = input.maxLength > 0 ? input.maxLength : null;
|
|
96
|
+
} else field.kind = el.tagName.toLowerCase();
|
|
97
|
+
found.push(field);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return found;
|
|
101
|
+
}"""
|
|
102
|
+
|
|
103
|
+
# What each field shows: a choice its checked option's label, a file its attached name (a photo
|
|
104
|
+
# its preview's alt), the phone its country's dial code then its number.
|
|
105
|
+
_READ_BACK = """(form, kinds) => {
|
|
106
|
+
const text = el => el ? el.textContent.replace(/\\s+/g, " ").trim() : null;
|
|
107
|
+
const optionLabel = el =>
|
|
108
|
+
text(document.getElementById((el.getAttribute("aria-labelledby") || "").split(" ")[1]));
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const [id, kind] of Object.entries(kinds)) {
|
|
111
|
+
const el = form.querySelector(`[data-ui="${id}"]`);
|
|
112
|
+
if (!el) continue;
|
|
113
|
+
if (kind === "file") {
|
|
114
|
+
const zone = el.closest("[data-role=dropzone]");
|
|
115
|
+
const preview = zone?.querySelector("[data-role=preview] img"); // a photo, named by its alt
|
|
116
|
+
out[id] = preview ? preview.alt : text(zone?.querySelector("[data-id=filename]"));
|
|
117
|
+
} else if (kind === "phone") {
|
|
118
|
+
const dial = (el.querySelector("button[aria-label]")?.getAttribute("aria-label") || "")
|
|
119
|
+
.match(/\\((\\+\\d+)\\)/);
|
|
120
|
+
const number = el.querySelector("input[type=tel]").value;
|
|
121
|
+
out[id] = dial && number ? `${dial[1]} ${number}` : null;
|
|
122
|
+
} else if (kind === "yes_no") {
|
|
123
|
+
const value = el.querySelector("[role=radio][aria-checked=true] input")?.value;
|
|
124
|
+
out[id] = value === "true" ? "Yes" : value === "false" ? "No" : null;
|
|
125
|
+
} else if (kind === "radio") {
|
|
126
|
+
const radio = el.querySelector("[role=radio][aria-checked=true]");
|
|
127
|
+
out[id] = radio ? optionLabel(radio) : null;
|
|
128
|
+
} else if (kind === "checkboxes") {
|
|
129
|
+
out[id] = [...el.querySelectorAll("[role=checkbox][aria-checked=true]")].map(optionLabel);
|
|
130
|
+
} else if (kind === "consent") {
|
|
131
|
+
out[id] = el.querySelector("[role=checkbox]").getAttribute("aria-checked") === "true"
|
|
132
|
+
? "Yes" : "No";
|
|
133
|
+
} else if (kind === "select") {
|
|
134
|
+
out[id] = el.querySelector("[role=combobox]").value.trim() || null;
|
|
135
|
+
} else if (kind === "number") out[id] = el.value.replace(/,/g, "");
|
|
136
|
+
else out[id] = el.value;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Workable:
|
|
143
|
+
name = "workable"
|
|
144
|
+
side_effects = [
|
|
145
|
+
SideEffect("upload", "hiring_system", "an attached file is stored by Workable at once"),
|
|
146
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
def __init__(self) -> None:
|
|
150
|
+
self._kinds: dict[str, str] = {} # how the page renders each field
|
|
151
|
+
self._formats: dict[str, str] = {} # a date input's placeholder, MM/DD/YYYY or other
|
|
152
|
+
|
|
153
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
154
|
+
return _code(url) is not None
|
|
155
|
+
|
|
156
|
+
def posting(self, url: str) -> str:
|
|
157
|
+
return f"https://apply.workable.com/j/{_code(url) or url}"
|
|
158
|
+
|
|
159
|
+
def read_api(self, url: str) -> list[Field] | None:
|
|
160
|
+
"""The fields from the form API the page itself loads, unless it has a required group."""
|
|
161
|
+
code = _code(url)
|
|
162
|
+
if code is None:
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
request = urllib.request.Request(
|
|
166
|
+
API.format(code=code), headers={"User-Agent": "atsfill"}
|
|
167
|
+
)
|
|
168
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
169
|
+
return _api_fields(json.load(response))
|
|
170
|
+
except OSError, ValueError, KeyError, TypeError:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
174
|
+
if "/apply" not in urlsplit(page.url).path:
|
|
175
|
+
page.goto(f"{self.posting(page.url)}/apply/")
|
|
176
|
+
form = page.locator(FORM)
|
|
177
|
+
try:
|
|
178
|
+
form.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
179
|
+
except Timeout:
|
|
180
|
+
raise Unsupported("posting_gone: no application form on the page") from None
|
|
181
|
+
_decline_cookies(page)
|
|
182
|
+
fields, self._kinds, self._formats = [], {}, {}
|
|
183
|
+
for raw in form.evaluate(_READ):
|
|
184
|
+
field = self._field(page, raw)
|
|
185
|
+
if field is not None:
|
|
186
|
+
fields.append(field)
|
|
187
|
+
self._kinds[field.id] = raw["kind"]
|
|
188
|
+
if raw["kind"] == "date":
|
|
189
|
+
self._formats[field.id] = raw["format"]
|
|
190
|
+
return fields
|
|
191
|
+
|
|
192
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
193
|
+
_decline_cookies(page, wait=False)
|
|
194
|
+
# attaching the resume empties the photo, so it goes first
|
|
195
|
+
for field_id, answer in sorted(answers.items(), key=lambda a: a[0] != "resume"):
|
|
196
|
+
values = [answer] if isinstance(answer, str) else answer
|
|
197
|
+
field = page.locator(f"{FORM} [data-ui='{field_id}']").first
|
|
198
|
+
match self._kinds[field_id]:
|
|
199
|
+
case "file": # a photo goes through a crop dialog first
|
|
200
|
+
field.set_input_files(values[0])
|
|
201
|
+
zone = field.locator("xpath=ancestor::*[@data-role='dropzone']")
|
|
202
|
+
crop = page.locator("button[data-ui=crop-image]")
|
|
203
|
+
with suppress(Timeout):
|
|
204
|
+
zone.locator("[data-id=filename]").or_(crop).first.wait_for(
|
|
205
|
+
timeout=UPLOAD_TIMEOUT_MS
|
|
206
|
+
)
|
|
207
|
+
if crop.is_visible():
|
|
208
|
+
crop.click()
|
|
209
|
+
crop.wait_for(state="hidden", timeout=UPLOAD_TIMEOUT_MS)
|
|
210
|
+
case "phone": # the number's dial code picks the country
|
|
211
|
+
number = field.locator("input[type=tel]")
|
|
212
|
+
number.fill("")
|
|
213
|
+
number.press_sequentially(values[0], delay=20)
|
|
214
|
+
case "date":
|
|
215
|
+
if typed := _typed_date(values[0], self._formats[field_id]):
|
|
216
|
+
field.click()
|
|
217
|
+
field.press_sequentially(typed, delay=20)
|
|
218
|
+
field.press("Tab")
|
|
219
|
+
case "yes_no":
|
|
220
|
+
value = {"Yes": "true", "No": "false"}.get(values[0])
|
|
221
|
+
radio = field.locator("[role=radio]").filter(
|
|
222
|
+
has=page.locator(f"input[value='{value}']")
|
|
223
|
+
)
|
|
224
|
+
if value and radio.count():
|
|
225
|
+
_check(radio, True)
|
|
226
|
+
case "radio":
|
|
227
|
+
_pick(page, field, "[role=radio]", values[0])
|
|
228
|
+
case "checkboxes":
|
|
229
|
+
for value in values:
|
|
230
|
+
_pick(page, field, "[role=checkbox]", value)
|
|
231
|
+
case "consent": # the box, not its label: the label holds a link
|
|
232
|
+
_check(field.locator("[role=checkbox]"), answer == "Yes")
|
|
233
|
+
case "select":
|
|
234
|
+
_select(page, field_id, values[0])
|
|
235
|
+
case _:
|
|
236
|
+
field.fill(values[0])
|
|
237
|
+
|
|
238
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
239
|
+
values = page.locator(FORM).evaluate(_READ_BACK, self._kinds)
|
|
240
|
+
for field_id, fmt in self._formats.items():
|
|
241
|
+
values[field_id] = _iso_date(values.get(field_id) or "", fmt)
|
|
242
|
+
return {k: v for k, v in values.items() if v not in (None, "", [])}
|
|
243
|
+
|
|
244
|
+
def submit(self, page: Page) -> str | None:
|
|
245
|
+
"""Clicks submit; the evidence is the `/apply` call's answer, after Turnstile's check."""
|
|
246
|
+
with page.expect_response(
|
|
247
|
+
lambda r: r.request.method == "POST" and APPLIED.search(r.url) is not None,
|
|
248
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
249
|
+
) as applied:
|
|
250
|
+
browser.click(page.locator(f"{FORM} button[data-ui=apply-button]"))
|
|
251
|
+
response = applied.value
|
|
252
|
+
if not response.ok:
|
|
253
|
+
raise RuntimeError(f"apply answered {response.status}: {response.text()[:200]}")
|
|
254
|
+
return f"{response.url} {response.status}"
|
|
255
|
+
|
|
256
|
+
def _field(self, page: Page, raw: dict[str, Any]) -> Field | None:
|
|
257
|
+
kind, options = raw["kind"], raw.get("options")
|
|
258
|
+
match kind:
|
|
259
|
+
case "text" | "number":
|
|
260
|
+
field_type = FieldType.TEXT
|
|
261
|
+
case "date":
|
|
262
|
+
field_type = FieldType.DATE
|
|
263
|
+
case "phone":
|
|
264
|
+
field_type = FieldType.PHONE
|
|
265
|
+
case "file":
|
|
266
|
+
field_type = FieldType.FILE
|
|
267
|
+
case "yes_no" | "consent":
|
|
268
|
+
field_type, options = FieldType.CHOICE, YES_NO
|
|
269
|
+
case "radio":
|
|
270
|
+
field_type = FieldType.CHOICE
|
|
271
|
+
case "checkboxes":
|
|
272
|
+
field_type = FieldType.MULTI_CHOICE
|
|
273
|
+
case "select":
|
|
274
|
+
field_type, options = FieldType.CHOICE, _options(page, raw["id"])
|
|
275
|
+
case "group" if not raw["required"]:
|
|
276
|
+
return None # an optional education or experience entry, left out
|
|
277
|
+
case _:
|
|
278
|
+
raise Unsupported(f"form_shape: {raw['id']} is a {kind}")
|
|
279
|
+
return Field(
|
|
280
|
+
raw["id"],
|
|
281
|
+
raw["label"],
|
|
282
|
+
field_type,
|
|
283
|
+
raw["required"],
|
|
284
|
+
options,
|
|
285
|
+
raw.get("maxLength"),
|
|
286
|
+
raw.get("accept"),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _code(url: str) -> str | None:
|
|
291
|
+
"""The posting's shortcode, from `apply.workable.com[/<account>]/j/<code>`."""
|
|
292
|
+
parts = urlsplit(url)
|
|
293
|
+
if not HOSTS.match(parts.hostname or ""):
|
|
294
|
+
return None
|
|
295
|
+
match = JOB.match(parts.path)
|
|
296
|
+
return match[1] if match else None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _api_fields(form: list[dict[str, Any]]) -> list[Field] | None:
|
|
300
|
+
fields = []
|
|
301
|
+
for entry in (f for section in form for f in section["fields"]):
|
|
302
|
+
kind = entry["type"]
|
|
303
|
+
if kind == "group":
|
|
304
|
+
if entry["required"]:
|
|
305
|
+
return None
|
|
306
|
+
continue
|
|
307
|
+
options = [o["value"].strip() for o in entry.get("options") or []] or None
|
|
308
|
+
if kind == "multiple":
|
|
309
|
+
single = entry.get("singleOption", True)
|
|
310
|
+
field_type = FieldType.CHOICE if single else FieldType.MULTI_CHOICE
|
|
311
|
+
elif kind == "boolean":
|
|
312
|
+
field_type, options = FieldType.CHOICE, YES_NO
|
|
313
|
+
elif kind in API_TYPES:
|
|
314
|
+
field_type = API_TYPES[kind]
|
|
315
|
+
else:
|
|
316
|
+
return None
|
|
317
|
+
accept = entry.get("supportedFileTypes") if kind == "file" else None
|
|
318
|
+
fields.append(
|
|
319
|
+
Field(
|
|
320
|
+
entry["id"],
|
|
321
|
+
entry.get("label", ""),
|
|
322
|
+
field_type,
|
|
323
|
+
entry["required"],
|
|
324
|
+
options,
|
|
325
|
+
entry.get("maxLength"),
|
|
326
|
+
accept,
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
return fields
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _decline_cookies(page: Page, wait: bool = True) -> None:
|
|
333
|
+
"""Declines the cookie banner, a modal dialog that leaves the form inert while it is open;
|
|
334
|
+
it shows a moment after the page."""
|
|
335
|
+
decline = page.locator(COOKIES)
|
|
336
|
+
with suppress(Timeout):
|
|
337
|
+
if wait:
|
|
338
|
+
decline.wait_for(timeout=COOKIE_TIMEOUT_MS)
|
|
339
|
+
if decline.is_visible():
|
|
340
|
+
decline.click()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _exact(value: str) -> re.Pattern[str]:
|
|
344
|
+
return re.compile(rf"^\s*{re.escape(value)}\s*$")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _pick(page: Page, field: Locator, role: str, value: str) -> None:
|
|
348
|
+
"""Checks the option labelled `value`, unless it is already checked. An option names its
|
|
349
|
+
label second in `aria-labelledby`, after the question."""
|
|
350
|
+
for option in field.locator(role).all():
|
|
351
|
+
label_id = (option.get_attribute("aria-labelledby") or "").split()[-1]
|
|
352
|
+
if re.match(_exact(value), page.locator(f"[id='{label_id}']").text_content() or ""):
|
|
353
|
+
_check(option, True)
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _check(option: Locator, checked: bool) -> None:
|
|
358
|
+
"""Clicks a radio or checkbox unless it already is `checked`. Its `aria-checked` is the
|
|
359
|
+
form's state: the hidden input it holds is remounted, and catches up, on a later render."""
|
|
360
|
+
state = "true" if checked else "false"
|
|
361
|
+
if option.get_attribute("aria-checked") == state:
|
|
362
|
+
return
|
|
363
|
+
browser.click(option)
|
|
364
|
+
with suppress(Timeout):
|
|
365
|
+
option.and_(option.page.locator(f"[aria-checked={state}]")).wait_for(
|
|
366
|
+
timeout=CHECK_TIMEOUT_MS
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _options(page: Page, field_id: str) -> list[str]:
|
|
371
|
+
"""A dropdown's options, from opening it."""
|
|
372
|
+
box = page.locator(f"[id='input_{field_id}_input']")
|
|
373
|
+
box.click()
|
|
374
|
+
labels = page.locator(f"[id='input_{field_id}_listbox'] [data-role=option-label]")
|
|
375
|
+
with suppress(Timeout):
|
|
376
|
+
labels.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
377
|
+
options = [t.strip() for t in labels.all_text_contents()]
|
|
378
|
+
box.press("Escape")
|
|
379
|
+
_closed(page, field_id)
|
|
380
|
+
return options
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _select(page: Page, field_id: str, value: str) -> None:
|
|
384
|
+
"""Opens a dropdown and clicks the option with the exact text; none leaves it empty."""
|
|
385
|
+
page.locator(f"[id='input_{field_id}_input']").click()
|
|
386
|
+
option = page.locator(f"[id='input_{field_id}_listbox'] [role=option]").filter(
|
|
387
|
+
has=page.locator("[data-role=option-label]", has_text=_exact(value))
|
|
388
|
+
)
|
|
389
|
+
try:
|
|
390
|
+
option.first.wait_for(timeout=OPTIONS_TIMEOUT_MS)
|
|
391
|
+
except Timeout:
|
|
392
|
+
page.keyboard.press("Escape")
|
|
393
|
+
else:
|
|
394
|
+
option.first.click()
|
|
395
|
+
_closed(page, field_id)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _closed(page: Page, field_id: str) -> None:
|
|
399
|
+
"""Waits for a dropdown's list to leave the page: closing, it still takes clicks."""
|
|
400
|
+
with suppress(Timeout):
|
|
401
|
+
page.locator(f"[id='input_{field_id}_listbox']").wait_for(
|
|
402
|
+
state="detached", timeout=OPTIONS_TIMEOUT_MS
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _typed_date(answer: str, fmt: str) -> str | None:
|
|
407
|
+
"""The digits a date input takes for a YYYY-MM-DD answer, in its placeholder's order."""
|
|
408
|
+
try:
|
|
409
|
+
day = date.fromisoformat(answer)
|
|
410
|
+
except ValueError:
|
|
411
|
+
return None
|
|
412
|
+
parts = {"YYYY": f"{day.year:04}", "MM": f"{day.month:02}", "DD": f"{day.day:02}"}
|
|
413
|
+
return "".join(parts[p] for p in re.findall(r"YYYY|MM|DD", fmt.upper()))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _iso_date(shown: str, fmt: str) -> str | None:
|
|
417
|
+
"""A date input's value, shown in its placeholder's order, as YYYY-MM-DD."""
|
|
418
|
+
order = re.findall(r"YYYY|MM|DD", fmt.upper())
|
|
419
|
+
numbers = re.findall(r"\d+", shown)
|
|
420
|
+
if len(order) != 3 or len(numbers) != 3:
|
|
421
|
+
return None
|
|
422
|
+
parts = dict(zip(order, numbers, strict=True))
|
|
423
|
+
return f"{parts['YYYY']}-{parts['MM']}-{parts['DD']}"
|
|
@@ -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"
|
|
@@ -140,7 +142,9 @@ kind = "ats"
|
|
|
140
142
|
share = 0.0159
|
|
141
143
|
account = "none"
|
|
142
144
|
hosting = ["hosted"]
|
|
145
|
+
adapter = "workable"
|
|
143
146
|
mapped = { date = 2026-10-20, version = "0.0.2", by = "agent", commit = "7934b25" }
|
|
147
|
+
dry_run_verified = { date = 2026-10-20, version = "0.0.3", by = "gate", commit = "76fb0fd" }
|
|
144
148
|
|
|
145
149
|
[energyjobline]
|
|
146
150
|
kind = "aggregator"
|
|
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
|