atsfill 0.0.5.dev161__tar.gz → 0.0.5.dev162__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 (65) hide show
  1. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/PKG-INFO +2 -1
  2. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/README.md +1 -0
  3. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/pyproject.toml +1 -1
  4. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/pyproject.toml.orig +1 -1
  5. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/__init__.py +2 -0
  6. atsfill-0.0.5.dev162/src/atsfill/adapters/adpmyjobs.py +760 -0
  7. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/adapter.py +13 -0
  8. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/attempts.py +1 -0
  9. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/commands.py +49 -15
  10. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/registry.toml +3 -2
  11. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/LICENSE +0 -0
  12. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/__init__.py +0 -0
  13. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/ashby.py +0 -0
  14. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/bamboohr.py +0 -0
  15. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/breezy.py +0 -0
  16. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/dayforce.py +0 -0
  17. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/eightfold.py +0 -0
  18. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/greenhouse.py +0 -0
  19. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/gupy.py +0 -0
  20. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/hireology.py +0 -0
  21. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/hiringthing.py +0 -0
  22. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/hrmdirect.py +0 -0
  23. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/isolvedhire.py +0 -0
  24. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/jazzhr.py +0 -0
  25. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/jobaffinity.py +0 -0
  26. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/jobstreet.py +0 -0
  27. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/join.py +0 -0
  28. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/lever.py +0 -0
  29. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/manatal.py +0 -0
  30. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/oracle.py +0 -0
  31. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/paycor.py +0 -0
  32. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/paylocity.py +0 -0
  33. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/personio.py +0 -0
  34. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/phenom.py +0 -0
  35. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/pinpoint.py +0 -0
  36. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/recruitee.py +0 -0
  37. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/recruiterflow.py +0 -0
  38. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/rippling.py +0 -0
  39. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/smartrecruiters.py +0 -0
  40. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/successfactors.js +0 -0
  41. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/successfactors.py +0 -0
  42. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/taleo.py +0 -0
  43. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/teamtailor.py +0 -0
  44. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/ukg.py +0 -0
  45. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/welcomekit.py +0 -0
  46. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/workable.py +0 -0
  47. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/workatastartup.py +0 -0
  48. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/workday.py +0 -0
  49. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/wttj.py +0 -0
  50. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/adapters/zohorecruit.py +0 -0
  51. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/cli.py +0 -0
  52. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/__init__.py +0 -0
  53. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/accounts.py +0 -0
  54. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/browser.py +0 -0
  55. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/conditions.py +0 -0
  56. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/html.py +0 -0
  57. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/matching.py +0 -0
  58. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/otp.py +0 -0
  59. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/passwords.py +0 -0
  60. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/paths.py +0 -0
  61. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/registry.py +0 -0
  62. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/results.py +0 -0
  63. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/snapshot.py +0 -0
  64. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/src/atsfill/core/telemetry.py +0 -0
  65. {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev162}/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.dev161
