atsfill 0.0.5.dev161__tar.gz → 0.0.5.dev164__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/PKG-INFO +6 -2
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/README.md +5 -1
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/pyproject.toml +1 -1
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/pyproject.toml.orig +1 -1
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/__init__.py +2 -0
- atsfill-0.0.5.dev164/src/atsfill/adapters/adpmyjobs.py +760 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/adapter.py +13 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/attempts.py +1 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/commands.py +49 -15
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/registry.toml +3 -2
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/LICENSE +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/gupy.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/hrmdirect.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/isolvedhire.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/taleo.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/workday.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.5.dev161 → atsfill-0.0.5.dev164}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.5.
|
|
3
|
+
Version: 0.0.5.dev164
|
|
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,9 +93,13 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
93
93
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
94
94
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
95
95
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
96
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
96
97
|
<!-- /run-history -->
|
|
97
98
|
|
|
98
|
-
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,
|
|
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, one mark per posting and version, newest first.
|
|
100
|
+
|
|
101
|
+

|
|
102
|
+
|
|
99
103
|
[The systems](docs/adapters/README.md) lists which ones need an account, to read the form or only to send it.
|
|
100
104
|
Any other system returns `unsupported`, so the consumer can fall back to driving a browser itself.
|
|
101
105
|
|
|
@@ -64,9 +64,13 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
64
64
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
65
65
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
66
66
|
[](https://shbernal.github.io/atsfill/run-history/)
|
|
67
|
+
[](https://shbernal.github.io/atsfill/run-history/)
|
|
67
68
|
<!-- /run-history -->
|
|
68
69
|
|
|
69
|
-
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,
|
|
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, one mark per posting and version, newest first.
|
|
71
|
+
|
|
72
|
+

|
|
73
|
+
|
|
70
74
|
[The systems](docs/adapters/README.md) lists which ones need an account, to read the form or only to send it.
|
|
71
75
|
Any other system returns `unsupported`, so the consumer can fall back to driving a browser itself.
|
|
72
76
|
|
|
@@ -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}§ionID={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`,
|
|
211
|
-
the session held: the result that ends the
|
|
212
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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" #
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|