atsfill 0.0.5.dev184__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.
Files changed (66) hide show
  1. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/PKG-INFO +2 -1
  2. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/README.md +1 -0
  3. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/pyproject.toml +1 -1
  4. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.5.dev187/src/atsfill/adapters/adp.py +760 -0
  7. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/adpmyjobs.py +5 -5
  8. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/workatastartup.py +20 -9
  9. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/commands.py +1 -0
  10. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/matching.py +12 -0
  11. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/results.py +2 -0
  12. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/registry.toml +3 -2
  13. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/LICENSE +0 -0
  14. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/__init__.py +0 -0
  15. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/ashby.py +0 -0
  16. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/bamboohr.py +0 -0
  17. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/breezy.py +0 -0
  18. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/dayforce.py +0 -0
  19. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/eightfold.py +0 -0
  20. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/greenhouse.py +0 -0
  21. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/gupy.py +0 -0
  22. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/hireology.py +0 -0
  23. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/hiringthing.py +0 -0
  24. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/hrmdirect.py +0 -0
  25. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/isolvedhire.py +0 -0
  26. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/jazzhr.py +0 -0
  27. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/jobaffinity.py +0 -0
  28. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/jobstreet.py +0 -0
  29. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/join.py +0 -0
  30. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/lever.py +0 -0
  31. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/manatal.py +0 -0
  32. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/oracle.py +0 -0
  33. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/paycor.py +0 -0
  34. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/paylocity.py +0 -0
  35. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/personio.py +0 -0
  36. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/phenom.py +0 -0
  37. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/pinpoint.py +0 -0
  38. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/recruitee.py +0 -0
  39. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/recruiterflow.py +0 -0
  40. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/rippling.py +0 -0
  41. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/smartrecruiters.py +0 -0
  42. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/successfactors.js +0 -0
  43. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/successfactors.py +0 -0
  44. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/taleo.py +0 -0
  45. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/teamtailor.py +0 -0
  46. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/ukg.py +0 -0
  47. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/welcomekit.py +0 -0
  48. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/workable.py +0 -0
  49. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/workday.py +0 -0
  50. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/wttj.py +0 -0
  51. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/adapters/zohorecruit.py +0 -0
  52. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/cli.py +0 -0
  53. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/__init__.py +0 -0
  54. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/accounts.py +0 -0
  55. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/adapter.py +0 -0
  56. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/attempts.py +0 -0
  57. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/browser.py +0 -0
  58. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/conditions.py +0 -0
  59. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/html.py +0 -0
  60. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/otp.py +0 -0
  61. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/passwords.py +0 -0
  62. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/paths.py +0 -0
  63. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/registry.py +0 -0
  64. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/snapshot.py +0 -0
  65. {atsfill-0.0.5.dev184 → atsfill-0.0.5.dev187}/src/atsfill/core/telemetry.py +0 -0
  66. {atsfill-0.0.5.dev184 → 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.dev184
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
  [![wttj run history](https://shbernal.github.io/atsfill/run-history/wttj.svg)](https://shbernal.github.io/atsfill/run-history/)
90
90
  [![workday run history](https://shbernal.github.io/atsfill/run-history/workday.svg)](https://shbernal.github.io/atsfill/run-history/)
91
91
  [![successfactors run history](https://shbernal.github.io/atsfill/run-history/successfactors.svg)](https://shbernal.github.io/atsfill/run-history/)
92
+ [![adp run history](https://shbernal.github.io/atsfill/run-history/adp.svg)](https://shbernal.github.io/atsfill/run-history/)
92
93
  [![adpmyjobs run history](https://shbernal.github.io/atsfill/run-history/adpmyjobs.svg)](https://shbernal.github.io/atsfill/run-history/)
93
94
  [![isolvedhire run history](https://shbernal.github.io/atsfill/run-history/isolvedhire.svg)](https://shbernal.github.io/atsfill/run-history/)
94
95
  [![jobstreet run history](https://shbernal.github.io/atsfill/run-history/jobstreet.svg)](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
  [![wttj run history](https://shbernal.github.io/atsfill/run-history/wttj.svg)](https://shbernal.github.io/atsfill/run-history/)
61
61
  [![workday run history](https://shbernal.github.io/atsfill/run-history/workday.svg)](https://shbernal.github.io/atsfill/run-history/)
62
62
  [![successfactors run history](https://shbernal.github.io/atsfill/run-history/successfactors.svg)](https://shbernal.github.io/atsfill/run-history/)
63
+ [![adp run history](https://shbernal.github.io/atsfill/run-history/adp.svg)](https://shbernal.github.io/atsfill/run-history/)
63
64
  [![adpmyjobs run history](https://shbernal.github.io/atsfill/run-history/adpmyjobs.svg)](https://shbernal.github.io/atsfill/run-history/)
64
65
  [![isolvedhire run history](https://shbernal.github.io/atsfill/run-history/isolvedhire.svg)](https://shbernal.github.io/atsfill/run-history/)
65
66
  [![jobstreet run history](https://shbernal.github.io/atsfill/run-history/jobstreet.svg)](https://shbernal.github.io/atsfill/run-history/)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev184"
3
+ version = "0.0.5.dev187"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev184"
3
+ version = "0.0.5.dev187"
4
4
  description = "Read, dry-run and submit job application forms on hiring systems: Ashby, Greenhouse, Teamtailor, Welcome to the Jungle."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,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)
@@ -408,15 +408,15 @@ class ADPMyJobs:
408
408
  every = len(chosen) == len(labels)
409
409
  incoming.setdefault(target, []).append(Is(ids[qid], None if every else chosen))
410
410
  label = _text(raw.get("questionDescription") or qid)
411
+ knockouts = [labels[a["answerId"]] for a in answers if a.get("knockoutRule")] or None
411
412
  match raw["questionType"]:
412
413
  case "Instructions":
413
414
  continue
414
- case "Single choice":
415
+ case "Single choice" | "Multiple choice" as kind:
415
416
  self._codes[ids[qid]] = labels
416
- f = Field(ids[qid], label, FieldType.CHOICE, True, list(labels.values()))
417
- case "Multiple choice":
418
- self._codes[ids[qid]] = labels
419
- f = Field(ids[qid], label, FieldType.MULTI_CHOICE, True, list(labels.values()))
417
+ type_ = FieldType.CHOICE if kind == "Single choice" else FieldType.MULTI_CHOICE
418
+ options = list(labels.values())
419
+ f = Field(ids[qid], label, type_, True, options, knockouts=knockouts)
420
420
  case "Free form":
421
421
  f = Field(ids[qid], label, FieldType.TEXT, True)
422
422
  case "Calendar":
@@ -390,6 +390,8 @@ class Control:
390
390
  return form.locator("input[type=tel]")
391
391
  case "country" | "combo":
392
392
  return form.locator(f'input[aria-label="{self.where}"]')
393
+ case "companies":
394
+ return form.locator('input[aria-label="Company name"]')
393
395
  case "multi" | "skills":
394
396
  return form.get_by_label(self.where, exact=False)
395
397
  case "month": # the selects' names carry the entry's id: <id>_start_date_month
@@ -429,15 +431,7 @@ STEPS: dict[str, list[Control]] = {
429
431
  Control("phone_number", "Phone Number", "phone", FieldType.PHONE, False),
430
432
  Control("share", "What is your job search status?", "radio", C, True),
431
433
  Control("yc_affiliation", "Have you worked at a YC company?", "radio", C, True),
432
- Control(
433
- "hidden_companies",
434
- "YC companies to be hidden from",
435
- "combo",
436
- T,
437
- False,
438
- "Company name",
439
- unfillable="unhandled_rendering: a list of YC companies to be hidden from",
440
- ),
434
+ Control("hidden_companies", "YC companies to be hidden from", "companies", M, False),
441
435
  ],
442
436
  "location": [
443
437
  Control("city_current", "What city do you live in?", "place", TA, True),
@@ -625,6 +619,8 @@ def _value(form: Locator, c: Control) -> Answer | None:
625
619
  return target.evaluate(_SINGLE_VALUE) or None
626
620
  case "multi":
627
621
  return target.evaluate(_CHIPS)
622
+ case "companies":
623
+ return target.evaluate(_COMPANIES)
628
624
  case "skills":
629
625
  return form.evaluate(_SKILLS)
630
626
  return target.input_value()
@@ -640,6 +636,8 @@ _CHIPS = """e => {
640
636
  const box = e.closest("[id$=__select]") ?? e.parentElement.parentElement.parentElement;
641
637
  return [...box.querySelectorAll("[aria-label^='Remove ']")].map(c => c.ariaLabel.slice(7));
642
638
  }"""
639
+ _COMPANIES = """e => [...e.parentElement.previousElementSibling.querySelectorAll(
640
+ "span[role=button] + span")].map(s => s.textContent.trim())"""
643
641
  _SKILLS = """form => {
644
642
  const groups = {};
645
643
  for (const r of form.querySelectorAll("input[type=radio][name^=top_skills_]")) {
@@ -684,6 +682,8 @@ def _profile_fill(page: Page, form: Locator, c: Control, answer: Answer) -> None
684
682
  form.locator(f'[aria-label="Remove {chip}"]').click()
685
683
  for item in [a for a in answer if a not in have]:
686
684
  _pick(page, c, target, item, strict=True, exact=True)
685
+ case "companies", list():
686
+ _companies(page, target, c, answer)
687
687
  case "skills", list():
688
688
  _skills(page, form, target, c, answer)
689
689
  case kind, _:
@@ -715,6 +715,17 @@ def _pick(
715
715
  box.press("Escape")
716
716
 
717
717
 
718
+ def _companies(page: Page, box: Locator, c: Control, answer: list[str]) -> None:
719
+ """Removes the companies the answer leaves out, then searches each other one and picks the
720
+ result named exactly as it: a nearest name would hide the profile from another company."""
721
+ chips = box.locator("xpath=../preceding-sibling::div[1]//div[span[@role='button']]")
722
+ while extra := [i for i, h in enumerate(box.evaluate(_COMPANIES)) if h not in answer]:
723
+ chips.nth(extra[0]).locator("span[role=button]").click()
724
+ have = box.evaluate(_COMPANIES)
725
+ for company in [a for a in answer if a not in have]:
726
+ _pick(page, c, box, company, strict=True, exact=True)
727
+
728
+
718
729
  def _skills(page: Page, form: Locator, box: Locator, c: Control, answer: list[str]) -> None:
719
730
  """Removes the skills the answer leaves out, adds the others, then picks each level."""
720
731
  wanted: dict[str, str] = {}
@@ -424,6 +424,7 @@ def _fill(
424
424
  if problems := matching.problems(result.fields, answers):
425
425
  result.status, result.reason = Status.REFUSED, "invalid_answers: " + "; ".join(problems)
426
426
  return result
427
+ result.warnings = matching.knockouts(result.fields, answers)
427
428
  adapter.fill(page, answers)
428
429
  _shown_as_listed(page, adapter, result, answers)
429
430
  if result.status is not Status.READY:
@@ -159,3 +159,15 @@ def problems(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
159
159
  and (problem := _answer_problem(by_id[i], a, form))
160
160
  ]
161
161
  return out
162
+
163
+
164
+ def knockouts(fields: list[Field], answers: dict[str, Answer]) -> list[str]:
165
+ """The answers that end the application: never refused, as one can be the truthful one."""
166
+ by_id = {f.id: f for f in fields}
167
+ return [
168
+ f"{i}: {', '.join(map(repr, chosen))} ends the application ({f.label})"
169
+ for i, a in answers.items()
170
+ if (f := by_id.get(i))
171
+ and f.knockouts
172
+ and (chosen := [v for v in ([a] if isinstance(a, str) else a) if v in f.knockouts])
173
+ ]
@@ -75,6 +75,7 @@ class Field:
75
75
  one_of: str | None = None # an alternative: one of the fields with this key answers (ADR 0005)
76
76
  option_sets: list[OptionSet] | None = None # a dependent list's; `options` where none holds
77
77
  min_length: int | None = None # a text the system refuses shorter
78
+ knockouts: list[str] | None = None # the options whose choice ends the application (ADR 0005)
78
79
 
79
80
 
80
81
  @dataclass(frozen=True)
@@ -106,6 +107,7 @@ class Result:
106
107
  unloaded: list[str] = field(default_factory=list) # steps a profile read could not load yet
107
108
  notice: str | None = None # telemetry's, the first time it is on (ADR 0016)
108
109
  report: str | None = None # how to send a failed run's report, when it was not sent
110
+ warnings: list[str] = field(default_factory=list) # the knockout answers a fill gave
109
111
 
110
112
 
111
113
  @dataclass
@@ -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" # Apply opens a talent community step, name, email and a required mobile number, verified with an emailed OTP, then the application's wizard
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
- scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: sign_up_phone: the talent community step before the form requires a mobile number, even when its OTP goes to the email (14 of 15 employers)" }
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