3
+ Version: 0.0.5.dev162
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
@@ -93,6 +93,7 @@ It is built for agents with a shell: every command prints one JSON document and
93
93
  [![jobstreet run history](https://shbernal.github.io/atsfill/run-history/jobstreet.svg)](https://shbernal.github.io/atsfill/run-history/)
94
94
  [![gupy run history](https://shbernal.github.io/atsfill/run-history/gupy.svg)](https://shbernal.github.io/atsfill/run-history/)
95
95
  [![ukg run history](https://shbernal.github.io/atsfill/run-history/ukg.svg)](https://shbernal.github.io/atsfill/run-history/)
96
+ [![adpmyjobs run history](https://shbernal.github.io/atsfill/run-history/adpmyjobs.svg)](https://shbernal.github.io/atsfill/run-history/)
96
97
  <!-- /run-history -->
97
98
 
98
99
  Each line is a hiring system atsfill has an adapter for, and its [run history](https://shbernal.github.io/atsfill/run-history/): the last 30 days of runs, green when a form filled or an application went through, red when it failed.
@@ -64,6 +64,7 @@ It is built for agents with a shell: every command prints one JSON document and
64
64
  [![jobstreet run history](https://shbernal.github.io/atsfill/run-history/jobstreet.svg)](https://shbernal.github.io/atsfill/run-history/)
65
65
  [![gupy run history](https://shbernal.github.io/atsfill/run-history/gupy.svg)](https://shbernal.github.io/atsfill/run-history/)
66
66
  [![ukg run history](https://shbernal.github.io/atsfill/run-history/ukg.svg)](https://shbernal.github.io/atsfill/run-history/)
67
+ [![adpmyjobs run history](https://shbernal.github.io/atsfill/run-history/adpmyjobs.svg)](https://shbernal.github.io/atsfill/run-history/)
67
68
  <!-- /run-history -->
68
69
 
69
70
  Each line is a hiring system atsfill has an adapter for, and its [run history](https://shbernal.github.io/atsfill/run-history/): the last 30 days of runs, green when a form filled or an application went through, red when it failed.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atsfill"
3
- version = "0.0.5.dev161"
3
+ version = "0.0.5.dev162"
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.dev161"
3
+ version = "0.0.5.dev162"
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.adpmyjobs import ADPMyJobs
1
2
  from atsfill.adapters.ashby import Ashby
2
3
  from atsfill.adapters.bamboohr import BambooHR
3
4
  from atsfill.adapters.breezy import Breezy
@@ -72,6 +73,7 @@ def adapters() -> list[Adapter]:
72
73
  UKG(),
73
74
  IsolvedHire(),
74
75
  Jobstreet(),
76
+ ADPMyJobs(),
75
77
  SuccessFactors(), # it claims a career site's job path on any host
76
78
  Phenom(), # last: it claims a URL by its path alone
77
79
  ]
@@ -0,0 +1,760 @@
1
+ """ADP MyJobs, ADP's candidate experience (`myjobs.adp.com/<career site>/cx/job/<id>`), as
2
+ docs/adapters/adpmyjobs.md observes.
3
+
4
+ Each career site keeps its own candidate profile, without a password: every sign-in is an
5
+ emailed OTP. Apply sends the application; the form is what follows it, a workflow of sections
6
+ each saved by its Next, the last Next completing the application. The read lists the open
7
+ sections from ADP's API with the page's token; the fill answers each on the page, page by page,
8
+ and the read-back is each page's values before its Next.
9
+ """
10
+
11
+ import re
12
+ import time
13
+ from contextlib import suppress
14
+ from dataclasses import dataclass, field, replace
15
+ from html import unescape
16
+ from typing import Any
17
+ from urllib.parse import parse_qs, quote, urlsplit
18
+
19
+ from atsfill.core import matching, otp
20
+ from atsfill.core.adapter import (
21
+ Blocked,
22
+ Gap,
23
+ NotTaken,
24
+ Refused,
25
+ SignedOut,
26
+ SignUpDetails,
27
+ Unsupported,
28
+ known_gap,
29
+ )
30
+ from atsfill.core.browser import Locator, Page, Request, Timeout
31
+ from atsfill.core.results import Answer, AnyOf, Condition, Field, FieldType, Is, SideEffect
32
+
33
+ HOST = "myjobs.adp.com"
34
+ POSTING = re.compile(r"^/(?P<site>[^/]+)/cx/(?:job/(?P<job>\d+)|job-details)")
35
+ API = "https://my.adp.com/myadp_prefix/mycareer/api"
36
+ QUESTIONNAIRES = f"{API}/rm/staffing/v3/work-fulfillment/recruiting-questionnaires"
37
+ APPLICATIONS = f"https://{HOST}/cx/staffing/v2/job-applications"
38
+ SIGN_IN_TIMEOUT_MS = 60_000 # the sign-in page takes ~20 s to render its form
39
+ SIGN_IN_STEP_TIMEOUT_MS = 30_000
40
+ SIGNED_IN_TIMEOUT_MS = 60_000
41
+ POSTING_TIMEOUT_MS = 45_000
42
+ SECTION_TIMEOUT_MS = 45_000
43
+ LOAD_TRIES = 3
44
+ SUBMIT_TIMEOUT_MS = 90_000
45
+ SETTLE_MS = 1_500 # an answer shows its follow-ups, a Next saves and routes
46
+ OPTION_TIMEOUT_MS = 10_000
47
+ UPLOAD_TIMEOUT_MS = 60_000
48
+ KEY_DELAY_MS = 40
49
+ SKIPPED = {"INSTRUCTIONS", "HEADER"}
50
+ YES_NO = ["Yes", "No"]
51
+
52
+ _FETCH = """async ([url, role, site]) => {
53
+ const headers = {myjobstoken: sessionStorage.getItem('myJobsToken') ?? ''};
54
+ if (role) headers.rolecode = 'manager';
55
+ if (site) headers.careersiteid = site;
56
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
57
+ const sep = url.includes('?') ? '&' : '?';
58
+ const response = await fetch(`${url}${sep}tz=${tz}`, {credentials: 'include', headers});
59
+ const said = response.ok ? '' : (await response.text()).slice(0, 300);
60
+ if (!response.ok) throw new Error(`${url} answered ${response.status}: ${said}`);
61
+ return response.json();
62
+ }"""
63
+
64
+
65
+ @dataclass
66
+ class Section:
67
+ component: str # `contact`, `prescreen1`
68
+ id: str # `contact.1520639`
69
+ title: str
70
+ completed: bool
71
+ fields: list[Field] = field(default_factory=list)
72
+ pages: list[set[str]] = field(default_factory=list) # the question ids on each page
73
+ review: bool = False # the pages end on a review of the answers, before that last Next
74
+
75
+
76
+ class ADPMyJobs:
77
+ name = "adpmyjobs"
78
+ per_employer = True # a candidate profile per career site: the scope is the site
79
+ asks = {"name", "phone"}
80
+ known_gaps = {"recruiting_workflow": Gap.FLOW}
81
+ side_effects = [
82
+ SideEffect("sign_up", "employer", "login --create creates the career site's profile"),
83
+ SideEffect("load", "employer", "Apply sends the application before the form shows"),
84
+ SideEffect("fill", "employer", "each section but the last is saved to it when left"),
85
+ SideEffect("submit", "employer", "the last section's Next completes the application"),
86
+ ]
87
+
88
+ def __init__(self) -> None:
89
+ self._site_id = "" # the career site's `careersiteid`
90
+ self._sections: list[Section] = []
91
+ self._codes: dict[str, dict[str, str]] = {} # by field id: an option's label by code
92
+ self._values: dict[str, Answer] = {}
93
+ self._fixed: set[str] = set() # disabled questions, their value shown as text
94
+
95
+ def detect(self, url: str, page: Page | None) -> bool:
96
+ return _posting(url) is not None
97
+
98
+ def posting(self, url: str) -> str:
99
+ found = _posting(url)
100
+ return f"https://{HOST}/{found[0]}/cx/job/{found[1]}" if found else url
101
+
102
+ def account_scope(self, url: str) -> str:
103
+ found = _posting(url)
104
+ return found[0] if found else ""
105
+
106
+ def login_url(self, scope: str | None) -> str:
107
+ return f"https://{HOST}/{scope}/auth"
108
+
109
+ def signed_in(self, request: Request, scope: str | None) -> bool:
110
+ """Signed out, ADP's API redirects to its login page."""
111
+ response = request.get(f"https://{HOST}/cx/staffing/v2/job-applications", max_redirects=0)
112
+ return not 300 <= response.status < 400
113
+
114
+ def sign_in_by_otp(self, page: Page, email: str) -> None:
115
+ _sign_in(page, email, None)
116
+
117
+ def sign_up_by_otp(self, page: Page, email: str, details: SignUpDetails) -> None:
118
+ _sign_in(page, email, details)
119
+
120
+ def open(self, page: Page) -> str | None:
121
+ """Loads the posting signed in: Apply sends the application. One the account applied
122
+ to has its unfinished workflow continued, as My Jobs does."""
123
+ found = _posting(page.url)
124
+ if found is None:
125
+ raise RuntimeError(f"{page.url} is not an ADP MyJobs posting")
126
+ site, req = found
127
+ settings = _career_site(page, site)
128
+ self._site_id = str(settings["externalId"])
129
+ if not (settings.get("enabledFeatures") or {}).get("candidateApplicationWorkflow"):
130
+ detail = f"{site} continues each application on ADP Recruiting, after Apply"
131
+ raise Unsupported(known_gap(self, "recruiting_workflow", detail))
132
+ page.goto(f"https://{HOST}/{site}/cx/job-details?reqId={req}")
133
+ deadline = time.monotonic() + POSTING_TIMEOUT_MS / 1000
134
+ while not page.evaluate("() => !!sessionStorage.getItem('myJobsToken')"):
135
+ if urlsplit(page.url).path.endswith("/auth"):
136
+ raise SignedOut("login_required: the posting sent to the sign-in")
137
+ if time.monotonic() > deadline:
138
+ raise RuntimeError("the posting loaded without a session token")
139
+ page.wait_for_timeout(SETTLE_MS)
140
+ with suppress(Timeout): # left with its requests in flight, the workflow can spin
141
+ page.wait_for_load_state("networkidle", timeout=POSTING_TIMEOUT_MS)
142
+ if application := self._application(page, req):
143
+ if (application.get("applicationStatusCode") or {}).get("label") != "CONTINUE":
144
+ raise Unsupported(f"already_sent: the account applied ({_status(application)})")
145
+ assignment = application["assignmentID"]
146
+ workflow = self._get(
147
+ page, f"{QUESTIONNAIRES}/WORKFLOW?assignmentID={assignment}&rmapp=forms"
148
+ )
149
+ component = next(
150
+ (
151
+ labels["component"]
152
+ for raw in workflow.get("questionSections") or []
153
+ if raw.get("completionStatusCode") != "completed"
154
+ and "component" in (labels := _labels(raw))
155
+ ),
156
+ None,
157
+ )
158
+ if component is None:
159
+ raise Unsupported("already_sent: the account completed its application")
160
+ page.goto(
161
+ f"https://{HOST}/{site}/cx/app?reqId={req}&assignmentId={assignment}"
162
+ f"&workflowComponent={component}"
163
+ )
164
+ self._wait_for_section(page)
165
+ return None
166
+ apply = page.get_by_role("button", name="Apply", exact=True)
167
+ try:
168
+ apply.first.wait_for(timeout=max(0, deadline - time.monotonic()) * 1000)
169
+ except Timeout:
170
+ raise Unsupported("posting_gone: the posting shows no Apply") from None
171
+ apply.first.click()
172
+ deadline = time.monotonic() + POSTING_TIMEOUT_MS / 1000
173
+ while not (made := self._application(page, req)):
174
+ if time.monotonic() > deadline:
175
+ raise RuntimeError("Apply listed no application")
176
+ page.wait_for_timeout(SETTLE_MS)
177
+ self._wait_for_section(page)
178
+ return f"Apply made application {made['assignmentID']}, listed {_status(made)}"
179
+
180
+ def _application(self, page: Page, req: str) -> dict[str, Any] | None:
181
+ applications = self._get(page, APPLICATIONS).get("jobApplications") or []
182
+ return next((a for a in applications if a.get("reqID") == req), None)
183
+
184
+ def browser_read(self, page: Page) -> list[Field]:
185
+ """Every section the workflow lists, from its questionnaire; a first section that is
186
+ only a statement to agree to is agreed and saved, as ADP lists the rest only after."""
187
+ sections = self._workflow(page)
188
+ while len(sections) == 1 and not sections[0].completed and _statement(sections[0]):
189
+ self._answer(page, sections[0], {f.id: "Yes" for f in sections[0].fields})
190
+ self._next(page, sections[0])
191
+ sections = self._workflow(page)
192
+ self._sections = [s for s in sections if not s.completed] # sent, with no way back
193
+ return [f for s in self._sections for f in s.fields]
194
+
195
+ def fill(self, page: Page, answers: dict[str, Answer]) -> None:
196
+ """Answers each section page by page, saving each with Next, up to the last section's
197
+ last page, or its review."""
198
+ self._values = {}
199
+ answers = {f.id: f.prefilled for s in self._sections for f in s.fields
200
+ if f.prefilled is not None} | answers # fmt: skip
201
+ for section in self._sections:
202
+ for number, ids in enumerate(section.pages or [set()]):
203
+ _on_section(page, section)
204
+ _to_page(page, section, ids)
205
+ if unlisted := _unlisted(page, section):
206
+ raise NotTaken(f"{section.title} shows {unlisted}, which the read did not list")
207
+ self._answer(page, section, answers)
208
+ self._values |= self._read(page, section)
209
+ last = number == max(len(section.pages), 1) - 1
210
+ if section is self._sections[-1] and last and not section.review:
211
+ return
212
+ self._next(page, section, last and not section.review)
213
+ if section.review and section is not self._sections[-1]:
214
+ self._next(page, section) # the review's Next saves the section
215
+
216
+ def read_back(self, page: Page) -> dict[str, Answer]:
217
+ """Each section's values as it was left."""
218
+ return dict(self._values)
219
+
220
+ def submit(self, page: Page) -> str | None:
221
+ """The last section's Next, which completes the application: the page then says so,
222
+ at the same address."""
223
+ page.get_by_role("button", name="Next").click()
224
+ done = page.get_by_text("Application Submitted")
225
+ try:
226
+ done.first.wait_for(timeout=SUBMIT_TIMEOUT_MS)
227
+ except Timeout:
228
+ raise RuntimeError(f"the last Next showed no confirmation: {_errors(page)}") from None
229
+ return "the page said Application Submitted!"
230
+
231
+ def _get(self, page: Page, url: str) -> Any:
232
+ """ADP's API as the page calls it: myjobs's with the token alone, my.adp.com's with a
233
+ role too, and its forms' with the career site."""
234
+ role = url.startswith(API)
235
+ return page.evaluate(_FETCH, [url, role, self._site_id if "/api/rm/" in url else ""])
236
+
237
+ def _assignment(self, page: Page) -> str:
238
+ assignment = parse_qs(urlsplit(page.url).query).get("assignmentId")
239
+ if not assignment:
240
+ raise RuntimeError(f"{page.url} shows no application")
241
+ return assignment[0]
242
+
243
+ def _wait_for_section(self, page: Page) -> None:
244
+ page.wait_for_url(
245
+ lambda u: "workflowComponent=" in u, timeout=SECTION_TIMEOUT_MS, wait_until="commit"
246
+ )
247
+ workflow = page.url
248
+ for attempt in range(LOAD_TRIES): # it can spin: loaded again from the posting, it shows
249
+ try:
250
+ page.get_by_role("button", name="Next").wait_for(timeout=SECTION_TIMEOUT_MS)
251
+ return
252
+ except Timeout:
253
+ if attempt == LOAD_TRIES - 1:
254
+ raise
255
+ site = urlsplit(workflow).path.split("/")[1]
256
+ req = parse_qs(urlsplit(workflow).query)["reqId"][0]
257
+ page.goto(f"https://{HOST}/{site}/cx/job-details?reqId={req}")
258
+ with suppress(Timeout):
259
+ page.wait_for_load_state("networkidle", timeout=POSTING_TIMEOUT_MS)
260
+ page.goto(workflow)
261
+
262
+ def _workflow(self, page: Page) -> list[Section]:
263
+ assignment = self._assignment(page)
264
+ workflow = self._get(
265
+ page, f"{QUESTIONNAIRES}/WORKFLOW?assignmentID={assignment}&rmapp=forms"
266
+ )
267
+ sections = []
268
+ for raw in workflow.get("questionSections") or []:
269
+ labels = _labels(raw)
270
+ section = Section(
271
+ labels.get("component", raw["sectionID"]),
272
+ raw["sectionID"],
273
+ raw.get("sectionTitle") or "",
274
+ raw.get("completionStatusCode") == "completed",
275
+ )
276
+ if not section.completed:
277
+ section.fields = self._section_fields(page, section, assignment)
278
+ sections.append(section)
279
+ return sections
280
+
281
+ def _section_fields(self, page: Page, section: Section, assignment: str) -> list[Field]:
282
+ if section.component.startswith("prescreen"):
283
+ url = f"{API}/staffing/v2/job-application/workflow/{assignment}/prescreen.questions"
284
+ questions = self._get(page, url).get("prescreeningQuestions") or []
285
+ return self._prescreen(section, questions)
286
+ url = f"{QUESTIONNAIRES}/{section.id}?assignmentID={assignment}&rmapp=forms"
287
+ pages = self._get(page, url).get("questionSections") or []
288
+ section.review = any(_labels(p).get("sectionReview") == "true" for p in pages)
289
+ pages = [p for p in pages if _labels(p).get("sectionReview") != "true"]
290
+ questions = []
291
+ for p in pages: # a form of pages lists the open page's questions, the rest by id
292
+ if "questions" not in p:
293
+ listed = self._get(page, f"{url}&sectionID={p['sectionID']}")
294
+ p = next(iter(listed.get("questionSections") or []), {})
295
+ questions += p.get("questions") or []
296
+ section.pages.append({q["questionID"] for q in p.get("questions") or []})
297
+ # A form of pages that is not open yet lists none: the fill stops on its questions,
298
+ # which a read once it is open lists.
299
+ fields = [f for q in questions if (f := self._question(page, section, q, questions))]
300
+ fields = _shown_by_dependencies(section, questions, fields, self._codes)
301
+ return _required_by_answers(section, questions, fields, self._codes)
302
+
303
+ def _question(
304
+ self, page: Page, section: Section, raw: dict[str, Any], questions: list[dict[str, Any]]
305
+ ) -> Field | None:
306
+ kind = raw.get("answerDataTypeCode")
307
+ if kind in SKIPPED or (raw.get("disabledIndicator") and not raw.get("answerDefaultValue")):
308
+ return None # a text, or a value the system fills itself
309
+ field_id = f"{section.component}.{raw['questionID']}"
310
+ label = _text(raw.get("questionText") or raw["questionID"])
311
+ country = _country(questions)
312
+ for x in raw.get("questionLabels") or []: # `ZIP Code:` for `Postcode` in the US
313
+ if country and x["code"].endswith(f"_{country}_label"):
314
+ label = _text(x["value"]).rstrip(":")
315
+ required = bool(raw.get("requiredIndicator"))
316
+ default = raw.get("answerDefaultValue")
317
+ options: list[str] | None = None
318
+ match kind:
319
+ case "TEXT" | "EMAIL":
320
+ type_ = FieldType.TEXT
321
+ case "PHONENUMBER":
322
+ type_ = FieldType.PHONE
323
+ case "BOOLEAN" | "CHECKBOX":
324
+ type_, options = FieldType.CHOICE, YES_NO
325
+ self._codes[field_id] = {"true": "Yes", "false": "No"}
326
+ case "UPLOAD":
327
+ type_ = FieldType.FILE
328
+ case "ESIGNATURE":
329
+ type_, label = FieldType.TEXT, "Electronic signature: your full legal name"
330
+ case (
331
+ "SELECTION"
332
+ | "COUNTRIES"
333
+ | "STATES"
334
+ | "RADIO"
335
+ | "LOOKUP"
336
+ | "MONTHS"
337
+ | "DAYS_OF_MONTH"
338
+ ):
339
+ type_ = FieldType.CHOICE
340
+ self._codes[field_id] = self._options(page, raw, questions)
341
+ options = list(self._codes[field_id].values())
342
+ case _:
343
+ reason = f"unhandled_rendering: {label!r} is a {kind} question"
344
+ return Field(field_id, label, FieldType.TEXT, required, unfillable=reason)
345
+ if isinstance(default, bool):
346
+ default = "true" if default else "false"
347
+ prefilled = self._codes.get(field_id, {}).get(default, default) if default else None
348
+ if raw.get("disabledIndicator"):
349
+ self._fixed.add(field_id)
350
+ return Field(
351
+ field_id, label, type_, required, options, prefilled=prefilled,
352
+ max_length=raw.get("maxLength"), min_length=raw.get("minLength"),
353
+ ) # fmt: skip
354
+
355
+ def _options(
356
+ self, page: Page, raw: dict[str, Any], questions: list[dict[str, Any]]
357
+ ) -> dict[str, str]:
358
+ """The codelist behind a question, by code; a state's, the default country's."""
359
+ link = next((x["href"] for x in raw.get("links") or [] if x.get("linkID") == "OPTIONS"), "")
360
+ if not link:
361
+ return {}
362
+ url = f"{API}/rm{link}&$skip=0&$top=1000"
363
+ if raw.get("answerDataTypeCode") == "STATES" and (country := _country(questions)):
364
+ url += "&$filter=" + quote(f'filter("COMPONENTcountry","{country}","eq")')
365
+ return {i["codeValue"]: i["longName"] for i in self._get(page, url).get("listItems") or []}
366
+
367
+ def _prescreen(self, section: Section, questions: list[dict[str, Any]]) -> list[Field]:
368
+ """The prescreen questions in order: an answer goes on to the next, or to the question
369
+ it branches to, or ends them; a question shows once an answer leads to it, a text to
370
+ read passing on its own condition."""
371
+ ordered = sorted(questions, key=lambda q: q.get("orderId", 0))
372
+ ids = {q["questionId"]: f"{section.component}.{q['questionId']}" for q in ordered}
373
+ incoming: dict[str, list[Condition | None]] = (
374
+ {ordered[0]["questionId"]: [None]} if ordered else {}
375
+ )
376
+ fields = []
377
+ for i, raw in enumerate(ordered):
378
+ qid = raw["questionId"]
379
+ ways = incoming.get(qid, [])
380
+ if not ways:
381
+ continue # no answer leads to it
382
+ here = None if None in ways else _any(ways)
383
+ following = ordered[i + 1]["questionId"] if i + 1 < len(ordered) else None
384
+ answers = raw.get("possibleAnswers") or []
385
+ labels = {a["answerId"]: _text(a["answerLabel"]) for a in answers}
386
+ if not answers and following:
387
+ free = raw["questionType"] != "Instructions"
388
+ incoming.setdefault(following, []).append(Is(ids[qid]) if free else here)
389
+ leads: dict[str, list[str]] = {}
390
+ for answer in answers:
391
+ target = answer.get("branchingQuestionId") or (
392
+ None if answer.get("endOfQuestionIndicator") else following
393
+ )
394
+ if target:
395
+ leads.setdefault(target, []).append(labels[answer["answerId"]])
396
+ for target, chosen in leads.items():
397
+ every = len(chosen) == len(labels)
398
+ incoming.setdefault(target, []).append(Is(ids[qid], None if every else chosen))
399
+ label = _text(raw.get("questionDescription") or qid)
400
+ match raw["questionType"]:
401
+ case "Instructions":
402
+ continue
403
+ case "Single choice":
404
+ self._codes[ids[qid]] = labels
405
+ f = Field(ids[qid], label, FieldType.CHOICE, True, list(labels.values()))
406
+ case "Free form":
407
+ f = Field(ids[qid], label, FieldType.TEXT, True)
408
+ case other:
409
+ reason = f"unhandled_rendering: {label!r} is a {other} question"
410
+ f = Field(ids[qid], label, FieldType.TEXT, True, unfillable=reason)
411
+ fields.append(replace(f, shown_when=here))
412
+ return fields
413
+
414
+ def _answer(self, page: Page, section: Section, answers: dict[str, Answer]) -> None:
415
+ """Answers the section's questions in order, each once it shows: a prescreen question
416
+ shows once the one before is answered."""
417
+ _on_section(page, section)
418
+ for f in section.fields:
419
+ if f.id not in answers or f.unfillable or f.id in self._fixed:
420
+ continue
421
+ box = _box(page, section, f)
422
+ if not box.count():
423
+ continue # not shown: its condition is unmet
424
+ if _value(box, f, self._codes.get(f.id, {})) == answers[f.id]:
425
+ continue # a list's default, the profile's value
426
+ _set(page, box, f, str(answers[f.id]), self._codes.get(f.id, {}))
427
+ page.wait_for_timeout(SETTLE_MS if f.type is FieldType.CHOICE else 0)
428
+
429
+ def _read(self, page: Page, section: Section) -> dict[str, Answer]:
430
+ values: dict[str, Answer] = {}
431
+ for f in section.fields:
432
+ box = _box(page, section, f)
433
+ if f.id in self._fixed and box.count() and f.prefilled is not None:
434
+ values[f.id] = f.prefilled # shown as text, as the profile has it
435
+ elif not f.unfillable and box.count():
436
+ value = _value(box, f, self._codes.get(f.id, {}))
437
+ if value:
438
+ values[f.id] = value
439
+ return values
440
+
441
+ def _next(self, page: Page, section: Section, last: bool = True) -> None:
442
+ """Next, once the section is left, or on a page of several, once the next page shows;
443
+ the page's errors where it stays."""
444
+ shown = _groups(page)
445
+ page.get_by_role("button", name="Next").click()
446
+ deadline = time.monotonic() + SECTION_TIMEOUT_MS / 1000
447
+ while f"workflowComponent={section.component}" in page.url:
448
+ if not last and _groups(page) != shown:
449
+ break
450
+ if time.monotonic() > deadline:
451
+ raise NotTaken(f"{section.title} did not save: {_errors(page)}")
452
+ page.wait_for_timeout(SETTLE_MS)
453
+ page.wait_for_timeout(SETTLE_MS)
454
+
455
+
456
+ def _posting(url: str) -> tuple[str, str] | None:
457
+ """The career site and the posting's id."""
458
+ parts = urlsplit(url)
459
+ if parts.hostname != HOST or not (match := POSTING.match(parts.path)):
460
+ return None
461
+ req = match["job"] or next(iter(parse_qs(parts.query).get("reqId", [])), None)
462
+ return (match["site"].lower(), req) if req else None
463
+
464
+
465
+ def _career_site(page: Page, site: str) -> dict[str, Any]:
466
+ """The career site's settings: its id, which ADP's API asks with each request, and the
467
+ features it enables."""
468
+ found = page.evaluate(
469
+ "url => fetch(url).then(r => r.ok ? r.json() : null)",
470
+ f"https://{HOST}/public/staffing/v1/career-site/{site}",
471
+ )
472
+ if not found:
473
+ raise Unsupported(f"posting_gone: no career site {site}")
474
+ return found["settings"]
475
+
476
+
477
+ def _sign_in(page: Page, email: str, details: SignUpDetails | None) -> None:
478
+ """ADP's sign-in: the email, then the passcode it emails, which signs in once typed and the
479
+ employer's privacy policy agreed to. An email the career site does not know gets "Create
480
+ your candidate profile" first: its phone, name and that policy's box, then the passcode's
481
+ way, Email Message."""
482
+ box = page.get_by_label("Email Address")
483
+ try:
484
+ box.wait_for(timeout=SIGN_IN_TIMEOUT_MS)
485
+ except Timeout:
486
+ raise Unsupported(f"unhandled_rendering: no ADP sign-in on {page.url}") from None
487
+ box.fill(email)
488
+ page.get_by_role("button", name="Continue").first.click()
489
+ passcode = page.get_by_label("Passcode")
490
+ create = page.get_by_text("Create your candidate profile")
491
+ method = page.get_by_role("button", name="Send passcode")
492
+ passcode.or_(create).or_(method).first.wait_for(timeout=SIGN_IN_STEP_TIMEOUT_MS)
493
+ if create.count():
494
+ if details is None:
495
+ raise Refused("no_account")
496
+ _create_profile(page, details)
497
+ passcode.or_(method).first.wait_for(timeout=SIGN_IN_STEP_TIMEOUT_MS)
498
+ if method.count() and not passcode.count():
499
+ page.get_by_text("Email Message").click()
500
+ method.click()
501
+ passcode.wait_for(timeout=SIGN_IN_STEP_TIMEOUT_MS)
502
+ otp.enter(passcode, email, "ADP's sign-in")
503
+ agree = page.get_by_role("button", name="Agree", exact=True)
504
+ deadline = time.monotonic() + SIGNED_IN_TIMEOUT_MS / 1000
505
+ while urlsplit(page.url).path.endswith("/auth"):
506
+ if agree.count() and agree.is_visible(): # the employer's privacy policy, once typed
507
+ agree.click()
508
+ if time.monotonic() > deadline:
509
+ raise Blocked(f"otp_rejected: ADP's sign-in sent an OTP to {email}")
510
+ page.wait_for_timeout(SETTLE_MS)
511
+
512
+
513
+ def _create_profile(page: Page, details: SignUpDetails) -> None:
514
+ assert details.name and details.phone # in `asks`: the core gives them
515
+ first, _, last = details.name.rpartition(" ")
516
+ phone = page.get_by_placeholder("Enter phone number")
517
+ phone.press_sequentially(details.phone, delay=KEY_DELAY_MS) # E.164 picks the country
518
+ page.get_by_label("First Name").fill(first or last)
519
+ page.get_by_label("Last Name").fill(last)
520
+ page.get_by_role("checkbox").first.check(force=True) # the employer's privacy policy
521
+ page.get_by_role("button", name="Continue").first.click()
522
+
523
+
524
+ def _country(questions: list[dict[str, Any]]) -> str | None:
525
+ """The form's country, whose states and address labels it shows: the default of the
526
+ question the state depends on."""
527
+ return next(
528
+ (q.get("answerDefaultValue") for q in questions if "state" in (q.get("dependents") or [])),
529
+ None,
530
+ )
531
+
532
+
533
+ def _status(application: dict[str, Any]) -> str:
534
+ return (application.get("applicationStatusCode") or {}).get("codeValue") or "with no status"
535
+
536
+
537
+ def _labels(section: dict[str, Any]) -> dict[str, str]:
538
+ return {x["code"]: x["value"] for x in section.get("sectionLabels") or []}
539
+
540
+
541
+ def _text(html: str) -> str:
542
+ return " ".join(unescape(re.sub(r"<[^>]+>", " ", html)).split())
543
+
544
+
545
+ def _any(ways: list[Condition | None]) -> Condition:
546
+ conditions = [w for w in ways if w is not None]
547
+ return conditions[0] if len(conditions) == 1 else AnyOf(conditions)
548
+
549
+
550
+ def _statement(section: Section) -> bool:
551
+ """A section that only asks to agree to what it says: one Yes or No box."""
552
+ return [f.options for f in section.fields] == [YES_NO] and section.fields[0].required
553
+
554
+
555
+ def _shown_by_dependencies(
556
+ section: Section,
557
+ questions: list[dict[str, Any]],
558
+ fields: list[Field],
559
+ codes: dict[str, dict[str, str]],
560
+ ) -> list[Field]:
561
+ """A question that shows only on another's answer, from that one's `dependencies`."""
562
+ when: dict[str, list[Condition | None]] = {}
563
+ for q in questions:
564
+ parent = f"{section.component}.{q['questionID']}"
565
+ labels = codes.get(parent, {})
566
+ for dependency in q.get("dependencies") or []:
567
+ for attribute in dependency.get("attributes") or []:
568
+ answer = labels.get(attribute["answerID"], attribute["answerID"])
569
+ child = f"{section.component}.{attribute['questionID']}"
570
+ when.setdefault(child, []).append(Is(parent, [answer]))
571
+ return [replace(f, shown_when=_any(when[f.id])) if f.id in when else f for f in fields]
572
+
573
+
574
+ def _required_by_answers(
575
+ section: Section,
576
+ questions: list[dict[str, Any]],
577
+ fields: list[Field],
578
+ codes: dict[str, dict[str, str]],
579
+ ) -> list[Field]:
580
+ """A question required only on another's answer, from that one's validation rules."""
581
+ when: dict[str, Condition] = {}
582
+ for q in questions:
583
+ parent = f"{section.component}.{q['questionID']}"
584
+ rules = (q.get("validationRules") or {}).get("conditionalRequiredValidations") or []
585
+ for rule in rules:
586
+ values = [codes.get(parent, {}).get(v, v) for v in rule.get("drivingValues") or []]
587
+ for child in rule.get("requiredFields") or []:
588
+ when[f"{section.component}.{child}"] = Is(parent, values)
589
+ return [replace(f, required_when=when[f.id]) if f.id in when else f for f in fields]
590
+
591
+
592
+ def _to_page(page: Page, section: Section, ids: set[str]) -> None:
593
+ """Makes sure the open page is the one expected before its Next: a form reopened on a
594
+ later page, or on its review, whose Next would send, goes back with Previous."""
595
+ if not ids or section.component.startswith("prescreen"):
596
+ return
597
+ with suppress(Timeout): # Next shows before the page's questions
598
+ page.locator("adp-form-group").first.wait_for(timeout=SECTION_TIMEOUT_MS)
599
+ for _ in range(len(section.pages) + 1):
600
+ if ids & set(_groups(page)):
601
+ return
602
+ back = page.get_by_role("button", name="Previous")
603
+ if not back.count():
604
+ break
605
+ shown = _groups(page)
606
+ back.click()
607
+ deadline = time.monotonic() + SECTION_TIMEOUT_MS / 1000
608
+ while _groups(page) == shown and time.monotonic() < deadline:
609
+ page.wait_for_timeout(SETTLE_MS)
610
+ raise RuntimeError(f"{section.title} shows another page than the one to fill")
611
+
612
+
613
+ def _groups(page: Page) -> list[str]:
614
+ return page.locator("adp-form-group").evaluate_all("es => es.map(e => e.dataset.name)")
615
+
616
+
617
+ _SHOWN = """es => es
618
+ .filter(e => ![...e.classList].some(c => /van-type-(instructions|header|sdf-date-time)/.test(c)))
619
+ .map(e => [e.dataset.name, e.querySelector('label')?.innerText.trim()])"""
620
+
621
+
622
+ def _unlisted(page: Page, section: Section) -> str:
623
+ """The questions the open page shows that the read did not list, by label."""
624
+ if section.component.startswith("prescreen"):
625
+ return ""
626
+ listed = {f.id.removeprefix(f"{section.component}.") for f in section.fields}
627
+ shown = page.locator("adp-form-group").evaluate_all(_SHOWN)
628
+ return ", ".join(repr(label or name) for name, label in shown if name not in listed)
629
+
630
+
631
+ def _on_section(page: Page, section: Section) -> None:
632
+ try:
633
+ page.wait_for_url(
634
+ lambda url: f"workflowComponent={section.component}" in url,
635
+ timeout=SECTION_TIMEOUT_MS,
636
+ wait_until="commit",
637
+ )
638
+ except Timeout:
639
+ raise RuntimeError(f"the workflow is not on {section.title}: {page.url}") from None
640
+ page.get_by_role("button", name="Next").wait_for(timeout=SECTION_TIMEOUT_MS)
641
+
642
+
643
+ def _box(page: Page, section: Section, f: Field) -> Locator:
644
+ question = f.id.removeprefix(f"{section.component}.")
645
+ if section.component.startswith("prescreen"):
646
+ labelled = f'[aria-labelledby="{question}-error"]:not(sdf-radio-button)'
647
+ return page.locator(f"#{question}, {labelled}").first
648
+ return page.locator(f'adp-form-group[data-name="{question}"]').first
649
+
650
+
651
+ def _set(page: Page, box: Locator, f: Field, answer: str, codes: dict[str, str]) -> None:
652
+ by_label = {v: k for k, v in codes.items()}
653
+ if f.type is FieldType.FILE:
654
+ _upload(page, box, f, answer)
655
+ return
656
+ if box.locator("dynamic-form-e-signature").count():
657
+ box.locator('sdf-radio-button[value="ACCEPT"]').click()
658
+ box.locator("sdf-input input").fill(answer)
659
+ return
660
+ radios = box.locator("sdf-radio-button")
661
+ if radios.count():
662
+ code = by_label.get(answer, {"Yes": "true", "No": "false"}.get(answer, answer))
663
+ radio = box.locator(f'sdf-radio-button[value="{code}"]')
664
+ if not radio.count():
665
+ raise NotTaken(f"{f.id} has no option {answer!r}")
666
+ radio.click()
667
+ return
668
+ if box.locator("sdf-checkbox").count():
669
+ boxed = box.locator("sdf-checkbox")
670
+ if (boxed.get_attribute("aria-checked") == "true") != (answer == "Yes"):
671
+ boxed.click()
672
+ return
673
+ if box.locator("sdf-phone-number-input").count(): # its country is a select of its own
674
+ target = box.locator("sdf-phone-number-input input").first
675
+ target.fill("")
676
+ target.press_sequentially(answer, delay=KEY_DELAY_MS) # E.164 picks the country
677
+ return
678
+ if select := _select(box):
679
+ _pick(page, select, f, answer)
680
+ return
681
+ target = box.locator("textarea, input").first
682
+ if not target.count():
683
+ raise NotTaken(f"{f.id} shows no input")
684
+ target.fill(answer)
685
+
686
+
687
+ def _select(box: Locator) -> Locator | None:
688
+ select = box if _tag(box) == "SDF-SELECT-SIMPLE" else box.locator("sdf-select-simple")
689
+ return select.first if select.count() else None
690
+
691
+
692
+ def _tag(box: Locator) -> str:
693
+ return box.evaluate("e => e.tagName")
694
+
695
+
696
+ def _pick(page: Page, select: Locator, f: Field, answer: str) -> None:
697
+ """Opens the list and picks the option that matches; a long list shows its first options
698
+ only, and its filter box narrows it to the answer."""
699
+ select.locator("[part=frame]").first.click()
700
+ items = page.locator("sdf-select-item:visible") # the open list's, in a floating pane
701
+ narrowed = False
702
+ deadline = time.monotonic() + OPTION_TIMEOUT_MS / 1000
703
+ while time.monotonic() < deadline:
704
+ for item in items.all():
705
+ text = (item.get_attribute("aria-label") or item.inner_text()).strip()
706
+ if text and matching.matches(f, answer, text):
707
+ item.click()
708
+ return
709
+ search = select.locator("input#select-filter-input")
710
+ if not narrowed and search.count():
711
+ search.press_sequentially(answer, delay=KEY_DELAY_MS)
712
+ narrowed = True
713
+ page.wait_for_timeout(SETTLE_MS)
714
+ page.keyboard.press("Escape")
715
+ raise NotTaken(f"{f.id} has no option {answer!r}")
716
+
717
+
718
+ def _upload(page: Page, box: Locator, f: Field, answer: str) -> None:
719
+ """The upload button opens a pane with a file input and Save; the question then shows the
720
+ file's name."""
721
+ box.locator("sdf-button[icon=action-upload]").click()
722
+ page.locator("input[type=file]").set_input_files(answer)
723
+ page.locator("#accept-button").click()
724
+ name = answer.replace("\\", "/").rsplit("/", 1)[-1]
725
+ try:
726
+ box.get_by_text(name).wait_for(timeout=UPLOAD_TIMEOUT_MS)
727
+ except Timeout:
728
+ raise NotTaken(f"{f.id} did not take {name}: {_errors(page)}") from None
729
+
730
+
731
+ def _value(box: Locator, f: Field, codes: dict[str, str]) -> Answer | None:
732
+ if f.type is FieldType.FILE: # the upload's text, its buttons icons alone: the file's name
733
+ lines = box.locator("dynamic-form-sdf-upload").inner_text().splitlines()
734
+ return next((t.strip() for t in lines if t.strip()), None)
735
+ if box.locator("dynamic-form-e-signature").count():
736
+ accepted = box.locator('sdf-radio-button[value="ACCEPT"][aria-checked="true"]').count()
737
+ return box.locator("sdf-input input").input_value() if accepted else None
738
+ checked = box.locator('sdf-radio-button[aria-checked="true"]')
739
+ if box.locator("sdf-radio-button").count():
740
+ code = checked.first.get_attribute("value") if checked.count() else None
741
+ return codes.get(code or "", code)
742
+ if box.locator("sdf-checkbox").count():
743
+ return (
744
+ "Yes" if box.locator("sdf-checkbox").get_attribute("aria-checked") == "true" else "No"
745
+ )
746
+ if box.locator("sdf-phone-number-input").count():
747
+ digits = re.sub(r"\D", "", box.locator("sdf-phone-number-input input").first.input_value())
748
+ return f"+{digits}" if digits else None
749
+ if select := _select(box):
750
+ return select.evaluate(
751
+ "e => (e.shadowRoot ?? e).querySelector('#selected-label')?.textContent.trim() || null"
752
+ )
753
+ target = box.locator("textarea, input").first
754
+ return target.input_value() if target.count() else None
755
+
756
+
757
+ def _errors(page: Page) -> str:
758
+ """Each question's error, from the text kept for screen readers."""
759
+ said = page.locator("rm-sr-field-error, sdf-alert-inline").all_text_contents()
760
+ return "; ".join(dict.fromkeys(t.strip() for t in said if t.strip())) or "no error shown"
@@ -266,6 +266,19 @@ class KeepsProfile(Adapter, NeedsAccount, Protocol):
266
266
  ...
267
267
 
268
268
 
269
+ @runtime_checkable
270
+ class SendsToOpen(Adapter, Protocol):
271
+ """An adapter whose system sends the application to show its form: what the form asks
272
+ follows the send. The core opens it under a send's rules, for every form command (ADR 0006).
273
+ """
274
+
275
+ def open(self, page: Page) -> str | None:
276
+ """Opens the form from the loaded posting: sends the application where the account has
277
+ none for it, returning the evidence of that send; continues the one it has, returning
278
+ None. Raises `SignedOut`, `Unsupported` or `Blocked` only before sending."""
279
+ ...
280
+
281
+
269
282
  @runtime_checkable
270
283
  class ReadsByRequest(Adapter, Protocol):
271
284
  """An adapter with a request read: a form's fields listed without a browser (ADR 0011)."""
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  from atsfill.core.results import Status
8
8
 
9
9
  PENDING = "clicked" # appended before the click; a later line records the outcome
10
+ OPENED = "opened" # sent by opening the form (`SendsToOpen`); its own submit is still to come
10
11
 
11
12
 
12
13
  def last(user_dir: Path, posting: str) -> str | None:
@@ -32,6 +32,7 @@ from atsfill.core.adapter import (
32
32
  ProfileIncomplete,
33
33
  ReadsByRequest,
34
34
  Refused,
35
+ SendsToOpen,
35
36
  SignedOut,
36
37
  SignsIn,
37
38
  SignsInByOtp,
@@ -206,18 +207,53 @@ def _opened(
206
207
  _again: ContextVar[Callable[[], Result | None] | None] = ContextVar("again", default=None)
207
208
 
208
209
 
209
- def _read_signed_in(page: Page, adapter: Adapter) -> list[Field]:
210
- """`browser_read`, once more after signing in again where the page asked to sign in although
211
- the session held: the result that ends the command instead is raised as `Blocked`."""
212
- try:
210
+ def _read_signed_in(page: Page, adapter: Adapter, mine: Path, user: str, url: str) -> list[Field]:
211
+ """`browser_read`, the form opened first where opening it sends, once more after signing in
212
+ again where the page asked to sign in although the session held: the result that ends the
213
+ command instead is raised as `Blocked`."""
214
+
215
+ def read() -> list[Field]:
216
+ if isinstance(adapter, SendsToOpen):
217
+ _open(page, adapter, mine, user, url)
213
218
  return adapter.browser_read(page)
219
+
220
+ try:
221
+ return read()
214
222
  except SignedOut:
215
223
  again = _again.get()
216
224
  if again is None:
217
225
  raise
218
226
  if ended := again():
219
227
  raise Blocked(ended.reason) from None
220
- return adapter.browser_read(page)
228
+ return read()
229
+
230
+
231
+ def _open(page: Page, adapter: SendsToOpen, mine: Path, user: str, url: str) -> None:
232
+ """Opens a form whose system sends the application to show it, under a send's rules: the
233
+ adapter in `submit-allowed`, and the send recorded `opened`, which the send of that same
234
+ application does not count against (ADR 0006). An error past the adapter's own stops may
235
+ follow the send: it is recorded `unknown`."""
236
+ if not paths.submit_enabled(adapter.name, user):
237
+ raise Refused(f"{_not_enabled(adapter, user)}; opening its form sends the application")
238
+ posting = adapter.posting(page.url)
239
+ try:
240
+ evidence = adapter.open(page)
241
+ except SignedOut, Unsupported, Blocked:
242
+ raise
243
+ except Exception as e:
244
+ reason = f"error_after_click: {type(e).__name__}: {e}"
245
+ attempts.record(mine, posting, Status.UNKNOWN, reason=reason)
246
+ raise
247
+ if evidence:
248
+ attempts.record(mine, posting, attempts.OPENED, evidence=evidence, url=url)
249
+
250
+
251
+ def _not_enabled(adapter: Adapter, user: str) -> str:
252
+ where = "" if user == paths.DEFAULT_USER else f" under [users.{user}]"
253
+ return (
254
+ f"submit_not_enabled: add {adapter.name} to `submit-allowed`{where}"
255
+ f" in {paths.config_file()}"
256
+ )
221
257
 
222
258
 
223
259
  def _sign_in_again(
@@ -364,11 +400,13 @@ def _secrets(data: Path, user: str, answers: dict[str, Answer], result: Result)
364
400
  return snapshot.secrets(answers, result.fields, emails)
365
401
 
366
402
 
367
- def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], user: str) -> Result:
403
+ def _fill(
404
+ page: Page, adapter: Adapter, mine: Path, url: str, answers: dict[str, Answer], user: str
405
+ ) -> Result:
368
406
  """Fills and reads back; `ready` when every answer reads back as given."""
369
407
  result = _result(adapter, url, user)
370
408
  with _stops(result):
371
- _listed(result, _read_signed_in(page, adapter))
409
+ _listed(result, _read_signed_in(page, adapter, mine, user, url))
372
410
  if _unfillable(result, answers):
373
411
  return result
374
412
  if problems := matching.problems(result.fields, answers):
@@ -385,6 +423,7 @@ def _fill(page: Page, adapter: Adapter, url: str, answers: dict[str, Answer], us
385
423
 
386
424
 
387
425
  def _browser_read(data: Path, user: str, url: str, sessions: Sessions | None, seen: Seen) -> Result:
426
+ mine = paths.user_dir(data, user)
388
427
  with _opened(data, user, url, sessions, seen) as opened:
389
428
  if isinstance(opened, Result):
390
429
  return opened
@@ -393,7 +432,7 @@ def _browser_read(data: Path, user: str, url: str, sessions: Sessions | None, se
393
432
  def run() -> Result:
394
433
  result = _result(adapter, url, user)
395
434
  with _stops(result):
396
- _listed(result, _read_signed_in(page, adapter))
435
+ _listed(result, _read_signed_in(page, adapter, mine, user, url))
397
436
  _unfillable(result)
398
437
  _profile_incomplete(result, adapter, user, url)
399
438
  return result
@@ -433,17 +472,12 @@ def _on_page(
433
472
  ) -> Result:
434
473
  posting = adapter.posting(page.url) # past any redirect a link goes through
435
474
  if send and not paths.submit_enabled(adapter.name, user):
436
- where = "" if user == paths.DEFAULT_USER else f" under [users.{user}]"
437
- reason = (
438
- f"submit_not_enabled: add {adapter.name} to `submit-allowed`{where}"
439
- f" in {paths.config_file()}"
440
- )
441
- return Result(Status.REFUSED, url, adapter.name, reason=reason)
475
+ return Result(Status.REFUSED, url, adapter.name, reason=_not_enabled(adapter, user))
442
476
  if send and not resend and attempts.blocks_send(mine, posting):
443
477
  last = attempts.last(mine, posting)
444
478
  reason = f"already_sent: last attempt ended {last}; pass --resend"
445
479
  return Result(Status.REFUSED, url, adapter.name, reason=reason)
446
- result = _fill(page, adapter, url, answers, user)
480
+ result = _fill(page, adapter, mine, url, answers, user)
447
481
  _profile_incomplete(result, adapter, user, url)
448
482
  if result.status is not Status.READY:
449
483
  return result
@@ -250,10 +250,11 @@ scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: profil
250
250
  [adpmyjobs]
251
251
  kind = "ats"
252
252
  share = 0.0084
253
- account = "to_read" # the posting loads only signed in; the sign-in has no password, an OTP alone each time
253
+ account = "to_read" # a candidate profile per career site without a password, signed in by an emailed OTP
254
254
  hosting = ["hosted"]
255
255
  hosts = ["myjobs.adp.com"]
256
- scope = { is = "in_scope", date = 2026-10-26, reason = "unsupported_flow: sign_in_otp_only: no password, every sign-in, the sign-up's included, is an OTP alone, and the posting loads only signed in (11 of 11 employers)" }
256
+ adapter = "adpmyjobs"
257
+ scope = { is = "in_scope", date = 2026-10-29, reason = "adapter before its gate: Apply sends the application, the form follows it; 2 of 2 workflows walked ready" }
257
258
 
258
259
  [phenom]
259
260
  kind = "ats"
File without changes