atsfill 0.0.5.dev186__tar.gz → 0.0.5.dev187__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.5.dev186 → atsfill-0.0.5.dev187}/PKG-INFO +2 -1
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/README.md +1 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.5.dev187/src/atsfill/adapters/adp.py +760 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/registry.toml +3 -2
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/LICENSE +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/adpmyjobs.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev186 → atsfill-0.0.5.dev187}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.5.
|
|
3
|
+
Version: 0.0.5.dev187
|
|
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
|
|
@@ -89,6 +89,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
89
89
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
90
90
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
91
91
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
92
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
92
93
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
93
94
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
94
95
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -60,6 +60,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
60
60
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
61
61
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
62
62
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
63
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
63
64
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
64
65
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
65
66
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from atsfill.adapters.adp import ADP
|
|
1
2
|
from atsfill.adapters.adpmyjobs import ADPMyJobs
|
|
2
3
|
from atsfill.adapters.ashby import Ashby
|
|
3
4
|
from atsfill.adapters.bamboohr import BambooHR
|
|
@@ -74,6 +75,7 @@ def adapters() -> list[Adapter]:
|
|
|
74
75
|
IsolvedHire(),
|
|
75
76
|
Jobstreet(),
|
|
76
77
|
ADPMyJobs(),
|
|
78
|
+
ADP(),
|
|
77
79
|
SuccessFactors(), # it claims a career site's job path on any host
|
|
78
80
|
Phenom(), # last: it claims a URL by its path alone
|
|
79
81
|
]
|
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
"""ADP Workforce Now's career center (`workforcenow.adp.com/mascsr/.../recruitment.html`), as
|
|
2
|
+
docs/adapters/adp.md observes.
|
|
3
|
+
|
|
4
|
+
Each employer keeps its own candidate profile, without a password: every sign-in is an emailed
|
|
5
|
+
OTP, a sign-up the employer's talent community step. Signed in, Apply opens the application's
|
|
6
|
+
wizard, one page whose Next moves step by step on the client. The read lists Personal
|
|
7
|
+
Information from the open step and the rest from the wizard's meta; the fill answers each step
|
|
8
|
+
up to the attestation, the read-back being each step's values before its Next.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from html import unescape
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
19
|
+
|
|
20
|
+
from atsfill.core import matching, otp
|
|
21
|
+
from atsfill.core.adapter import (
|
|
22
|
+
Blocked,
|
|
23
|
+
NotTaken,
|
|
24
|
+
Refused,
|
|
25
|
+
SignedOut,
|
|
26
|
+
SignUpDetails,
|
|
27
|
+
Unsupported,
|
|
28
|
+
)
|
|
29
|
+
from atsfill.core.browser import Locator, Page, Request, Timeout
|
|
30
|
+
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
31
|
+
|
|
32
|
+
HOST = "workforcenow.adp.com"
|
|
33
|
+
CAREER_CENTER = f"https://{HOST}/mascsr/default/mdf/recruitment/recruitment.html"
|
|
34
|
+
SIGNED_IN = f"https://{HOST}/mascsr/applicant/mdf/recruitment/postLogin.html"
|
|
35
|
+
EVENTS = f"https://{HOST}/mascsr/applicant/careercenter-auth/events/staffing"
|
|
36
|
+
APPLICANTS = f"{EVENTS}/v2/job-applicants"
|
|
37
|
+
META = f"{EVENTS}/v1/job-application.submit/meta"
|
|
38
|
+
PAGE_TIMEOUT_MS = 60_000
|
|
39
|
+
STEP_TIMEOUT_MS = 30_000
|
|
40
|
+
SIGNED_IN_TIMEOUT_MS = 60_000
|
|
41
|
+
UPLOAD_TIMEOUT_MS = 60_000
|
|
42
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
43
|
+
SETTLE_MS = 1_500 # a step re-renders, a list opens
|
|
44
|
+
OPTION_TIMEOUT_MS = 10_000
|
|
45
|
+
KEY_DELAY_MS = 40
|
|
46
|
+
YES_NO = ["Yes", "No"]
|
|
47
|
+
DATE = "%m/%d/%Y"
|
|
48
|
+
NEXT = "#ja_sv_cw_next_footer_btn"
|
|
49
|
+
PREVIOUS = "#ja_sv_cw_prev_footer_btn"
|
|
50
|
+
SUBMIT = "#submitApplication"
|
|
51
|
+
ATTEST = "input[name=self_att_agree_chk]"
|
|
52
|
+
PERSON = "/jobApplication/applicant/person"
|
|
53
|
+
PAY = "/jobApplication/requestedPayRate"
|
|
54
|
+
|
|
55
|
+
# Personal Information as the step shows it: the field's id, its box, its type
|
|
56
|
+
PERSONAL = [
|
|
57
|
+
("first_name", "#personalInfomationFirstName", FieldType.TEXT),
|
|
58
|
+
("middle_name", "#personalInfomationMiddleName", FieldType.TEXT),
|
|
59
|
+
("last_name", "#personalInfomationLastName", FieldType.TEXT),
|
|
60
|
+
("email", "#personalInfomationEmail", FieldType.TEXT),
|
|
61
|
+
("mobile_phone", "#personalInfomationMobileNumberError", FieldType.PHONE),
|
|
62
|
+
("home_phone", "#personalInfomationMobileNumberErrorMessage", FieldType.PHONE),
|
|
63
|
+
("country", "#PersonalAddress_country", FieldType.CHOICE),
|
|
64
|
+
("address_line1", "#PersonalAddress_address_line1", FieldType.TEXT),
|
|
65
|
+
("address_line2", "#PersonalAddress_address_line2", FieldType.TEXT),
|
|
66
|
+
("address_line3", "#PersonalAddress_address_line3", FieldType.TEXT),
|
|
67
|
+
("city", "#PersonalAddress_city", FieldType.TEXT),
|
|
68
|
+
("state", "#PersonalAddress_state", FieldType.CHOICE),
|
|
69
|
+
("postal_code", "#PersonalAddress_postalCode", FieldType.TEXT),
|
|
70
|
+
("county", "#PersonalAddress_county", FieldType.TEXT),
|
|
71
|
+
]
|
|
72
|
+
LABELS = {"home_phone": "Home Phone Number", "mobile_phone": "Mobile Number*"}
|
|
73
|
+
SHOWN_BY_COUNTRY = {"county"} # once a country with counties is picked
|
|
74
|
+
ADDRESS_MAX = 30 # "Address Line 1 must not exceed 30 characters"
|
|
75
|
+
FIXED = {"personal.email"} # the verified email, read only
|
|
76
|
+
QUESTIONS = "#question_0, #questionYes_0, #desiredSalaryId, #refferalOptions, #commentTextArea"
|
|
77
|
+
|
|
78
|
+
# Voluntary Self-ID: its lists and radio groups, each with the meta's indicator that shows it
|
|
79
|
+
# and its code list; a radio's value is its code
|
|
80
|
+
SELF_ID = [
|
|
81
|
+
("gender", "Gender", "#vsidGender", "GENDER", "genderCode"),
|
|
82
|
+
("ethnicity", "Ethnicity", "#vsidEthinicity", "RACE", "ethnicityCode"),
|
|
83
|
+
("race", "Race", "#vsidRace", "RACE", "raceCode"),
|
|
84
|
+
("veteran", "Protected Veteran Status", "sdf-radio-group:has(#veteranStatusIdYes)",
|
|
85
|
+
"VSID_VeteranEnabledIndicator", "militaryStatusCode"),
|
|
86
|
+
("disability", "Disability Status", "sdf-radio-group:has(#disabilityStatusIdYes)",
|
|
87
|
+
"VSID_DisabilityEnabledIndicator", "disabilityTypeCodes"),
|
|
88
|
+
] # fmt: skip
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Step:
|
|
93
|
+
name: str
|
|
94
|
+
marker: str # a box only this step shows
|
|
95
|
+
fields: list[Field] = field(default_factory=list)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ADP:
|
|
99
|
+
name = "adp"
|
|
100
|
+
per_employer = True # a candidate profile per employer: the scope is its career center
|
|
101
|
+
asks = {"name", "phone"}
|
|
102
|
+
known_gaps: dict = {}
|
|
103
|
+
side_effects = [
|
|
104
|
+
SideEffect("sign_up", "employer", "login --create joins the employer's talent community"),
|
|
105
|
+
SideEffect("load", "employer", "Apply leaves a draft application, Pending Submission"),
|
|
106
|
+
SideEffect("upload", "employer", "the résumé is uploaded to the employer's ADP"),
|
|
107
|
+
SideEffect("submit", "employer", "Submit sends the application"),
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
def __init__(self) -> None:
|
|
111
|
+
self._steps: list[Step] = []
|
|
112
|
+
self._boxes: dict[str, str] = {} # each field's box, by field id
|
|
113
|
+
self._values: dict[str, Answer] = {}
|
|
114
|
+
self._codes: dict[str, dict[str, str]] = {} # a radio's label by its value, by field id
|
|
115
|
+
self._amounts: set[str] = set() # the page shows them formatted: `50,000.00`
|
|
116
|
+
|
|
117
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
118
|
+
return _posting(url) is not None
|
|
119
|
+
|
|
120
|
+
def posting(self, url: str) -> str:
|
|
121
|
+
found = _posting(url)
|
|
122
|
+
if found is None:
|
|
123
|
+
return url
|
|
124
|
+
cid, cc_id, job = found
|
|
125
|
+
return f"{CAREER_CENTER}?{urlencode({'cid': cid, 'ccId': cc_id, 'jobId': job})}"
|
|
126
|
+
|
|
127
|
+
def account_scope(self, url: str) -> str:
|
|
128
|
+
"""The employer's career center, `<cid>.<ccId>`: its sign-in page needs both."""
|
|
129
|
+
found = _posting(url) or _career_center(url)
|
|
130
|
+
return f"{found[0]}.{found[1]}" if found else ""
|
|
131
|
+
|
|
132
|
+
def login_url(self, scope: str | None) -> str:
|
|
133
|
+
cid, _, cc_id = (scope or "").partition(".")
|
|
134
|
+
query = urlencode({"cid": cid, "ccId": cc_id, "lang": "en_US", "selectedMenuKey": "SignIn"})
|
|
135
|
+
return f"{CAREER_CENTER}?{query}"
|
|
136
|
+
|
|
137
|
+
def signed_in(self, request: Request, scope: str | None) -> bool | None:
|
|
138
|
+
"""Signed out, the candidate profile's endpoint redirects to ADP's login page."""
|
|
139
|
+
cid = (scope or "").partition(".")[0]
|
|
140
|
+
response = request.get(f"{APPLICANTS}?cid={cid}", max_redirects=0)
|
|
141
|
+
if 300 <= response.status < 400:
|
|
142
|
+
return False
|
|
143
|
+
return True if response.ok else None
|
|
144
|
+
|
|
145
|
+
def sign_in_by_otp(self, page: Page, email: str) -> None:
|
|
146
|
+
_sign_in(page, email, None)
|
|
147
|
+
|
|
148
|
+
def sign_up_by_otp(self, page: Page, email: str, details: SignUpDetails) -> None:
|
|
149
|
+
_sign_in(page, email, details)
|
|
150
|
+
|
|
151
|
+
def browser_read(self, page: Page) -> list[Field]:
|
|
152
|
+
"""Opens the wizard on its first step; Personal Information from the page, the
|
|
153
|
+
questionnaire, the desired pay, Voluntary Self-ID and the attestation from the meta."""
|
|
154
|
+
_open(page)
|
|
155
|
+
query = parse_qs(urlsplit(page.url).query)
|
|
156
|
+
cid, cc_id, job = query["cid"][0], query["ccId"][0], query["jobId"][0]
|
|
157
|
+
meta = page.evaluate(_FETCH, [f"{META}?cid={cid}&ccId={cc_id}&lang=en_US", job])
|
|
158
|
+
control = (meta.get("data") or {}).get("control") or {}
|
|
159
|
+
transforms = _transforms(meta)
|
|
160
|
+
shown = page.locator("body").inner_text()
|
|
161
|
+
self._boxes, self._codes, self._amounts = {}, {}, set()
|
|
162
|
+
indicators = {
|
|
163
|
+
(n := i["nameCode"]).get("codeValue") or n.get("shortName"): i.get("indicatorValue")
|
|
164
|
+
for i in control.get("customIndicators") or []
|
|
165
|
+
}
|
|
166
|
+
asked = self._questions(control, transforms)
|
|
167
|
+
self._steps = [
|
|
168
|
+
Step("Personal Information", PERSONAL[0][1], self._personal(page, transforms)),
|
|
169
|
+
Step("Resume", "input[type=file]", [self._resume()]),
|
|
170
|
+
# A step that asks nothing has no box of its own: Next moves past it
|
|
171
|
+
Step("Questions", QUESTIONS if asked else "", asked),
|
|
172
|
+
] # fmt: skip
|
|
173
|
+
if "Voluntary Self-ID" in shown:
|
|
174
|
+
fields = self._self_id(transforms, indicators)
|
|
175
|
+
self._steps.append(Step("Voluntary Self-ID", "#vsidWhy, #vsidEthRace", fields))
|
|
176
|
+
self._steps.append(Step("Review Your Application", ""))
|
|
177
|
+
self._steps.append(Step("Self-Attest & Submit", SUBMIT, [self._attestation(control)]))
|
|
178
|
+
return [f for s in self._steps for f in s.fields]
|
|
179
|
+
|
|
180
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
181
|
+
"""Answers each step and moves on with Next, up to the attestation, which it agrees to
|
|
182
|
+
without Submit."""
|
|
183
|
+
self._values = {}
|
|
184
|
+
answers = {f.id: f.prefilled for s in self._steps for f in s.fields
|
|
185
|
+
if f.prefilled is not None} | answers # fmt: skip
|
|
186
|
+
for step in self._steps:
|
|
187
|
+
_wait_for_step(page, step)
|
|
188
|
+
for f in step.fields:
|
|
189
|
+
if f.id in answers and f.id not in FIXED:
|
|
190
|
+
self._set(page, f, answers[f.id])
|
|
191
|
+
self._values |= self._read(page, step)
|
|
192
|
+
if step.marker == SUBMIT:
|
|
193
|
+
return
|
|
194
|
+
_next(page, step)
|
|
195
|
+
|
|
196
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
197
|
+
"""Each step's values as it was left."""
|
|
198
|
+
return dict(self._values)
|
|
199
|
+
|
|
200
|
+
def submit(self, page: Page) -> str | None:
|
|
201
|
+
"""Submit, on the attestation; the wizard then closes on a confirmation."""
|
|
202
|
+
page.locator(SUBMIT).click()
|
|
203
|
+
try:
|
|
204
|
+
page.locator(SUBMIT).wait_for(state="detached", timeout=SUBMIT_TIMEOUT_MS)
|
|
205
|
+
except Timeout:
|
|
206
|
+
raise RuntimeError(f"Submit stayed on the attestation: {_errors(page)}") from None
|
|
207
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
208
|
+
return f"Submit closed the wizard: {page.locator('body').inner_text()[:200]!r}"
|
|
209
|
+
|
|
210
|
+
def _personal(self, page: Page, transforms: dict[str, Any]) -> list[Field]:
|
|
211
|
+
"""The step's boxes, required where their label carries the asterisk; the profile's
|
|
212
|
+
values as prefilled."""
|
|
213
|
+
fields = []
|
|
214
|
+
for name, selector, type_ in PERSONAL:
|
|
215
|
+
box = page.locator(selector)
|
|
216
|
+
field_id = f"personal.{name}"
|
|
217
|
+
if name in SHOWN_BY_COUNTRY and not box.count() and "personal.country" in self._boxes:
|
|
218
|
+
self._boxes[field_id] = selector
|
|
219
|
+
label = name.replace("_", " ").title()
|
|
220
|
+
when = Is("personal.country", None)
|
|
221
|
+
fields.append(Field(field_id, label, type_, False, shown_when=when))
|
|
222
|
+
continue
|
|
223
|
+
if not box.count():
|
|
224
|
+
continue
|
|
225
|
+
self._boxes[field_id] = selector
|
|
226
|
+
label = LABELS.get(name) or _label(page, selector) or name.replace("_", " ").title()
|
|
227
|
+
required = label.endswith("*") or box.first.get_attribute("aria-required") == "true"
|
|
228
|
+
options = None
|
|
229
|
+
if name == "country":
|
|
230
|
+
options = [o for o in _react_options(page, box.first) if o]
|
|
231
|
+
elif name == "state":
|
|
232
|
+
options = _states(transforms)
|
|
233
|
+
value = _value(page, selector, type_)
|
|
234
|
+
fields.append(
|
|
235
|
+
Field(
|
|
236
|
+
field_id, label.rstrip("*").strip(), type_, required, options,
|
|
237
|
+
prefilled=value or None,
|
|
238
|
+
max_length=ADDRESS_MAX if name.startswith("address_line") else None,
|
|
239
|
+
)
|
|
240
|
+
) # fmt: skip
|
|
241
|
+
return fields
|
|
242
|
+
|
|
243
|
+
def _resume(self) -> Field:
|
|
244
|
+
self._boxes["resume"] = "input[type=file]"
|
|
245
|
+
return Field("resume", "Resume", FieldType.FILE, True, accept=[".pdf", ".doc", ".docx"])
|
|
246
|
+
|
|
247
|
+
def _questions(self, control: dict[str, Any], transforms: dict[str, Any]) -> list[Field]:
|
|
248
|
+
"""The questionnaire, each question its `#question_<n>` by position; then the desired
|
|
249
|
+
pay and the comment where the meta does not hide them."""
|
|
250
|
+
fields = []
|
|
251
|
+
questions = (control.get("questionnaire") or {}).get("questions") or []
|
|
252
|
+
for n, q in enumerate(questions):
|
|
253
|
+
field_id = f"question.{q['questionID']}"
|
|
254
|
+
label = _text(q.get("questionText") or q["questionID"])
|
|
255
|
+
required = bool(q.get("requiredIndicator"))
|
|
256
|
+
options = [o.get("answerValue") or o["answerID"] for o in q.get("answerOptions") or []]
|
|
257
|
+
box = f"#question_{n}"
|
|
258
|
+
match q.get("answerTypeCode"):
|
|
259
|
+
case "Yes/No Buttons":
|
|
260
|
+
box = f"sdf-radio-group:has(#questionYes_{n})"
|
|
261
|
+
f = Field(field_id, label, FieldType.CHOICE, required, YES_NO)
|
|
262
|
+
case "Comment" | "Text Field":
|
|
263
|
+
f = Field(field_id, label, FieldType.TEXT, required)
|
|
264
|
+
case "Number Field":
|
|
265
|
+
self._amounts.add(field_id)
|
|
266
|
+
f = Field(field_id, label, FieldType.TEXT, required)
|
|
267
|
+
case "Drop List":
|
|
268
|
+
f = Field(field_id, label, FieldType.CHOICE, required, options)
|
|
269
|
+
case "Multi-Select Menu":
|
|
270
|
+
f = Field(field_id, label, FieldType.MULTI_CHOICE, required, options)
|
|
271
|
+
case "Date Field":
|
|
272
|
+
f = Field(field_id, label, FieldType.DATE, required)
|
|
273
|
+
case "Money Field": # an amount, then its currency
|
|
274
|
+
self._amounts.add(field_id)
|
|
275
|
+
f = Field(field_id, label, FieldType.TEXT, required)
|
|
276
|
+
currency = f"{field_id}.currency"
|
|
277
|
+
self._boxes[currency] = f"#question_currency_type_{n}"
|
|
278
|
+
fields += [f, Field(currency, f"{label} (currency)", FieldType.CHOICE,
|
|
279
|
+
required, [_spaced(o) for o in options])] # fmt: skip
|
|
280
|
+
self._boxes[field_id] = box
|
|
281
|
+
continue
|
|
282
|
+
case other:
|
|
283
|
+
reason = f"unhandled_rendering: {label!r} is a {other} question"
|
|
284
|
+
f = Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
|
|
285
|
+
self._boxes[field_id] = box
|
|
286
|
+
fields.append(f)
|
|
287
|
+
pay = transforms.get(PAY) or {}
|
|
288
|
+
if pay and not pay.get("hidden"):
|
|
289
|
+
required = not pay.get("optional")
|
|
290
|
+
rates = _codes(transforms, f"{PAY}/annualRateAmount/nameCode") or ["Annually", "Hourly"]
|
|
291
|
+
currencies = [ # as the list shows them: `United States Dollar ( USD )`
|
|
292
|
+
f"{i['longName']} ( {i['codeValue']} )"
|
|
293
|
+
for i in _items(transforms, f"{PAY}/annualRateAmount/currencyCode")
|
|
294
|
+
]
|
|
295
|
+
self._amounts.add("pay.amount")
|
|
296
|
+
self._boxes |= {
|
|
297
|
+
"pay.rate": "#salaryOptions sdf-radio-group",
|
|
298
|
+
"pay.amount": "#desiredSalaryId",
|
|
299
|
+
"pay.currency": "#add_info_select_box",
|
|
300
|
+
}
|
|
301
|
+
fields += [
|
|
302
|
+
Field("pay.rate", "Desired salary: rate", FieldType.CHOICE, required, rates,
|
|
303
|
+
default="Annually"),
|
|
304
|
+
Field("pay.amount", "What is your desired salary?", FieldType.TEXT, required),
|
|
305
|
+
Field("pay.currency", "Desired salary: currency", FieldType.CHOICE, required,
|
|
306
|
+
currencies or None),
|
|
307
|
+
] # fmt: skip
|
|
308
|
+
referral = transforms.get("/jobApplication/applicantReferralIndicator") or {}
|
|
309
|
+
if referral and not referral.get("hidden"):
|
|
310
|
+
self._boxes |= { # Yes shows who: an employee's name or a position ID
|
|
311
|
+
"referral": "#refferalOptions:has(#yes)",
|
|
312
|
+
"referral.by": "#refferalOptions:has(#employeeName)",
|
|
313
|
+
"referral.who": "#add_info_text_box",
|
|
314
|
+
}
|
|
315
|
+
label = "Did any of our employees inform you about this position?"
|
|
316
|
+
yes = Is("referral", ["Yes"])
|
|
317
|
+
fields += [
|
|
318
|
+
Field("referral", label, FieldType.CHOICE, not referral.get("optional"), YES_NO,
|
|
319
|
+
default="No"),
|
|
320
|
+
Field("referral.by", "Please provide Employee Name or Position ID.",
|
|
321
|
+
FieldType.CHOICE, True, ["Employee Name", "Position ID"], shown_when=yes),
|
|
322
|
+
Field("referral.who", "Employee name or position ID", FieldType.TEXT, True,
|
|
323
|
+
shown_when=yes),
|
|
324
|
+
] # fmt: skip
|
|
325
|
+
comment = transforms.get("/jobApplication/comment") or {}
|
|
326
|
+
if comment and not comment.get("hidden"):
|
|
327
|
+
self._boxes["comment"] = "#commentTextArea"
|
|
328
|
+
label = "Do you want to tell us anything else about yourself?"
|
|
329
|
+
fields.append(Field("comment", label, FieldType.TEXT, not comment.get("optional")))
|
|
330
|
+
return fields
|
|
331
|
+
|
|
332
|
+
def _self_id(self, transforms: dict[str, Any], indicators: dict[str, bool]) -> list[Field]:
|
|
333
|
+
"""The questions the employer's indicators enable, every answer optional: the step
|
|
334
|
+
moves on without any."""
|
|
335
|
+
fields = []
|
|
336
|
+
for name, label, box, indicator, codes in SELF_ID:
|
|
337
|
+
if not indicators.get(indicator):
|
|
338
|
+
continue
|
|
339
|
+
items = _items(transforms, f"{PERSON}/{codes}")
|
|
340
|
+
if name == "veteran": # the page offers three of the four: Y, N, D
|
|
341
|
+
items = [i for i in items if i["codeValue"] != "X"]
|
|
342
|
+
options = [i["longName"] for i in items]
|
|
343
|
+
field_id = f"self_id.{name}"
|
|
344
|
+
if box.startswith("sdf-radio-group"):
|
|
345
|
+
self._codes[field_id] = {i["codeValue"]: i["longName"] for i in items}
|
|
346
|
+
self._boxes[field_id] = box
|
|
347
|
+
when = Is("self_id.ethnicity", ["Not Hispanic or Latino"]) if name == "race" else None
|
|
348
|
+
fields.append(Field(field_id, label, FieldType.CHOICE, False, options, shown_when=when))
|
|
349
|
+
return fields
|
|
350
|
+
|
|
351
|
+
def _attestation(self, control: dict[str, Any]) -> Field:
|
|
352
|
+
said = " ".join(_text(a.get("messageTxt") or "") for a in control.get("attestations") or [])
|
|
353
|
+
label = "Yes, I agree to sign electronically." + (f" ({said[:300]}…)" if said else "")
|
|
354
|
+
self._boxes["attestation"] = ATTEST
|
|
355
|
+
return Field("attestation", label, FieldType.CHOICE, True, YES_NO)
|
|
356
|
+
|
|
357
|
+
def _set(self, page: Page, f: Field, answer: Answer) -> None:
|
|
358
|
+
box = page.locator(self._boxes[f.id]).first
|
|
359
|
+
if not box.count():
|
|
360
|
+
return # not shown: its condition is unmet
|
|
361
|
+
if f.type is FieldType.FILE:
|
|
362
|
+
_upload(page, box, answer if isinstance(answer, str) else answer[0])
|
|
363
|
+
return
|
|
364
|
+
if isinstance(answer, list):
|
|
365
|
+
for one in answer:
|
|
366
|
+
_react_pick(page, box, f, one)
|
|
367
|
+
return
|
|
368
|
+
tag = box.evaluate("e => e.tagName")
|
|
369
|
+
if box.locator("wfn-radio-button").count():
|
|
370
|
+
box.locator(f'wfn-radio-button[value="{answer}"]').click()
|
|
371
|
+
elif tag == "SDF-RADIO-GROUP":
|
|
372
|
+
_radio(box, f, answer, self._codes.get(f.id, {}))
|
|
373
|
+
elif tag == "SDF-SELECT-SIMPLE":
|
|
374
|
+
_sdf_pick(page, box, f, answer)
|
|
375
|
+
elif tag == "WFN-DROPDOWN-LIST":
|
|
376
|
+
_wfn_pick(page, box, f, answer)
|
|
377
|
+
elif box.get_attribute("type") == "checkbox":
|
|
378
|
+
if box.is_checked() != (answer == "Yes"): # the box is hidden: its label clicks
|
|
379
|
+
page.locator(f'label[for="{box.get_attribute("id")}"]').click()
|
|
380
|
+
elif box.get_attribute("role") == "combobox":
|
|
381
|
+
_react_pick(page, box, f, answer)
|
|
382
|
+
elif f.type is FieldType.PHONE: # cleared, it keeps its country's code: `+1`
|
|
383
|
+
box.fill("")
|
|
384
|
+
kept = re.sub(r"\D", "", box.input_value())
|
|
385
|
+
digits = re.sub(r"\D", "", answer)
|
|
386
|
+
typed = digits.removeprefix(kept) if kept else answer
|
|
387
|
+
box.press_sequentially(typed, delay=KEY_DELAY_MS)
|
|
388
|
+
elif f.type is FieldType.DATE:
|
|
389
|
+
box.fill("")
|
|
390
|
+
typed = datetime.fromisoformat(answer).strftime(DATE)
|
|
391
|
+
box.press_sequentially(typed, delay=KEY_DELAY_MS)
|
|
392
|
+
box.press("Tab")
|
|
393
|
+
else:
|
|
394
|
+
box.fill(answer)
|
|
395
|
+
if f.id == "personal.address_line1": # a place search: its suggestions stay closed
|
|
396
|
+
box.press("Escape")
|
|
397
|
+
page.wait_for_timeout(SETTLE_MS if f.type is FieldType.CHOICE else 0)
|
|
398
|
+
|
|
399
|
+
def _read(self, page: Page, step: Step) -> dict[str, Answer]:
|
|
400
|
+
values: dict[str, Answer] = {}
|
|
401
|
+
for f in step.fields:
|
|
402
|
+
if f.id in FIXED and f.prefilled is not None:
|
|
403
|
+
values[f.id] = f.prefilled
|
|
404
|
+
elif not f.unfillable and (value := _value(page, self._boxes[f.id], f.type)):
|
|
405
|
+
if isinstance(value, str) and f.id in self._codes:
|
|
406
|
+
value = self._codes[f.id].get(value, value)
|
|
407
|
+
elif isinstance(value, str) and f.id in self._amounts:
|
|
408
|
+
value = _amount(value)
|
|
409
|
+
elif isinstance(value, str) and f.id == "personal.state":
|
|
410
|
+
value = value.partition(" - ")[2] or value # `DC - District of Columbia`
|
|
411
|
+
values[f.id] = value
|
|
412
|
+
elif f.id == "pay.currency" and _fixed_dollar(page):
|
|
413
|
+
values[f.id] = next((o for o in f.options or [] if o.endswith("( USD )")), "USD")
|
|
414
|
+
return values
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# The meta lists the questionnaire of the posting its header names
|
|
418
|
+
_FETCH = """async ([url, requisition]) => {
|
|
419
|
+
const headers = {requisitionoid: requisition, 'x-requested-with': 'XMLHttpRequest'};
|
|
420
|
+
const response = await fetch(url, {credentials: 'include', headers});
|
|
421
|
+
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
|
|
422
|
+
return response.json();
|
|
423
|
+
}"""
|
|
424
|
+
# Signed out, the candidate profile's endpoint redirects to ADP's login page
|
|
425
|
+
_HOLDS = """url => fetch(url, {credentials: 'include', redirect: 'manual'}).then(r => r.ok)"""
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _posting(url: str) -> tuple[str, str, str] | None:
|
|
429
|
+
"""The employer (`cid`), its career center (`ccId`) and the posting (`jobId`)."""
|
|
430
|
+
found = _career_center(url)
|
|
431
|
+
job = parse_qs(urlsplit(url).query).get("jobId")
|
|
432
|
+
return (*found, job[0]) if found and job else None
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _career_center(url: str) -> tuple[str, str] | None:
|
|
436
|
+
parts = urlsplit(url)
|
|
437
|
+
query = parse_qs(parts.query)
|
|
438
|
+
if parts.hostname != HOST or "/recruitment/" not in parts.path:
|
|
439
|
+
return None
|
|
440
|
+
if not (query.get("cid") and query.get("ccId")):
|
|
441
|
+
return None
|
|
442
|
+
return query["cid"][0], query["ccId"][0]
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _open(page: Page) -> None:
|
|
446
|
+
"""The posting signed in: Apply, or Complete Your Application on a draft, opens the wizard;
|
|
447
|
+
a wizard opened past its first step goes back to it."""
|
|
448
|
+
found = _posting(page.url)
|
|
449
|
+
if found is None:
|
|
450
|
+
raise RuntimeError(f"{page.url} is not an ADP Workforce Now posting")
|
|
451
|
+
cid, cc_id, job = found
|
|
452
|
+
query = urlencode({"cid": cid, "ccId": cc_id, "lang": "en_US", "jobId": job})
|
|
453
|
+
page.goto(f"{SIGNED_IN}?{query}")
|
|
454
|
+
apply = page.locator(
|
|
455
|
+
"#recruitment_jobDescription_candidateApply:visible,"
|
|
456
|
+
" #recruitment_jobDescription_complete:visible"
|
|
457
|
+
)
|
|
458
|
+
wizard = page.locator(NEXT)
|
|
459
|
+
try:
|
|
460
|
+
apply.or_(wizard).first.wait_for(timeout=PAGE_TIMEOUT_MS)
|
|
461
|
+
except Timeout:
|
|
462
|
+
if "login" in urlsplit(page.url).path:
|
|
463
|
+
raise SignedOut("login_required: the posting sent to ADP's login") from None
|
|
464
|
+
raise Unsupported("posting_gone: the posting shows no Apply") from None
|
|
465
|
+
if not wizard.count():
|
|
466
|
+
apply.first.click()
|
|
467
|
+
wizard.wait_for(timeout=PAGE_TIMEOUT_MS)
|
|
468
|
+
first = page.locator(PERSONAL[0][1])
|
|
469
|
+
for _ in range(len(PERSONAL)):
|
|
470
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
471
|
+
if first.count() or not page.locator(PREVIOUS).count():
|
|
472
|
+
break
|
|
473
|
+
page.locator(PREVIOUS).click()
|
|
474
|
+
first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _transforms(meta: dict[str, Any]) -> dict[str, Any]:
|
|
478
|
+
"""Each profile path's `optional`, `hidden` and code list."""
|
|
479
|
+
found: dict[str, Any] = {}
|
|
480
|
+
for entry in (meta.get("meta") or {}).get("/data/transforms") or []:
|
|
481
|
+
found |= entry
|
|
482
|
+
return found
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _spaced(currency: str) -> str:
|
|
486
|
+
"""A currency as its list shows it: `United States Dollar ( USD )` for the meta's `(USD)`."""
|
|
487
|
+
return re.sub(r"\((\w+)\)$", r"( \1 )", currency)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _items(transforms: dict[str, Any], path: str) -> list[dict[str, str]]:
|
|
491
|
+
items = ((transforms.get(path) or {}).get("codeList") or {}).get("listItems") or []
|
|
492
|
+
return [i for i in items if i.get("longName")]
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _codes(transforms: dict[str, Any], path: str) -> list[str]:
|
|
496
|
+
return [i["longName"] for i in _items(transforms, path)]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _states(transforms: dict[str, Any]) -> list[str]:
|
|
500
|
+
"""The states by name: the list shows `IL - Illinois` once the country is picked, the
|
|
501
|
+
meta `US-Illinois`."""
|
|
502
|
+
return [
|
|
503
|
+
re.sub(r"^[A-Z]{2}-", "", s)
|
|
504
|
+
for s in _codes(transforms, f"{PERSON}/address/countrySubdivisionLevel1")
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _text(html: str) -> str:
|
|
509
|
+
return " ".join(unescape(re.sub(r"<[^>]+>", " ", html)).split())
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _label(page: Page, selector: str) -> str:
|
|
513
|
+
"""The text of the label for a box, by its id."""
|
|
514
|
+
box_id = selector.removeprefix("#")
|
|
515
|
+
label = page.locator(f'label[for="{box_id}"]')
|
|
516
|
+
if label.count():
|
|
517
|
+
return " ".join(label.first.inner_text().split())
|
|
518
|
+
return ""
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _wait_for_step(page: Page, step: Step) -> None:
|
|
522
|
+
if not step.marker:
|
|
523
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
524
|
+
return
|
|
525
|
+
try:
|
|
526
|
+
page.locator(step.marker).first.wait_for(state="attached", timeout=STEP_TIMEOUT_MS)
|
|
527
|
+
except Timeout:
|
|
528
|
+
raise RuntimeError(f"the wizard is not on {step.name}") from None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _next(page: Page, step: Step) -> None:
|
|
532
|
+
"""Next, once the step is left; the page's errors where it stays."""
|
|
533
|
+
page.locator(NEXT).click()
|
|
534
|
+
deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000
|
|
535
|
+
while step.marker and page.locator(step.marker).first.count():
|
|
536
|
+
if time.monotonic() > deadline:
|
|
537
|
+
raise NotTaken(f"{step.name} did not move on: {_errors(page)}")
|
|
538
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
539
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _errors(page: Page) -> str:
|
|
543
|
+
"""Each highlighted field's error under it."""
|
|
544
|
+
said = page.locator("[role=alert]:visible, [id$=errorMessage]:visible").all_inner_texts()
|
|
545
|
+
return "; ".join(dict.fromkeys(t.strip() for t in said if t.strip())) or "no error shown"
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _radio(group: Locator, f: Field, answer: str, codes: dict[str, str]) -> None:
|
|
549
|
+
"""Clicks the radio whose label matches, or whose code does where the labels are the meta's."""
|
|
550
|
+
code = next((c for c, label in codes.items() if label == answer), None)
|
|
551
|
+
for radio in group.locator("sdf-radio-button").all():
|
|
552
|
+
value = radio.get_attribute("value") or ""
|
|
553
|
+
if value == code or matching.matches(f, answer, radio.get_attribute("label") or value):
|
|
554
|
+
radio.click()
|
|
555
|
+
return
|
|
556
|
+
raise NotTaken(f"{f.id} has no option {answer!r}")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _fixed_dollar(page: Page) -> bool:
|
|
560
|
+
"""Some employers show no currency list beside the desired pay, `$` before it instead."""
|
|
561
|
+
box = page.locator("#desiredSalaryId")
|
|
562
|
+
if not box.count() or page.locator("#add_info_select_box").count():
|
|
563
|
+
return False
|
|
564
|
+
return box.evaluate("e => e.parentElement.innerText.trim()") == "$"
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _amount(shown: str) -> str:
|
|
568
|
+
"""An amount as typed: the page shows `50,000.00` for 50000."""
|
|
569
|
+
plain = shown.replace(",", "")
|
|
570
|
+
return plain.removesuffix(".00") if re.fullmatch(r"-?\d+\.\d\d", plain) else plain
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _react_options(page: Page, box: Locator) -> list[str]:
|
|
574
|
+
"""A react-select's options, from its menu opened and closed again."""
|
|
575
|
+
box.click()
|
|
576
|
+
options = page.locator("[id*=-option-]:visible")
|
|
577
|
+
with suppress(Timeout):
|
|
578
|
+
options.first.wait_for(timeout=OPTION_TIMEOUT_MS)
|
|
579
|
+
found = [" ".join(t.split()) for t in options.all_inner_texts()]
|
|
580
|
+
box.press("Escape")
|
|
581
|
+
return found
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _react_pick(page: Page, box: Locator, f: Field, answer: str) -> None:
|
|
585
|
+
"""Types the answer into the react-select and picks the option that matches."""
|
|
586
|
+
box.click()
|
|
587
|
+
box.press_sequentially(answer.partition(" (")[0], delay=KEY_DELAY_MS) # the filter is plain
|
|
588
|
+
options = page.locator("[id*=-option-]:visible")
|
|
589
|
+
deadline = time.monotonic() + OPTION_TIMEOUT_MS / 1000
|
|
590
|
+
while time.monotonic() < deadline:
|
|
591
|
+
for option in options.all():
|
|
592
|
+
if matching.matches(f, answer, " ".join(option.inner_text().split())):
|
|
593
|
+
option.click()
|
|
594
|
+
return
|
|
595
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
596
|
+
box.press("Escape")
|
|
597
|
+
shown = [" ".join(t.split()) for t in options.all_inner_texts()][:5]
|
|
598
|
+
raise NotTaken(f"{f.id} has no option {answer!r} among {shown}")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
_ITEMS = "es => es.map(e => (e.getAttribute('aria-label') || e.innerText).trim())"
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _sdf_pick(page: Page, select: Locator, f: Field, answer: str) -> None:
|
|
605
|
+
"""Opens the list and picks the option that matches; a long list shows its first options
|
|
606
|
+
only, and its filter box narrows it to the answer."""
|
|
607
|
+
select.locator("[part=frame]").first.click()
|
|
608
|
+
items = page.locator("sdf-select-item:visible") # the open list's, in a floating pane
|
|
609
|
+
narrowed = False
|
|
610
|
+
texts: list[str] = []
|
|
611
|
+
deadline = time.monotonic() + OPTION_TIMEOUT_MS / 1000
|
|
612
|
+
while time.monotonic() < deadline:
|
|
613
|
+
texts = items.evaluate_all(_ITEMS) # at once: the list re-renders as it fills
|
|
614
|
+
found = next((n for n, t in enumerate(texts) if t and matching.matches(f, answer, t)), None)
|
|
615
|
+
if found is not None:
|
|
616
|
+
items.nth(found).click()
|
|
617
|
+
return
|
|
618
|
+
search = page.locator("input#select-filter-input:visible")
|
|
619
|
+
if not narrowed and search.count():
|
|
620
|
+
search.press_sequentially(answer, delay=KEY_DELAY_MS)
|
|
621
|
+
narrowed = True
|
|
622
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
623
|
+
page.keyboard.press("Escape")
|
|
624
|
+
raise NotTaken(f"{f.id} has no option {answer!r} among {texts[:5]}")
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _wfn_pick(page: Page, dropdown: Locator, f: Field, answer: str) -> None:
|
|
628
|
+
dropdown.click()
|
|
629
|
+
options = page.locator("[role=option]:visible")
|
|
630
|
+
deadline = time.monotonic() + OPTION_TIMEOUT_MS / 1000
|
|
631
|
+
while time.monotonic() < deadline:
|
|
632
|
+
for option in options.all():
|
|
633
|
+
text = " ".join(option.inner_text().split())
|
|
634
|
+
name = text.partition(" - ")[2] or text # `IL - Illinois`
|
|
635
|
+
if matching.matches(f, answer, text) or matching.matches(f, answer, name):
|
|
636
|
+
option.click()
|
|
637
|
+
return
|
|
638
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
639
|
+
page.keyboard.press("Escape")
|
|
640
|
+
raise NotTaken(f"{f.id} has no option {answer!r}")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _upload(page: Page, box: Locator, path: str) -> None:
|
|
644
|
+
"""The résumé's file input; the step then lists the file by name."""
|
|
645
|
+
box.set_input_files(path)
|
|
646
|
+
name = path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
647
|
+
try:
|
|
648
|
+
page.get_by_text(name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
649
|
+
except Timeout:
|
|
650
|
+
raise NotTaken(f"resume did not take {name}: {_errors(page)}") from None
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
_VALUE = """e => {
|
|
654
|
+
const pair = e.querySelector('wfn-radio-button'); // each holds the group's value
|
|
655
|
+
if (pair) return pair.getAttribute('radio-group-value') || null;
|
|
656
|
+
if (e.tagName === 'SDF-RADIO-GROUP') {
|
|
657
|
+
const on = e.querySelector('sdf-radio-button[aria-checked="true"]');
|
|
658
|
+
return on ? on.getAttribute('value') : null;
|
|
659
|
+
}
|
|
660
|
+
const select = e.tagName === 'WFN-DROPDOWN-LIST' // a list around a select of its own
|
|
661
|
+
? e.shadowRoot?.querySelector('sdf-select-simple') : e;
|
|
662
|
+
if (select?.tagName === 'SDF-SELECT-SIMPLE')
|
|
663
|
+
return (select.shadowRoot ?? select).querySelector('#selected-label')?.textContent.trim()
|
|
664
|
+
|| null;
|
|
665
|
+
if (e.type === 'checkbox') return e.checked ? 'Yes' : 'No';
|
|
666
|
+
if (e.getAttribute('role') === 'combobox') {
|
|
667
|
+
const box = e.closest('[class*="value-container"]') ?? e.parentElement.parentElement;
|
|
668
|
+
const many = [...box.querySelectorAll('[class*="multi-value__label"]')];
|
|
669
|
+
if (many.length) return many.map(m => m.textContent.trim());
|
|
670
|
+
return box.querySelector('[class*="single-value"]')?.textContent.trim() || null;
|
|
671
|
+
}
|
|
672
|
+
return e.value || null;
|
|
673
|
+
}"""
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _value(page: Page, selector: str, type_: FieldType) -> Answer | None:
|
|
677
|
+
box = page.locator(selector).first
|
|
678
|
+
if not box.count():
|
|
679
|
+
return None
|
|
680
|
+
if type_ is FieldType.FILE:
|
|
681
|
+
names = page.locator("text=/\\.(pdf|docx?)$/i").all_inner_texts()
|
|
682
|
+
return names[0].strip() if names else None
|
|
683
|
+
value = box.evaluate(_VALUE)
|
|
684
|
+
if type_ is FieldType.PHONE and isinstance(value, str):
|
|
685
|
+
digits = re.sub(r"\D", "", value)
|
|
686
|
+
return f"+{digits}" if len(digits) > 3 else None # a bare country code is empty
|
|
687
|
+
if type_ is FieldType.DATE and isinstance(value, str) and value:
|
|
688
|
+
with suppress(ValueError):
|
|
689
|
+
return datetime.strptime(value, DATE).date().isoformat()
|
|
690
|
+
return value
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _sign_in(page: Page, email: str, details: SignUpDetails | None) -> None:
|
|
694
|
+
"""Sign In's email, then the emailed code. An email the employer does not know is asked to
|
|
695
|
+
join its talent community first: name and mobile number."""
|
|
696
|
+
box = page.locator("#guestEmail")
|
|
697
|
+
_consented(page, box)
|
|
698
|
+
box.fill(email)
|
|
699
|
+
page.get_by_role("button", name="Continue").first.click()
|
|
700
|
+
first = page.locator("#guestFirstName")
|
|
701
|
+
_code_step(page).or_(first).first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
702
|
+
if first.count() and first.is_visible():
|
|
703
|
+
if details is None:
|
|
704
|
+
raise Refused("no_account")
|
|
705
|
+
_join(page, email, details)
|
|
706
|
+
_verify(page, email)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _join(page: Page, email: str, details: SignUpDetails) -> None:
|
|
710
|
+
assert details.name and details.phone # in `asks`: the core gives them
|
|
711
|
+
given, _, family = details.name.rpartition(" ")
|
|
712
|
+
page.locator("#guestFirstName").fill(given or family)
|
|
713
|
+
page.locator("#guestLastName").fill(family)
|
|
714
|
+
page.locator("#guestEmail").fill(email)
|
|
715
|
+
phone = page.locator("#login_view_phone")
|
|
716
|
+
phone.fill("")
|
|
717
|
+
phone.press_sequentially(details.phone, delay=KEY_DELAY_MS) # E.164 picks the country
|
|
718
|
+
page.get_by_role("button", name="Continue").first.click()
|
|
719
|
+
_code_step(page).first.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _consented(page: Page, step: Locator) -> None:
|
|
723
|
+
"""Some employers show a privacy notice before the step: Consent & Continue."""
|
|
724
|
+
consent = page.get_by_text("Consent & Continue", exact=True)
|
|
725
|
+
try:
|
|
726
|
+
step.or_(consent).first.wait_for(timeout=PAGE_TIMEOUT_MS)
|
|
727
|
+
except Timeout:
|
|
728
|
+
raise Unsupported(f"unhandled_rendering: no ADP sign-in on {page.url}") from None
|
|
729
|
+
if consent.count() and consent.first.is_visible():
|
|
730
|
+
consent.first.click()
|
|
731
|
+
step.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _code_step(page: Page) -> Locator:
|
|
735
|
+
return page.locator("#recruitment_login_guestLogin, #oneTimePassWord")
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _verify(page: Page, email: str) -> None:
|
|
739
|
+
""" "Get your verification code": the email, Send, the code, Verify; signed in, the career
|
|
740
|
+
center moves to its signed-in address, a moment before the session holds."""
|
|
741
|
+
send = page.locator("#recruitment_login_guestLogin")
|
|
742
|
+
if send.count() and send.is_visible():
|
|
743
|
+
option = page.locator("#emailIdentifierId")
|
|
744
|
+
if option.count():
|
|
745
|
+
option.click(force=True)
|
|
746
|
+
send.click()
|
|
747
|
+
code = page.locator("#oneTimePassWord")
|
|
748
|
+
code.wait_for(timeout=STEP_TIMEOUT_MS)
|
|
749
|
+
otp.enter(code, email, "ADP's verification code")
|
|
750
|
+
page.locator("#recruitment_login_verifyOTP").click()
|
|
751
|
+
deadline = time.monotonic() + SIGNED_IN_TIMEOUT_MS / 1000
|
|
752
|
+
while not page.url.startswith(SIGNED_IN):
|
|
753
|
+
if time.monotonic() > deadline:
|
|
754
|
+
raise Blocked(f"otp_rejected: ADP's verification code sent to {email}")
|
|
755
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
756
|
+
cid = parse_qs(urlsplit(page.url).query)["cid"][0]
|
|
757
|
+
while not page.evaluate(_HOLDS, f"{APPLICANTS}?cid={cid}"):
|
|
758
|
+
if time.monotonic() > deadline:
|
|
759
|
+
raise Blocked(f"otp_rejected: ADP's verification code sent to {email}")
|
|
760
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
@@ -205,10 +205,11 @@ scope = { is = "supported", date = 2026-10-20, commit = "d35da0b" }
|
|
|
205
205
|
[adp]
|
|
206
206
|
kind = "ats"
|
|
207
207
|
share = 0.0108
|
|
208
|
-
account = "to_read" #
|
|
208
|
+
account = "to_read" # a candidate profile per employer without a password, made on its talent community step and signed in by an emailed OTP
|
|
209
209
|
hosting = ["hosted"]
|
|
210
210
|
hosts = ["workforcenow.adp.com"]
|
|
211
|
-
|
|
211
|
+
adapter = "adp"
|
|
212
|
+
scope = { is = "supported", date = 2026-10-29, commit = "76371bd" }
|
|
212
213
|
|
|
213
214
|
[eures]
|
|
214
215
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|