atsfill 0.0.4.dev87__tar.gz → 0.0.4.dev89__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/PKG-INFO +2 -2
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/README.md +1 -1
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/pyproject.toml +1 -1
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/pyproject.toml.orig +1 -1
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/__init__.py +2 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/oracle.py +10 -10
- atsfill-0.0.4.dev89/src/atsfill/adapters/workday.py +995 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/browser.py +2 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/registry.toml +1 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/LICENSE +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/bamboohr.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/eightfold.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.4.dev87 → atsfill-0.0.4.dev89}/src/atsfill/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.4.
|
|
3
|
+
Version: 0.0.4.dev89
|
|
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
|
|
@@ -55,7 +55,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
55
55
|
## Supported hiring systems
|
|
56
56
|
|
|
57
57
|
<!-- badges -->
|
|
58
|
-
                       
|
|
58
|
+
                        
|
|
59
59
|
<!-- /badges -->
|
|
60
60
|
|
|
61
61
|
| System | Account needed | `read` without a browser |
|
|
@@ -29,7 +29,7 @@ It is built for agents with a shell: every command prints one JSON document and
|
|
|
29
29
|
## Supported hiring systems
|
|
30
30
|
|
|
31
31
|
<!-- badges -->
|
|
32
|
-
                       
|
|
32
|
+
                        
|
|
33
33
|
<!-- /badges -->
|
|
34
34
|
|
|
35
35
|
| System | Account needed | `read` without a browser |
|
|
@@ -20,6 +20,7 @@ from atsfill.adapters.rippling import Rippling
|
|
|
20
20
|
from atsfill.adapters.smartrecruiters import SmartRecruiters
|
|
21
21
|
from atsfill.adapters.teamtailor import Teamtailor
|
|
22
22
|
from atsfill.adapters.workable import Workable
|
|
23
|
+
from atsfill.adapters.workday import Workday
|
|
23
24
|
from atsfill.adapters.wttj import WelcomeToTheJungle
|
|
24
25
|
from atsfill.adapters.zohorecruit import ZohoRecruit
|
|
25
26
|
from atsfill.core.adapter import Adapter, Filtered
|
|
@@ -51,6 +52,7 @@ def adapters() -> list[Adapter]:
|
|
|
51
52
|
Pinpoint(),
|
|
52
53
|
HiringThing(),
|
|
53
54
|
Eightfold(),
|
|
55
|
+
Workday(),
|
|
54
56
|
Phenom(), # last: it claims a URL by its path alone
|
|
55
57
|
]
|
|
56
58
|
|
|
@@ -255,7 +255,6 @@ class Oracle:
|
|
|
255
255
|
self._picked: dict[str, str] = {} # a geography field's id: the item picked in it
|
|
256
256
|
self._months: list[str] = []
|
|
257
257
|
self._job = ""
|
|
258
|
-
self._uploads: dict[str, str] = {}
|
|
259
258
|
|
|
260
259
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
261
260
|
return JOB.search(urlsplit(url).path) is not None
|
|
@@ -311,14 +310,14 @@ class Oracle:
|
|
|
311
310
|
if COUNTRY in on_page: # the address below it follows the country's format
|
|
312
311
|
self._fill(page, COUNTRY, answers[COUNTRY])
|
|
313
312
|
_settle(page)
|
|
314
|
-
|
|
313
|
+
# Files go last: a click while one attaches can be lost, and once attached its
|
|
314
|
+
# label takes the focus back from a field typed after it.
|
|
315
|
+
files = [i for i in on_page if self._kinds[i] == "file"]
|
|
316
|
+
for field_id in [i for i in on_page if i not in (COUNTRY, *files)] + files:
|
|
315
317
|
self._fill(page, field_id, answers[field_id])
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if self._fields[i].type is FieldType.FILE and isinstance(a, str)
|
|
320
|
-
}
|
|
321
|
-
self._wait_for_uploads(page)
|
|
318
|
+
self._wait_for_uploads(
|
|
319
|
+
page, {i: PurePath(a).name for i in files if isinstance(a := answers[i], str)}
|
|
320
|
+
)
|
|
322
321
|
|
|
323
322
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
324
323
|
values: dict[str, Any] = page.evaluate(_READ_BACK, self._kinds)
|
|
@@ -490,8 +489,9 @@ class Oracle:
|
|
|
490
489
|
def _file_input(self, page: Page, field_id: str) -> Locator:
|
|
491
490
|
return page.locator(f"{_upload(field_id)} input[type=file]").first
|
|
492
491
|
|
|
493
|
-
def _wait_for_uploads(self, page: Page) -> None:
|
|
494
|
-
for
|
|
492
|
+
def _wait_for_uploads(self, page: Page, uploads: dict[str, str]) -> None:
|
|
493
|
+
"""Waits for each file (by field id: its name) to show attached."""
|
|
494
|
+
for field_id, name in uploads.items():
|
|
495
495
|
selector = f"{_upload(field_id)} :is({ATTACHED})"
|
|
496
496
|
done = page.locator(selector, has_text=name)
|
|
497
497
|
try:
|
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
"""Workday (`<tenant>.wd<n>.myworkdayjobs.com/<site>/job/…` and
|
|
2
|
+
`wd<n>.myworkdaysite.com/recruiting/<tenant>/<site>/job/…`), as docs/adapters/workday.md
|
|
3
|
+
observes.
|
|
4
|
+
|
|
5
|
+
Applying needs an account, one per tenant: the account scope. The form is a wizard of pages,
|
|
6
|
+
each saved to a draft application as it is left; only the current page is in the DOM. The
|
|
7
|
+
read walks the pages that save with what they hold, the posting's questionnaires and apply
|
|
8
|
+
flow standing for the pages it does not reach; the read-back is each page's values, read
|
|
9
|
+
before it is saved.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from html import unescape
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
from atsfill.core import matching
|
|
20
|
+
from atsfill.core.adapter import Blocked, Gap, NotTaken, Refused, Unsupported, known_gap
|
|
21
|
+
from atsfill.core.browser import Locator, Page, PageRequest, Request, Response, Timeout
|
|
22
|
+
from atsfill.core.results import Answer, Field, FieldType, SideEffect
|
|
23
|
+
|
|
24
|
+
JOBS_HOST = re.compile(r"^(?P<tenant>[a-z0-9-]+)\.(?P<dc>wd\d+)\.myworkdayjobs\.com$")
|
|
25
|
+
SITE_HOST = re.compile(r"^(?P<dc>wd\d+)\.myworkdaysite\.com$")
|
|
26
|
+
LOCALE = r"(?:/[a-z]{2}-[A-Z]{2})?"
|
|
27
|
+
JOBS_PATH = re.compile(LOCALE + r"/(?P<site>[^/]+)(?P<rest>/.*)?$")
|
|
28
|
+
SITE_PATH = re.compile(LOCALE + r"/recruiting/(?P<tenant>[^/]+)/(?P<site>[^/]+)(?P<rest>/.*)?$")
|
|
29
|
+
JOB = re.compile(r"^/(?:job/[^/]+|details)/(?P<slug>[^/?#]+)")
|
|
30
|
+
SIGN_IN_TIMEOUT_S = 30
|
|
31
|
+
SIGN_IN_POLL_MS = 500
|
|
32
|
+
PRESS_TIMEOUT_MS = 5_000
|
|
33
|
+
PRESSES = 3
|
|
34
|
+
FORM_TIMEOUT_MS = 30_000
|
|
35
|
+
STEP_TIMEOUT_MS = 30_000
|
|
36
|
+
MENU_TIMEOUT_MS = 5_000
|
|
37
|
+
UPLOAD_TIMEOUT_MS = 30_000
|
|
38
|
+
SUBMIT_TIMEOUT_MS = 90_000
|
|
39
|
+
STEP_POLL_MS = 300
|
|
40
|
+
SETTLE_MS = 1_000
|
|
41
|
+
SETTLED = 3 # counts in a row the same
|
|
42
|
+
FOLLOW_UP_ROUNDS = 4 # follow-ups of follow-ups
|
|
43
|
+
RENDER_TRIES = 5
|
|
44
|
+
MAX_PAGES = 10
|
|
45
|
+
PROMPT_LEVELS = 3 # Escapes to close a list
|
|
46
|
+
MAX_PROMPT_OPTIONS = 40 # a longer list (the country phone codes) is read from the API
|
|
47
|
+
NEXT = "[data-automation-id=pageFooterNextButton]"
|
|
48
|
+
APPLIED = "[data-automation-id=alreadyApplied]"
|
|
49
|
+
FIELD = '[data-automation-id^="formField-"]'
|
|
50
|
+
STEP = "[data-automation-id=progressBarActiveStep]"
|
|
51
|
+
ERRORS = "[data-automation-id=errorHeading], [data-automation-id=inputAlert]"
|
|
52
|
+
UPLOADED = "[data-automation-id=file-upload-item-name]"
|
|
53
|
+
DELETE_FILE = "[data-automation-id=delete-file]"
|
|
54
|
+
PROMPT_OPTION = "[data-automation-id=activeListContainer] [data-automation-id=promptOption]:visible"
|
|
55
|
+
PICKED = "[data-automation-id=selectedItem]"
|
|
56
|
+
DELETE_PICK = "[data-automation-id=DELETE_charm]"
|
|
57
|
+
SEARCH = "input[data-uxi-widget-type=selectinput]"
|
|
58
|
+
BACK = "[data-automation-id=backButton]:visible"
|
|
59
|
+
PHONE_CODE_ID = "phoneNumber--countryPhoneCode"
|
|
60
|
+
PHONE_CODE = f'[data-fkit-id="{PHONE_CODE_ID}"]'
|
|
61
|
+
PHONE_CODES = "/values/phone/countryCodes"
|
|
62
|
+
# An answer to a question posts the questionnaire's definition; the page renders its reply
|
|
63
|
+
# over the answers given meanwhile
|
|
64
|
+
DEFINITION = "/definition?"
|
|
65
|
+
# A field whose value is the site's default before any answer, not the account's: the
|
|
66
|
+
# country's default is the browser's locale's, the user's, and decides the address fields read
|
|
67
|
+
SITE_DEFAULTS = {PHONE_CODE_ID}
|
|
68
|
+
PHONE_NUMBER = "phoneNumber--phoneNumber"
|
|
69
|
+
RESUME_ID = "resumeAttachments--attachments"
|
|
70
|
+
REVIEW = "Review"
|
|
71
|
+
MY_INFORMATION = "My Information"
|
|
72
|
+
EXPERIENCE = "My Experience"
|
|
73
|
+
# Each questionnaire's page: `Application Questions`, or `… 1 of 2` and `… 2 of 2`
|
|
74
|
+
QUESTIONNAIRES = [
|
|
75
|
+
("primary", "questionnaireId", "Application Questions"),
|
|
76
|
+
("secondary", "secondaryQuestionnaireId", "Application Questions 2 of 2"),
|
|
77
|
+
]
|
|
78
|
+
SELECT_ONE = "Select One"
|
|
79
|
+
YES_NO = ["Yes", "No"]
|
|
80
|
+
TAG = re.compile(r"<[^>]+>")
|
|
81
|
+
RESUME = re.compile(r"resume|\bcv\b", re.I)
|
|
82
|
+
SKILLS = re.compile(r"skills\b", re.I)
|
|
83
|
+
SKILLS_ID = "skills--skills"
|
|
84
|
+
# The lists atsfill fills the first entry of: each part's id, label, rendering and the apply
|
|
85
|
+
# flow's section that shows it (None: always shown, required with the list)
|
|
86
|
+
type EntryList = tuple[str, re.Pattern[str], list[tuple[str, str, str, str | None]]]
|
|
87
|
+
ENTRY_LISTS: list[EntryList] = [
|
|
88
|
+
("workExperience", re.compile(r"work (experience|history)", re.I), [
|
|
89
|
+
("jobTitle", "Job Title", "text", None),
|
|
90
|
+
("companyName", "Company", "text", None),
|
|
91
|
+
("location", "Location", "text", "Location"),
|
|
92
|
+
("currentlyWorkHere", "I currently work here", "box", None),
|
|
93
|
+
("startDate", "From", "month", None),
|
|
94
|
+
("endDate", "To", "month", None),
|
|
95
|
+
("roleDescription", "Role Description", "text", "Role Description"),
|
|
96
|
+
]),
|
|
97
|
+
("education", re.compile(r"education", re.I), [
|
|
98
|
+
("school", "School or University", "school", None),
|
|
99
|
+
("degree", "Degree", "degrees", None),
|
|
100
|
+
("fieldOfStudy", "Field of Study", "fieldsOfStudy", "Field of Study"),
|
|
101
|
+
("gradeAverage", "Overall Result (GPA)", "text", "Overall Result (GPA)"),
|
|
102
|
+
("firstYearAttended", "From", "year", "From"),
|
|
103
|
+
("lastYearAttended", "To (Actual or Expected)", "year", "To (Actual or Expected)"),
|
|
104
|
+
]),
|
|
105
|
+
] # fmt: skip
|
|
106
|
+
SCHOOL_SEARCH = "university" # finds schools where the tenant lists them
|
|
107
|
+
# The school: a search prompt where the tenant lists schools, else a text input; one field
|
|
108
|
+
SCHOOL_IDS = {"education--schoolName": "education--school"}
|
|
109
|
+
BRACKETED = re.compile(r"\(([^()]+)\)")
|
|
110
|
+
ENTRY_ID = re.compile(r"^(workExperience|education)-(\d+)--")
|
|
111
|
+
ADD = "[data-automation-id=add-button]"
|
|
112
|
+
ENTRIES = re.compile(r"(work|education|skill|website|language|certification|portfolio)", re.I)
|
|
113
|
+
ENTRY_PART = re.compile(r"number|date|attachments", re.I) # a field of an entry, not a list
|
|
114
|
+
KINDS = {
|
|
115
|
+
"text": FieldType.TEXT,
|
|
116
|
+
"list": FieldType.CHOICE,
|
|
117
|
+
"radio": FieldType.CHOICE,
|
|
118
|
+
"box": FieldType.CHOICE,
|
|
119
|
+
"boxes": FieldType.MULTI_CHOICE,
|
|
120
|
+
"prompt": FieldType.TYPEAHEAD,
|
|
121
|
+
"date": FieldType.DATE,
|
|
122
|
+
"month": FieldType.TEXT,
|
|
123
|
+
"year": FieldType.TEXT,
|
|
124
|
+
"file": FieldType.FILE,
|
|
125
|
+
}
|
|
126
|
+
EMAIL = "[data-automation-id=email]"
|
|
127
|
+
WITH_EMAIL = "[data-automation-id=SignInWithEmailButton]" # beside Google, LinkedIn…
|
|
128
|
+
# What the sign-in and sign-up pages say, by the reason it is
|
|
129
|
+
MESSAGES = [
|
|
130
|
+
(re.compile(r"verify your account", re.I), Blocked, "email_link"),
|
|
131
|
+
(re.compile(r"password must|password requirements", re.I), Refused, "password_rule"),
|
|
132
|
+
(re.compile(r"wrong email address or password|account might be locked", re.I), Refused,
|
|
133
|
+
"wrong_password"),
|
|
134
|
+
] # fmt: skip
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class Site:
|
|
139
|
+
"""Where a tenant's career site is served: its host, the path before the site, the site."""
|
|
140
|
+
|
|
141
|
+
tenant: str
|
|
142
|
+
host: str
|
|
143
|
+
prefix: str # "" on myworkdayjobs.com, `/recruiting/<tenant>` on myworkdaysite.com
|
|
144
|
+
site: str
|
|
145
|
+
rest: str # the path after the site: `/job/<location>/<slug>`, `/details/<slug>`…
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def dc(self) -> str:
|
|
149
|
+
"""The data centre, `wd<n>`."""
|
|
150
|
+
return next(label for label in self.host.split(".") if re.fullmatch(r"wd\d+", label))
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def base(self) -> str:
|
|
154
|
+
return f"https://{self.host}{self.prefix}/{self.site}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def site(url: str) -> Site | None:
|
|
158
|
+
parts = urlsplit(url)
|
|
159
|
+
host = parts.hostname or ""
|
|
160
|
+
if (jobs := JOBS_HOST.match(host)) and (path := JOBS_PATH.match(parts.path)):
|
|
161
|
+
return Site(jobs["tenant"], host, "", path["site"], path["rest"] or "")
|
|
162
|
+
if SITE_HOST.match(host) and (path := SITE_PATH.match(parts.path)):
|
|
163
|
+
prefix = f"/recruiting/{path['tenant']}"
|
|
164
|
+
return Site(path["tenant"], host, prefix, path["site"], path["rest"] or "")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _profile_hosts(request: Request, scope: str | None) -> list[str]:
|
|
169
|
+
"""The Workday hosts a request's cookies are for: the tenant's own, or a shared
|
|
170
|
+
myworkdaysite.com host."""
|
|
171
|
+
hosts = {c["domain"].lstrip(".") for c in request.storage_state()["cookies"]}
|
|
172
|
+
return sorted(
|
|
173
|
+
h for h in hosts
|
|
174
|
+
if SITE_HOST.match(h) or ((m := JOBS_HOST.match(h)) and m["tenant"] == scope)
|
|
175
|
+
) # fmt: skip
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Workday:
|
|
179
|
+
name = "workday"
|
|
180
|
+
per_employer = True
|
|
181
|
+
known_gaps = {
|
|
182
|
+
"experience_entries": Gap.FLOW,
|
|
183
|
+
"long_wizard": Gap.FLOW,
|
|
184
|
+
"other_country": Gap.FLOW,
|
|
185
|
+
}
|
|
186
|
+
side_effects = [
|
|
187
|
+
SideEffect("sign_up", "employer", "the account is created in the employer's tenant"),
|
|
188
|
+
SideEffect("load", "hiring_system", "opening the wizard creates a draft application"),
|
|
189
|
+
SideEffect(
|
|
190
|
+
"fill", "hiring_system", "each page, the resume too, is saved to the draft when left"
|
|
191
|
+
),
|
|
192
|
+
SideEffect("submit", "employer", "the application reaches the employer"),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
def __init__(self) -> None:
|
|
196
|
+
self._sites: dict[str, Site] = {} # by tenant, the last posting's
|
|
197
|
+
self._fields: dict[str, Field] = {}
|
|
198
|
+
self._values: dict[str, Answer] = {}
|
|
199
|
+
self._apply = ""
|
|
200
|
+
|
|
201
|
+
def detect(self, url: str, page: Page | None) -> bool:
|
|
202
|
+
found = site(url)
|
|
203
|
+
return found is not None and bool(JOB.match(found.rest))
|
|
204
|
+
|
|
205
|
+
def posting(self, url: str) -> str:
|
|
206
|
+
"""The job's slug on its tenant's site, whatever the host, locale or location."""
|
|
207
|
+
found = site(url)
|
|
208
|
+
if found is None or not (job := JOB.match(found.rest)):
|
|
209
|
+
return url
|
|
210
|
+
return f"https://{found.tenant}.{found.dc}.myworkdayjobs.com/{found.site}/details/{job['slug']}"
|
|
211
|
+
|
|
212
|
+
def account_scope(self, url: str) -> str | None:
|
|
213
|
+
found = site(url)
|
|
214
|
+
if found is None:
|
|
215
|
+
return None
|
|
216
|
+
self._sites[found.tenant] = found
|
|
217
|
+
return found.tenant
|
|
218
|
+
|
|
219
|
+
def login_url(self, scope: str | None) -> str:
|
|
220
|
+
"""The site's login page; the site is the last posting's for the tenant."""
|
|
221
|
+
if scope not in self._sites:
|
|
222
|
+
raise ValueError(f"no Workday posting seen for the tenant {scope!r}")
|
|
223
|
+
return f"{self._sites[scope].base}/login"
|
|
224
|
+
|
|
225
|
+
def signed_in(self, request: Request, scope: str | None) -> bool:
|
|
226
|
+
"""The tenant's `userprofile` holds the account's email; `{}` for a visitor."""
|
|
227
|
+
for host in _profile_hosts(request, scope):
|
|
228
|
+
response = request.get(f"https://{host}/wday/cxs/{scope}/x/userprofile")
|
|
229
|
+
if response.ok and response.json().get("emailAddress"):
|
|
230
|
+
return True
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
def sign_in(self, page: Page, email: str, password: str) -> None:
|
|
234
|
+
_email_form(page)
|
|
235
|
+
_submit(page, "signInSubmitButton", email, password)
|
|
236
|
+
|
|
237
|
+
def sign_up(self, page: Page, email: str, password: str) -> None:
|
|
238
|
+
_email_form(page)
|
|
239
|
+
page.locator("[data-automation-id=createAccountLink]").click()
|
|
240
|
+
page.locator("[data-automation-id=verifyPassword]").fill(password)
|
|
241
|
+
consent = page.locator("[data-automation-id=createAccountCheckbox]")
|
|
242
|
+
if consent.count():
|
|
243
|
+
consent.check()
|
|
244
|
+
_submit(page, "createAccountSubmitButton", email, password)
|
|
245
|
+
|
|
246
|
+
def read_form(self, page: Page) -> list[Field]:
|
|
247
|
+
"""Walks the wizard as far as its pages save with what they hold, reading each page
|
|
248
|
+
it reaches: from the page, else My Experience from the apply flow's sections and the
|
|
249
|
+
questions from the questionnaires, which also stand for those pages when they are not
|
|
250
|
+
reached. A page after the first that does not save is read once it does."""
|
|
251
|
+
found = site(page.url)
|
|
252
|
+
if found is None or not (job := JOB.match(found.rest)):
|
|
253
|
+
raise RuntimeError(f"{page.url} is not a Workday posting")
|
|
254
|
+
api = f"https://{found.host}/wday/cxs/{found.tenant}/{found.site}"
|
|
255
|
+
calypso = f"https://{found.host}/wday/calypso/cxs"
|
|
256
|
+
posting = _json(page, f"{api}/job/{job['slug']}").get("jobPostingInfo") or {}
|
|
257
|
+
if not posting.get("posted"): # `canApply` is false too with a draft in progress
|
|
258
|
+
raise Unsupported("posting_gone: the posting takes no applications")
|
|
259
|
+
flow = _json(
|
|
260
|
+
page,
|
|
261
|
+
f"{calypso}/jobapplication/{found.tenant}/jobpostings/{job['slug']}"
|
|
262
|
+
"/applyflowpages?applyDetail=applyManually",
|
|
263
|
+
)
|
|
264
|
+
values = f"{calypso}/jobapplication/{found.tenant}/values"
|
|
265
|
+
from_api = {EXPERIENCE: _experience(self, page, flow, values)}
|
|
266
|
+
for kind, key, step in QUESTIONNAIRES:
|
|
267
|
+
if questionnaire := posting.get(key):
|
|
268
|
+
url = f"{calypso}/common/{found.tenant}/questionnaire/{questionnaire}"
|
|
269
|
+
from_api[step] = _questions(kind, _json(page, url).get("questions") or [])
|
|
270
|
+
self._apply = f"{page.url.split('?')[0].split('/apply')[0]}/apply/applyManually"
|
|
271
|
+
page.goto(self._apply)
|
|
272
|
+
_wait_for_page(page)
|
|
273
|
+
seen: dict[str, list[Field]] = {}
|
|
274
|
+
for _ in range(MAX_PAGES):
|
|
275
|
+
step = _step(page)
|
|
276
|
+
if step == REVIEW: # repeats the answers, an upload without its input
|
|
277
|
+
break
|
|
278
|
+
if not (_questions_page(step) or step == EXPERIENCE):
|
|
279
|
+
seen[step] = _read(page, f"{calypso}/jobapplication/{found.tenant}{PHONE_CODES}")
|
|
280
|
+
try:
|
|
281
|
+
_save_and_continue(page)
|
|
282
|
+
except NotTaken:
|
|
283
|
+
break
|
|
284
|
+
steps = [p["descriptor"] for p in flow.get("data") or []]
|
|
285
|
+
steps += [s for s in seen if s not in steps]
|
|
286
|
+
fields = [
|
|
287
|
+
f for s in steps for f in seen.get(s, []) + from_api.get(_questionnaire_step(s), [])
|
|
288
|
+
]
|
|
289
|
+
self._fields = {f.id: f for f in fields if not f.unfillable}
|
|
290
|
+
return fields
|
|
291
|
+
|
|
292
|
+
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
293
|
+
"""Fills each page of the wizard from the first and saves it, up to Review."""
|
|
294
|
+
page.goto(self._apply)
|
|
295
|
+
_wait_for_page(page)
|
|
296
|
+
self._values = {}
|
|
297
|
+
pending: set[PageRequest] = set()
|
|
298
|
+
page.on("request", lambda r: pending.add(r) if DEFINITION in r.url else None)
|
|
299
|
+
for done in ("requestfinished", "requestfailed"):
|
|
300
|
+
page.on(done, lambda r: pending.discard(r))
|
|
301
|
+
for _ in range(MAX_PAGES):
|
|
302
|
+
if _step(page) == REVIEW:
|
|
303
|
+
return
|
|
304
|
+
_add_entries(page, answers)
|
|
305
|
+
filled: set[str] = set()
|
|
306
|
+
for _ in range(FOLLOW_UP_ROUNDS): # an answer shows its follow-up questions
|
|
307
|
+
todo = [r for r in _rendered(page, answers) if r["id"] not in filled]
|
|
308
|
+
todo = [r for r in todo if r["id"] in answers and r["id"] in self._fields]
|
|
309
|
+
if not todo:
|
|
310
|
+
break
|
|
311
|
+
for raw in todo:
|
|
312
|
+
_fill(page, self._fields[raw["id"]], raw, answers[raw["id"]])
|
|
313
|
+
filled.add(raw["id"])
|
|
314
|
+
if raw["kind"] != "text":
|
|
315
|
+
_until_rendered(page, pending)
|
|
316
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
317
|
+
self._unread(page)
|
|
318
|
+
self._values |= {r["id"]: r["value"] for r in _page_fields(page)}
|
|
319
|
+
if self._values.get(PHONE_NUMBER) and (code := _phone_code(page)):
|
|
320
|
+
self._values[PHONE_NUMBER] = f"+{code} {self._values[PHONE_NUMBER]}"
|
|
321
|
+
_save_and_continue(page)
|
|
322
|
+
raise Unsupported(known_gap(self, "long_wizard", f"more than {MAX_PAGES} pages"))
|
|
323
|
+
|
|
324
|
+
def _unread(self, page: Page) -> None:
|
|
325
|
+
"""Stops on required fields the read did not list: on My Information, the address of a
|
|
326
|
+
country other than the form's; on a later page, one the read could not reach."""
|
|
327
|
+
unread = [
|
|
328
|
+
r["label"] for r in _page_fields(page) if r["required"] and r["id"] not in self._fields
|
|
329
|
+
]
|
|
330
|
+
if not unread:
|
|
331
|
+
return
|
|
332
|
+
step, labels = _step(page), ", ".join(unread)
|
|
333
|
+
if step == MY_INFORMATION:
|
|
334
|
+
detail = f"the country answered shows fields the read did not: {labels}"
|
|
335
|
+
raise Unsupported(known_gap(self, "other_country", detail))
|
|
336
|
+
raise NotTaken(f"{step} shows fields the read did not: {labels}; read again")
|
|
337
|
+
|
|
338
|
+
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
339
|
+
"""Each page's values, read before it was saved; one file as its name."""
|
|
340
|
+
values: dict[str, Answer] = {}
|
|
341
|
+
for k, v in self._values.items():
|
|
342
|
+
if k not in self._fields or v in (None, "", []):
|
|
343
|
+
continue
|
|
344
|
+
one_file = self._fields[k].type is FieldType.FILE and len(v) == 1
|
|
345
|
+
values[k] = v[0] if one_file else v
|
|
346
|
+
return values
|
|
347
|
+
|
|
348
|
+
def submit(self, page: Page) -> str | None:
|
|
349
|
+
with page.expect_response(
|
|
350
|
+
lambda r: r.request.method in ("POST", "PUT") and "/submit" in r.url,
|
|
351
|
+
timeout=SUBMIT_TIMEOUT_MS,
|
|
352
|
+
) as sent:
|
|
353
|
+
page.locator(NEXT).click()
|
|
354
|
+
response = sent.value
|
|
355
|
+
if response.ok:
|
|
356
|
+
return f"{response.request.method} {urlsplit(response.url).path} {response.status}"
|
|
357
|
+
raise RuntimeError(f"{response.url} answered {response.status}")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _email_form(page: Page) -> None:
|
|
361
|
+
"""Opens the email form where the page offers social accounts first."""
|
|
362
|
+
form = page.locator(f"{EMAIL}:visible, {WITH_EMAIL}:visible").first
|
|
363
|
+
form.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
364
|
+
if page.locator(WITH_EMAIL).is_visible():
|
|
365
|
+
page.locator(WITH_EMAIL).click()
|
|
366
|
+
page.locator(EMAIL).wait_for(timeout=FORM_TIMEOUT_MS)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _submit(page: Page, button: str, email: str, password: str) -> None:
|
|
370
|
+
"""Fills the email and password, presses the button through the overlay that takes its
|
|
371
|
+
clicks, and waits to leave the login page or for what the page says."""
|
|
372
|
+
page.locator(EMAIL).fill(email)
|
|
373
|
+
page.locator("[data-automation-id=password]").fill(password)
|
|
374
|
+
press = page.locator(
|
|
375
|
+
f"[data-automation-id=noCaptchaWrapper]:has([data-automation-id={button}])"
|
|
376
|
+
" [data-automation-id=click_filter]"
|
|
377
|
+
)
|
|
378
|
+
said = page.locator("[data-automation-id=errorMessage], [data-automation-id=inputAlert]")
|
|
379
|
+
requested: list[PageRequest] = []
|
|
380
|
+
page.on("request", lambda r: requested.append(r))
|
|
381
|
+
for _ in range(PRESSES): # the first press after typing can post nothing
|
|
382
|
+
if not _on_login(page) or any(_redirected(r) for r in requested):
|
|
383
|
+
break
|
|
384
|
+
try:
|
|
385
|
+
with page.expect_response(_posts_account, timeout=PRESS_TIMEOUT_MS):
|
|
386
|
+
press.click()
|
|
387
|
+
break
|
|
388
|
+
except Timeout:
|
|
389
|
+
if said.count(): # a field the page refused without posting
|
|
390
|
+
break
|
|
391
|
+
deadline = time.monotonic() + SIGN_IN_TIMEOUT_S
|
|
392
|
+
while time.monotonic() < deadline:
|
|
393
|
+
redirects = [_redirected(r) for r in requested]
|
|
394
|
+
if "/login" in redirects:
|
|
395
|
+
raise Refused("email_in_use: the sign-up went back to the login page")
|
|
396
|
+
if "/login/warning" in redirects:
|
|
397
|
+
raise Blocked("email_link: the sign-up sent a verification email")
|
|
398
|
+
if not _on_login(page):
|
|
399
|
+
return
|
|
400
|
+
if said.count():
|
|
401
|
+
text = " ".join(said.all_inner_texts()).strip()
|
|
402
|
+
for pattern, error, code in MESSAGES:
|
|
403
|
+
if pattern.search(text):
|
|
404
|
+
raise error(f"{code}: {text}")
|
|
405
|
+
raise Unsupported(f"unhandled_rendering: the login page says {text!r}")
|
|
406
|
+
page.wait_for_timeout(SIGN_IN_POLL_MS)
|
|
407
|
+
raise Unsupported("unhandled_rendering: the login page answered nothing")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _on_login(page: Page) -> bool:
|
|
411
|
+
return urlsplit(page.url).path.endswith("/login")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _redirected(request: PageRequest) -> str | None:
|
|
415
|
+
"""Where a sign-up redirected to, after the site: `/login` for an email that has an
|
|
416
|
+
account, `/login/warning` once a verification email is sent; None for another request."""
|
|
417
|
+
before = request.redirected_from
|
|
418
|
+
if before is None or not urlsplit(before.url).path.endswith("/register"):
|
|
419
|
+
return None
|
|
420
|
+
found = site(request.url)
|
|
421
|
+
return found.rest if found else None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _posts_account(response: Response) -> bool:
|
|
425
|
+
return response.request.method == "POST" and bool(
|
|
426
|
+
re.search(r"/(login|register)$", urlsplit(response.url).path)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _json(page: Page, url: str) -> Any:
|
|
431
|
+
"""A Workday API's answer, fetched by the page: same origin, the session's cookies."""
|
|
432
|
+
return page.evaluate(
|
|
433
|
+
"""async url => {
|
|
434
|
+
const response = await fetch(url, {headers: {Accept: "application/json"}});
|
|
435
|
+
return response.ok ? response.json() : {};
|
|
436
|
+
}""",
|
|
437
|
+
url,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _text(html: str) -> str:
|
|
442
|
+
return re.sub(r"\s+", " ", unescape(TAG.sub(" ", html))).strip()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _questions_page(step: str) -> bool:
|
|
446
|
+
return step.startswith("Application Questions")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _questionnaire_step(step: str) -> str:
|
|
450
|
+
"""The key a page's API fields are under: `… 1 of 2` is the primary questionnaire's."""
|
|
451
|
+
return re.sub(r" 1 of \d+$", "", step)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _read(page: Page, phone_codes: str) -> list[Field]:
|
|
455
|
+
"""The fields the page shows, a dropdown's or a short prompt's options read by opening it,
|
|
456
|
+
the country phone codes from `phone_codes`, the API's list."""
|
|
457
|
+
raws = _page_fields(page)
|
|
458
|
+
for raw in raws:
|
|
459
|
+
box = page.locator(f'[data-fkit-id="{raw["dom"]}"]')
|
|
460
|
+
if raw["kind"] == "list":
|
|
461
|
+
raw["options"] = _options(page, raw)
|
|
462
|
+
elif raw["id"] == PHONE_CODE_ID:
|
|
463
|
+
raw["options"] = [c["descriptor"] for c in _json(page, phone_codes)]
|
|
464
|
+
elif raw["kind"] == "prompt":
|
|
465
|
+
raw["options"] = _prompt_options(page, box)
|
|
466
|
+
return [f for raw in raws if (f := _field(raw)) is not None]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _wait_for_page(page: Page) -> None:
|
|
470
|
+
"""Waits for the wizard's page, or for what stands before it."""
|
|
471
|
+
page.locator(f"{NEXT}, {APPLIED}").first.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
472
|
+
if page.locator(APPLIED).count():
|
|
473
|
+
raise Unsupported("posting_gone: the account already applied to this posting")
|
|
474
|
+
if _step(page) == REVIEW:
|
|
475
|
+
return
|
|
476
|
+
page.locator(FIELD).first.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
477
|
+
counts, deadline = [-1], time.monotonic() + FORM_TIMEOUT_MS / 1000
|
|
478
|
+
while time.monotonic() < deadline: # sections render one after another, some seconds apart
|
|
479
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
480
|
+
counts.append(page.locator(FIELD).count())
|
|
481
|
+
if len(set(counts[-SETTLED:])) == 1 and len(counts) > SETTLED:
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _step(page: Page) -> str:
|
|
486
|
+
"""The wizard's current page, `My Information`…"""
|
|
487
|
+
return page.locator(STEP).inner_text().strip().splitlines()[-1].strip()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _page_fields(page: Page) -> list[dict[str, Any]]:
|
|
491
|
+
"""The page's fields, a list's first entry's parts as `<list>--<part>`: the page numbers
|
|
492
|
+
entries its own way (`workExperience-8--jobTitle`)."""
|
|
493
|
+
raws, first = page.evaluate(_FIELDS), {}
|
|
494
|
+
for raw in raws:
|
|
495
|
+
raw["dom"] = raw["id"] # the page's, to find the field by
|
|
496
|
+
entry = ENTRY_ID.match(raw["id"])
|
|
497
|
+
if entry and first.setdefault(entry[1], entry[2]) == entry[2]:
|
|
498
|
+
raw["id"] = ENTRY_ID.sub(r"\1--", raw["id"])
|
|
499
|
+
raw["id"] = SCHOOL_IDS.get(raw["id"], raw["id"])
|
|
500
|
+
return raws
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _add_entries(page: Page, answers: dict[str, Answer]) -> None:
|
|
504
|
+
"""Adds a list's first entry where answers are given for it and the page shows none."""
|
|
505
|
+
shown = {r["id"] for r in _page_fields(page)}
|
|
506
|
+
groups = page.locator("[role=group][aria-labelledby]").all()
|
|
507
|
+
for prefix, pattern, _ in ENTRY_LISTS:
|
|
508
|
+
answered = any(a.startswith(f"{prefix}--") for a in answers)
|
|
509
|
+
if not answered or any(i.startswith(f"{prefix}--") for i in shown):
|
|
510
|
+
continue
|
|
511
|
+
# `Work-Experience-section`, `Work-History-(Optional)-section`
|
|
512
|
+
for group in (g for g in groups if pattern.match(_group_name(g))):
|
|
513
|
+
if group.locator(ADD).count():
|
|
514
|
+
group.locator(ADD).first.click()
|
|
515
|
+
page.locator(f'[data-fkit-id^="{prefix}-"]').first.wait_for(timeout=FORM_TIMEOUT_MS)
|
|
516
|
+
break
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _group_name(group: Locator) -> str:
|
|
520
|
+
return (group.get_attribute("aria-labelledby") or "").replace("-", " ")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _rendered(page: Page, answers: dict[str, Answer]) -> list[dict[str, Any]]:
|
|
524
|
+
"""The page's fields once each answered one shows its control: an upload's comes last."""
|
|
525
|
+
for _ in range(RENDER_TRIES):
|
|
526
|
+
raws = _page_fields(page)
|
|
527
|
+
if all(r["kind"] for r in raws if r["id"] in answers):
|
|
528
|
+
break
|
|
529
|
+
page.wait_for_timeout(SETTLE_MS)
|
|
530
|
+
return raws
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _field(raw: dict[str, Any]) -> Field | None:
|
|
534
|
+
kind, options = KINDS.get(raw["kind"]), raw["options"]
|
|
535
|
+
if raw["kind"] == "box":
|
|
536
|
+
options = YES_NO
|
|
537
|
+
if raw["id"] == PHONE_NUMBER:
|
|
538
|
+
kind = FieldType.PHONE
|
|
539
|
+
if raw["kind"] == "prompt" and options: # a list of one value to search
|
|
540
|
+
kind = FieldType.CHOICE
|
|
541
|
+
if kind is None:
|
|
542
|
+
reason = f"unhandled_rendering: {raw['label'] or raw['id']!r} is a {raw['kind']}"
|
|
543
|
+
return Field(raw["id"], raw["label"], FieldType.TEXT, raw["required"], unfillable=reason)
|
|
544
|
+
prefilled = raw["value"] not in (None, "", [], "No") and raw["id"] not in SITE_DEFAULTS
|
|
545
|
+
return Field(raw["id"], raw["label"], kind, raw["required"], options, prefilled=prefilled)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _experience(adapter: Workday, page: Page, flow: dict[str, Any], values: str) -> list[Field]:
|
|
549
|
+
"""My Experience from the apply flow's sections: the resume, skills, the first entry of the
|
|
550
|
+
work experience and the education lists, their degrees and fields of study from `values`,
|
|
551
|
+
the API's lists. A section's own fields are sections too, hidden or required each; the other
|
|
552
|
+
lists (websites, languages, certifications) atsfill does not fill, a known gap where one is
|
|
553
|
+
required."""
|
|
554
|
+
sections = [
|
|
555
|
+
s
|
|
556
|
+
for p in flow.get("data") or []
|
|
557
|
+
if p.get("descriptor") == EXPERIENCE
|
|
558
|
+
for s in p.get("sections") or []
|
|
559
|
+
]
|
|
560
|
+
shown = {s["label"]: bool(s["required"]) for s in sections if not s.get("hidden")}
|
|
561
|
+
fields = []
|
|
562
|
+
for label, required in shown.items():
|
|
563
|
+
if RESUME.search(label):
|
|
564
|
+
fields.append(Field(RESUME_ID, label, FieldType.FILE, required))
|
|
565
|
+
elif SKILLS.match(label):
|
|
566
|
+
fields.append(Field(SKILLS_ID, label, FieldType.TYPEAHEAD, required))
|
|
567
|
+
elif entry := next((e for e in ENTRY_LISTS if e[1].match(label)), None):
|
|
568
|
+
fields += _entry(page, entry, required, shown, values)
|
|
569
|
+
elif ENTRIES.match(label) and not ENTRY_PART.search(label):
|
|
570
|
+
reason = known_gap(adapter, "experience_entries", f"{label} is a list of entries")
|
|
571
|
+
fields.append(
|
|
572
|
+
Field(f"myExperience--{label}", label, FieldType.TEXT, required, unfillable=reason)
|
|
573
|
+
)
|
|
574
|
+
return fields
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _entry(
|
|
578
|
+
page: Page,
|
|
579
|
+
entry: EntryList,
|
|
580
|
+
required: bool,
|
|
581
|
+
shown: dict[str, bool],
|
|
582
|
+
values: str,
|
|
583
|
+
) -> list[Field]:
|
|
584
|
+
"""The first entry of a list: each of its parts the posting shows, required where the list
|
|
585
|
+
is and the part is, `<list>--<part>` as its id."""
|
|
586
|
+
prefix, _, parts = entry
|
|
587
|
+
name = {"workExperience": "Work Experience", "education": "Education"}[prefix]
|
|
588
|
+
fields = []
|
|
589
|
+
for part, label, kind, section in parts:
|
|
590
|
+
if section is not None and section not in shown:
|
|
591
|
+
continue
|
|
592
|
+
needed = required and (section is None or shown[section])
|
|
593
|
+
fid, label = f"{prefix}--{part}", f"{name}: {label}"
|
|
594
|
+
match kind:
|
|
595
|
+
case "month" | "year":
|
|
596
|
+
suffix = " (MM/YYYY)" if kind == "month" else " (year)"
|
|
597
|
+
if part == "endDate":
|
|
598
|
+
asked = f"{name}: I currently work here"
|
|
599
|
+
suffix, needed = f'{suffix} (asked when "{asked}" is "No")', False
|
|
600
|
+
fields.append(Field(fid, label + suffix, FieldType.TEXT, needed))
|
|
601
|
+
case "box":
|
|
602
|
+
fields.append(Field(fid, label, FieldType.CHOICE, False, YES_NO))
|
|
603
|
+
case "school":
|
|
604
|
+
fields.append(Field(fid, label, FieldType.TYPEAHEAD, needed))
|
|
605
|
+
case "degrees" | "fieldsOfStudy": # a dropdown, a prompt: the API's list either way
|
|
606
|
+
listed = _json(page, f"{values}/educations/{kind}")
|
|
607
|
+
if listed and all("href" in v for v in listed): # a long list, in groups
|
|
608
|
+
first = urlsplit(listed[0]["href"])
|
|
609
|
+
url = f"{urlsplit(values).scheme}://{urlsplit(values).netloc}{first.path}"
|
|
610
|
+
options = [v["descriptor"] for v in _json(page, f"{url}?{first.query}")]
|
|
611
|
+
fields.append(Field(fid, label, FieldType.TYPEAHEAD, needed, options or None))
|
|
612
|
+
else:
|
|
613
|
+
options = [v["descriptor"] for v in listed]
|
|
614
|
+
fields.append(Field(fid, label, FieldType.CHOICE, needed, options or None))
|
|
615
|
+
case _:
|
|
616
|
+
fields.append(Field(fid, label, FieldType.TEXT, needed))
|
|
617
|
+
return fields
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _questions(kind: str, items: list[dict[str, Any]]) -> list[Field]:
|
|
621
|
+
"""A questionnaire's questions. One an answer branches to is asked only after it: optional,
|
|
622
|
+
its label naming the question as listed, itself a follow-up at times, and the answers."""
|
|
623
|
+
by_id = {item["id"]: item for item in items}
|
|
624
|
+
asked_when: dict[str, tuple[str, list[str]]] = {}
|
|
625
|
+
for item in items:
|
|
626
|
+
for answer in item.get("possibleAnswers") or []:
|
|
627
|
+
if follow_up := (answer.get("branchingQuestion") or {}).get("id"):
|
|
628
|
+
parent, values = asked_when.setdefault(follow_up, (item["id"], []))
|
|
629
|
+
if parent == item["id"]:
|
|
630
|
+
values.append(answer["answerText"])
|
|
631
|
+
labels: dict[str, str] = {}
|
|
632
|
+
|
|
633
|
+
def label(qid: str) -> str:
|
|
634
|
+
if qid not in labels:
|
|
635
|
+
item = by_id[qid]
|
|
636
|
+
text = _text(item.get("body") or "")
|
|
637
|
+
if item.get("type", {}).get("descriptor") == "Number":
|
|
638
|
+
text += " (a number)"
|
|
639
|
+
if qid in asked_when and asked_when[qid][0] in by_id:
|
|
640
|
+
parent, values = asked_when[qid]
|
|
641
|
+
text += f' (asked when "{label(parent)}" is "{" or ".join(values)}")'
|
|
642
|
+
labels[qid] = text
|
|
643
|
+
return labels[qid]
|
|
644
|
+
|
|
645
|
+
fields = []
|
|
646
|
+
for item in items:
|
|
647
|
+
fid, text = f"{kind}Questionnaire--{item['id']}", label(item["id"])
|
|
648
|
+
options = [a["answerText"] for a in item.get("possibleAnswers") or []] or None
|
|
649
|
+
required = bool(item.get("required")) and item["id"] not in asked_when
|
|
650
|
+
match item.get("type", {}).get("descriptor"):
|
|
651
|
+
case "Multiple Choice - Single Select":
|
|
652
|
+
fields.append(Field(fid, text, FieldType.CHOICE, required, options))
|
|
653
|
+
case "Multiple Choice - Multi Select":
|
|
654
|
+
fields.append(Field(fid, text, FieldType.MULTI_CHOICE, required, options))
|
|
655
|
+
case "Text" | "Number":
|
|
656
|
+
fields.append(Field(fid, text, FieldType.TEXT, required))
|
|
657
|
+
case "Date":
|
|
658
|
+
fields.append(Field(fid, text, FieldType.DATE, required))
|
|
659
|
+
case "Upload Attachments":
|
|
660
|
+
fields.append(Field(fid, text, FieldType.FILE, required))
|
|
661
|
+
case other:
|
|
662
|
+
reason = f"unhandled_rendering: {fid} is a question of type {other}"
|
|
663
|
+
fields.append(Field(fid, text, FieldType.TEXT, required, unfillable=reason))
|
|
664
|
+
return fields
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _fill(page: Page, field: Field, raw: dict[str, Any], answer: Answer) -> None:
|
|
668
|
+
box = page.locator(f'[data-fkit-id="{raw["dom"]}"]')
|
|
669
|
+
match raw["kind"], answer:
|
|
670
|
+
case "text", str():
|
|
671
|
+
control = box.locator("textarea, input[type=text]").first
|
|
672
|
+
if field.type is FieldType.PHONE:
|
|
673
|
+
answer = _national(page, answer)
|
|
674
|
+
control.fill("")
|
|
675
|
+
control.press_sequentially(answer) # some pages miss a value filled at once
|
|
676
|
+
control.blur() # the page takes the value on leaving the field
|
|
677
|
+
case "list", str():
|
|
678
|
+
_pick(page, field, box, answer)
|
|
679
|
+
case "radio" | "boxes" | "box", _:
|
|
680
|
+
wanted = {answer} if isinstance(answer, str) else set(answer)
|
|
681
|
+
for option in box.locator("input[type=radio], input[type=checkbox]").all():
|
|
682
|
+
name = "Yes" if raw["kind"] == "box" else _option_label(option)
|
|
683
|
+
checked = any(matching.matches(field, w, name) for w in wanted)
|
|
684
|
+
if raw["kind"] == "box":
|
|
685
|
+
checked = answer == "Yes"
|
|
686
|
+
if option.is_checked() != checked and (checked or raw["kind"] != "radio"):
|
|
687
|
+
option.click()
|
|
688
|
+
case "prompt", str():
|
|
689
|
+
_prompt(page, field, box, answer)
|
|
690
|
+
case "date" | "month" | "year", str():
|
|
691
|
+
if raw["kind"] == "date":
|
|
692
|
+
year, month, day = answer.split("-")
|
|
693
|
+
elif raw["kind"] == "month":
|
|
694
|
+
(month, year), day = answer.split("/"), ""
|
|
695
|
+
else:
|
|
696
|
+
year, month, day = answer, "", ""
|
|
697
|
+
for part, value in (("Month", month), ("Day", day), ("Year", year)):
|
|
698
|
+
section = box.locator(f"[data-automation-id=dateSection{part}-input]")
|
|
699
|
+
if section.count():
|
|
700
|
+
section.focus() # a display over it takes clicks
|
|
701
|
+
section.press("Control+a") # the draft's value
|
|
702
|
+
section.press_sequentially(value)
|
|
703
|
+
case "file", _:
|
|
704
|
+
files = [answer] if isinstance(answer, str) else answer
|
|
705
|
+
delete = box.locator(DELETE_FILE)
|
|
706
|
+
while (count := delete.count()) > 0: # the draft's or the profile's files
|
|
707
|
+
delete.first.click()
|
|
708
|
+
deadline = time.monotonic() + UPLOAD_TIMEOUT_MS / 1000
|
|
709
|
+
while delete.count() == count and time.monotonic() < deadline:
|
|
710
|
+
page.wait_for_timeout(STEP_POLL_MS)
|
|
711
|
+
box.locator("input[type=file]").set_input_files(files)
|
|
712
|
+
for path in files:
|
|
713
|
+
name = path.rsplit("/", 1)[-1]
|
|
714
|
+
box.locator(UPLOADED, has_text=name).first.wait_for(timeout=UPLOAD_TIMEOUT_MS)
|
|
715
|
+
case kind, _:
|
|
716
|
+
reason = f"unhandled_rendering: cannot fill {field.id}, a {kind}, with {answer!r}"
|
|
717
|
+
raise Unsupported(reason)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _option_label(option: Locator) -> str:
|
|
721
|
+
return option.evaluate(
|
|
722
|
+
"""e => (document.querySelector(`label[for="${CSS.escape(e.id)}"]`)
|
|
723
|
+
?? e.closest("label") ?? e.parentElement).innerText.trim()"""
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _phone_code(page: Page) -> str | None:
|
|
728
|
+
"""The country code the form shows beside the phone number, `44` for `(+44)`."""
|
|
729
|
+
box = page.locator(PHONE_CODE)
|
|
730
|
+
code = re.search(r"\(\+(\d+)\)", box.inner_text()) if box.count() else None
|
|
731
|
+
return code[1] if code else None
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _national(page: Page, number: str) -> str:
|
|
735
|
+
"""The number without the country code the form shows beside it, which must be the
|
|
736
|
+
number's own."""
|
|
737
|
+
code, digits = _phone_code(page), re.sub(r"[^\d+]", "", number)
|
|
738
|
+
if code and digits.startswith(f"+{code}"):
|
|
739
|
+
return digits[len(code) + 1 :]
|
|
740
|
+
if code and digits.startswith("+"):
|
|
741
|
+
raise NotTaken(f"{number!r} is not a +{code} number: answer {PHONE_CODE_ID} for it")
|
|
742
|
+
return number
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _until_rendered(page: Page, pending: set[PageRequest]) -> None:
|
|
746
|
+
page.wait_for_timeout(STEP_POLL_MS) # the request goes out a moment after the answer
|
|
747
|
+
deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000
|
|
748
|
+
while pending and time.monotonic() < deadline:
|
|
749
|
+
page.wait_for_timeout(STEP_POLL_MS)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _pick(page: Page, field: Field, box: Locator, answer: str) -> None:
|
|
753
|
+
"""A dropdown: its options are in the listbox the button controls."""
|
|
754
|
+
listbox = _open(page, box.locator("button[aria-haspopup=listbox]").first)
|
|
755
|
+
for option in listbox.locator("[role=option]:not([aria-disabled=true])").all():
|
|
756
|
+
if matching.matches(field, answer, option.inner_text().strip()):
|
|
757
|
+
option.click()
|
|
758
|
+
return
|
|
759
|
+
page.keyboard.press("Escape")
|
|
760
|
+
raise NotTaken(f"no option {answer!r} in {field.id}")
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _open(page: Page, button: Locator) -> Locator:
|
|
764
|
+
"""Opens a dropdown and waits for its options, which load a moment later."""
|
|
765
|
+
for press in range(PRESSES): # a click right after typing elsewhere can go unheard
|
|
766
|
+
button.click()
|
|
767
|
+
try: # some tenants name the listbox only once it opens
|
|
768
|
+
page.wait_for_function(
|
|
769
|
+
"b => b.getAttribute('aria-controls')",
|
|
770
|
+
arg=button.element_handle(),
|
|
771
|
+
timeout=MENU_TIMEOUT_MS / PRESSES,
|
|
772
|
+
)
|
|
773
|
+
break
|
|
774
|
+
except Timeout:
|
|
775
|
+
if press == PRESSES - 1:
|
|
776
|
+
raise
|
|
777
|
+
listbox = page.locator(f"[id='{button.get_attribute('aria-controls')}']")
|
|
778
|
+
listbox.locator(f"[role=option]:not(:text-is('{SELECT_ONE}'))").first.wait_for(
|
|
779
|
+
timeout=MENU_TIMEOUT_MS
|
|
780
|
+
)
|
|
781
|
+
return listbox
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _options(page: Page, raw: dict[str, Any]) -> list[str]:
|
|
785
|
+
listbox = _open(
|
|
786
|
+
page, page.locator(f'[data-fkit-id="{raw["dom"]}"] button[aria-haspopup=listbox]').first
|
|
787
|
+
)
|
|
788
|
+
names = [
|
|
789
|
+
o.inner_text().strip()
|
|
790
|
+
for o in listbox.locator("[role=option]:not([aria-disabled=true])").all()
|
|
791
|
+
]
|
|
792
|
+
page.keyboard.press("Escape")
|
|
793
|
+
return [n for n in names if n and n != SELECT_ONE]
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _prompt(page: Page, field: Field, box: Locator, answer: str) -> None:
|
|
797
|
+
"""A search prompt: typing and Enter list the items that match, at any depth. A prompt of
|
|
798
|
+
one item has it replaced; skills, of many, get one more."""
|
|
799
|
+
if _picked(field, box, answer):
|
|
800
|
+
return
|
|
801
|
+
if field.id != SKILLS_ID: # every other prompt holds one item
|
|
802
|
+
for picked in box.locator(PICKED).all():
|
|
803
|
+
picked.locator(DELETE_PICK).click()
|
|
804
|
+
# The search can miss a name with brackets that their content finds: `Citizen (Ireland)`
|
|
805
|
+
for term in [answer, *BRACKETED.findall(answer)]:
|
|
806
|
+
if _search(page, field, box, answer, term):
|
|
807
|
+
return
|
|
808
|
+
if _pick_in_list(page, field, box, answer): # a search can miss an item the list holds
|
|
809
|
+
return
|
|
810
|
+
raise NotTaken(f"no item {answer!r} in {field.id}")
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _search(page: Page, field: Field, box: Locator, answer: str, term: str) -> bool:
|
|
814
|
+
"""Types the term and Enter, which picks the only item that matches, else lists them."""
|
|
815
|
+
search = _close_prompts(page, box)
|
|
816
|
+
search.fill(term) # a click would land on the picked item over it
|
|
817
|
+
search.press("Enter")
|
|
818
|
+
options = page.locator(PROMPT_OPTION)
|
|
819
|
+
deadline = time.monotonic() + MENU_TIMEOUT_MS / 1000
|
|
820
|
+
while time.monotonic() < deadline:
|
|
821
|
+
if _picked(field, box, answer):
|
|
822
|
+
_close_prompts(page, box)
|
|
823
|
+
return True
|
|
824
|
+
for option in options.all():
|
|
825
|
+
if matching.matches(field, answer, option.inner_text().strip()):
|
|
826
|
+
option.click()
|
|
827
|
+
_close_prompts(page, box)
|
|
828
|
+
return True
|
|
829
|
+
page.wait_for_timeout(STEP_POLL_MS)
|
|
830
|
+
return False
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _pick_in_list(page: Page, field: Field, box: Locator, answer: str) -> bool:
|
|
834
|
+
"""Picks the item from the prompt's whole list, a category's items behind it."""
|
|
835
|
+
search = _close_prompts(page, box)
|
|
836
|
+
search.fill("")
|
|
837
|
+
search.press("Enter")
|
|
838
|
+
try:
|
|
839
|
+
page.locator(PROMPT_OPTION).first.wait_for(timeout=MENU_TIMEOUT_MS)
|
|
840
|
+
except Timeout:
|
|
841
|
+
_close_prompts(page, box)
|
|
842
|
+
return False
|
|
843
|
+
for n, row in enumerate(page.evaluate(_PROMPT_ROWS)):
|
|
844
|
+
if not row["nested"] and matching.matches(field, answer, row["name"]):
|
|
845
|
+
page.locator(PROMPT_OPTION).nth(n).click()
|
|
846
|
+
break
|
|
847
|
+
if row["nested"]:
|
|
848
|
+
page.locator(PROMPT_OPTION).nth(n).click()
|
|
849
|
+
page.locator(BACK).wait_for(timeout=MENU_TIMEOUT_MS)
|
|
850
|
+
names = [r["name"] for r in page.evaluate(_PROMPT_ROWS)]
|
|
851
|
+
if found := [m for m, n in enumerate(names) if matching.matches(field, answer, n)]:
|
|
852
|
+
page.locator(PROMPT_OPTION).nth(found[0]).click()
|
|
853
|
+
break
|
|
854
|
+
page.locator(BACK).click()
|
|
855
|
+
page.locator(BACK).wait_for(state="hidden", timeout=MENU_TIMEOUT_MS)
|
|
856
|
+
_close_prompts(page, box)
|
|
857
|
+
return _picked(field, box, answer)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _picked(field: Field, box: Locator, answer: str) -> bool:
|
|
861
|
+
picked = box.locator(PICKED).all_inner_texts()
|
|
862
|
+
return any(matching.matches(field, answer, p.strip()) for p in picked)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _close_prompts(page: Page, box: Locator) -> Locator:
|
|
866
|
+
"""Closes any open prompt list; the box's search input."""
|
|
867
|
+
search = box.locator(SEARCH)
|
|
868
|
+
for _ in range(PROMPT_LEVELS):
|
|
869
|
+
if not page.locator(PROMPT_OPTION).count():
|
|
870
|
+
break
|
|
871
|
+
search.press("Escape")
|
|
872
|
+
page.mouse.click(1, 1) # outside any list, which closes it
|
|
873
|
+
return search
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _prompt_options(page: Page, box: Locator) -> list[str] | None:
|
|
877
|
+
"""The items of a prompt's short list, a category's items in its place; None for a long
|
|
878
|
+
list or a search."""
|
|
879
|
+
search = _close_prompts(page, box)
|
|
880
|
+
search.focus()
|
|
881
|
+
search.press("Enter") # on an empty search: the whole list
|
|
882
|
+
options = page.locator(PROMPT_OPTION)
|
|
883
|
+
try:
|
|
884
|
+
options.first.wait_for(timeout=MENU_TIMEOUT_MS)
|
|
885
|
+
except Timeout:
|
|
886
|
+
return None
|
|
887
|
+
rows = page.evaluate(_PROMPT_ROWS)
|
|
888
|
+
if not rows or rows[0]["size"] > MAX_PROMPT_OPTIONS:
|
|
889
|
+
_close_prompts(page, box)
|
|
890
|
+
return None
|
|
891
|
+
items = []
|
|
892
|
+
for n, row in enumerate(rows):
|
|
893
|
+
if not row["nested"]:
|
|
894
|
+
items.append(row["name"])
|
|
895
|
+
continue
|
|
896
|
+
page.locator(PROMPT_OPTION).nth(n).click() # by place: a name can hold a no-break space
|
|
897
|
+
page.locator(BACK).wait_for(timeout=MENU_TIMEOUT_MS)
|
|
898
|
+
items += [r["name"] for r in page.evaluate(_PROMPT_ROWS)]
|
|
899
|
+
page.locator(BACK).click()
|
|
900
|
+
page.locator(BACK).wait_for(state="hidden", timeout=MENU_TIMEOUT_MS)
|
|
901
|
+
_close_prompts(page, box)
|
|
902
|
+
return items
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _save_and_continue(page: Page) -> None:
|
|
906
|
+
"""Saves the page; raises with what the page says when it stays. A press can go unheard,
|
|
907
|
+
like the login page's: one that changes nothing is made again."""
|
|
908
|
+
before, shown = _step(page), _errors(page)
|
|
909
|
+
for _ in range(PRESSES):
|
|
910
|
+
page.locator(NEXT).click()
|
|
911
|
+
deadline = time.monotonic() + STEP_TIMEOUT_MS / 1000 / PRESSES
|
|
912
|
+
while time.monotonic() < deadline:
|
|
913
|
+
if _step(page) != before:
|
|
914
|
+
_wait_for_page(page)
|
|
915
|
+
return
|
|
916
|
+
if (errors := _errors(page)) and errors != shown: # a former save's stay a while
|
|
917
|
+
raise NotTaken(f"{before} was not saved: {errors}")
|
|
918
|
+
page.wait_for_timeout(STEP_POLL_MS)
|
|
919
|
+
if errors := _errors(page):
|
|
920
|
+
raise NotTaken(f"{before} was not saved: {errors}")
|
|
921
|
+
raise RuntimeError(f"{before} was not saved in {STEP_TIMEOUT_MS / 1000:.0f}s")
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _errors(page: Page) -> str:
|
|
925
|
+
return re.sub(r"\s+", " ", " ".join(page.locator(ERRORS).all_inner_texts())).strip()
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
# Each field the page shows: its id, label, whether required (`*`), rendering and value.
|
|
929
|
+
_FIELDS = """() => {
|
|
930
|
+
const text = e => (e?.innerText ?? "").replace(/\\s+/g, " ").trim();
|
|
931
|
+
const labelOf = (f, e) => text(f.querySelector(`label[for="${CSS.escape(e.id)}"]`)
|
|
932
|
+
?? e.closest("label") ?? e.parentElement);
|
|
933
|
+
return [...document.querySelectorAll('[data-automation-id^="formField-"][data-fkit-id]')]
|
|
934
|
+
.filter(f => f.offsetParent !== null)
|
|
935
|
+
.map(f => {
|
|
936
|
+
const label = text(f.querySelector("label, legend"));
|
|
937
|
+
const list = f.querySelector("button[aria-haspopup=listbox]");
|
|
938
|
+
const prompt = f.querySelector("input[data-uxi-widget-type=selectinput]");
|
|
939
|
+
const radios = [...f.querySelectorAll("input[type=radio]")];
|
|
940
|
+
const boxes = [...f.querySelectorAll("input[type=checkbox]")];
|
|
941
|
+
const file = f.querySelector("input[type=file]");
|
|
942
|
+
const date = f.querySelector("[data-automation-id^=dateSection]");
|
|
943
|
+
const typed = f.querySelector("textarea, input[type=text]");
|
|
944
|
+
let kind = null, value = null, options = null;
|
|
945
|
+
if (file) {
|
|
946
|
+
kind = "file";
|
|
947
|
+
value = [...f.querySelectorAll("[data-automation-id=file-upload-item-name]")].map(text);
|
|
948
|
+
} else if (date) {
|
|
949
|
+
// a date, a month (MM/YYYY) or a year, by the sections it shows
|
|
950
|
+
const part = p => f.querySelector(`[data-automation-id=dateSection${p}-input]`)?.value;
|
|
951
|
+
const [y, m, d] = [part("Year"), part("Month"), part("Day")];
|
|
952
|
+
kind = d !== undefined ? "date" : m !== undefined ? "month" : "year";
|
|
953
|
+
const pad = v => v.padStart(2, "0");
|
|
954
|
+
value = kind === "date" ? (y && m && d ? `${y}-${pad(m)}-${pad(d)}` : null)
|
|
955
|
+
: kind === "month" ? (y && m ? `${pad(m)}/${y}` : null) : (y || null);
|
|
956
|
+
} else if (list) {
|
|
957
|
+
kind = "list";
|
|
958
|
+
value = text(list) === "Select One" ? null : text(list);
|
|
959
|
+
} else if (prompt) {
|
|
960
|
+
kind = "prompt";
|
|
961
|
+
const picked = [...f.querySelectorAll("[data-automation-id=selectedItem]")].map(text);
|
|
962
|
+
value = picked.length ? picked.join(", ") : null;
|
|
963
|
+
} else if (radios.length) {
|
|
964
|
+
kind = "radio";
|
|
965
|
+
options = radios.map(e => labelOf(f, e));
|
|
966
|
+
const checked = radios.find(e => e.checked);
|
|
967
|
+
value = checked ? labelOf(f, checked) : null;
|
|
968
|
+
} else if (boxes.length > 1) {
|
|
969
|
+
kind = "boxes";
|
|
970
|
+
options = boxes.map(e => labelOf(f, e));
|
|
971
|
+
value = boxes.filter(e => e.checked).map(e => labelOf(f, e));
|
|
972
|
+
} else if (boxes.length === 1) {
|
|
973
|
+
kind = "box";
|
|
974
|
+
value = boxes[0].checked ? "Yes" : "No";
|
|
975
|
+
} else if (typed) {
|
|
976
|
+
kind = "text";
|
|
977
|
+
value = typed.value;
|
|
978
|
+
}
|
|
979
|
+
return {id: f.dataset.fkitId, label: label.replace(/\\*$/, "").trim(),
|
|
980
|
+
required: label.endsWith("*"), kind, value, options};
|
|
981
|
+
});
|
|
982
|
+
}"""
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
# The open prompt list's rows: each item's name, whether it opens a category, the list's size.
|
|
986
|
+
_PROMPT_ROWS = r"""() => [...document.querySelectorAll(
|
|
987
|
+
"[data-automation-id=activeListContainer] [role=option]")]
|
|
988
|
+
.filter(row => row.offsetParent !== null)
|
|
989
|
+
.map(row => ({
|
|
990
|
+
name: (row.querySelector("[data-automation-id=promptOption]")?.innerText ?? "")
|
|
991
|
+
.replace(/\s+/g, " ").trim(),
|
|
992
|
+
nested: !!row.querySelector("[data-uxi-multiselectlistitem-hassidecharm=true]"),
|
|
993
|
+
size: Number(row.getAttribute("aria-setsize") || 0),
|
|
994
|
+
}))
|
|
995
|
+
.filter(row => row.name)"""
|
|
@@ -27,6 +27,7 @@ from camoufox.sync_api import Camoufox
|
|
|
27
27
|
from playwright.sync_api import APIRequestContext as Request
|
|
28
28
|
from playwright.sync_api import Browser, Locator, Page, Response, sync_playwright
|
|
29
29
|
from playwright.sync_api import Error as BrowserError
|
|
30
|
+
from playwright.sync_api import Request as PageRequest # one the page sent, unlike `Request`
|
|
30
31
|
from playwright.sync_api import TimeoutError as Timeout
|
|
31
32
|
|
|
32
33
|
__all__ = [
|
|
@@ -36,6 +37,7 @@ __all__ = [
|
|
|
36
37
|
"Locator",
|
|
37
38
|
"OutOfTime",
|
|
38
39
|
"Page",
|
|
40
|
+
"PageRequest",
|
|
39
41
|
"Request",
|
|
40
42
|
"Response",
|
|
41
43
|
"Timeout",
|
|
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
|