atsfill 0.0.8.dev219__tar.gz → 0.0.8.dev221__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.8.dev219 → atsfill-0.0.8.dev221}/PKG-INFO +1 -1
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/pyproject.toml +1 -1
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/pyproject.toml.orig +1 -1
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/adp.py +56 -3
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/adpmyjobs.py +24 -7
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/bamboohr.py +34 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/eightfold.py +14 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/gupy.py +88 -6
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/hrmdirect.py +23 -7
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/isolvedhire.py +20 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/taleo.py +24 -10
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/workday.py +40 -16
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/LICENSE +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/README.md +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/__init__.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/__init__.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/ashby.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/breezy.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/dayforce.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/greenhouse.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/hireology.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/hiringthing.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/jazzhr.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/jobaffinity.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/jobstreet.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/join.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/lever.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/manatal.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/oracle.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/paycor.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/paylocity.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/personio.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/phenom.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/pinpoint.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/recruitee.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/recruiterflow.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/rippling.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/smartrecruiters.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/successfactors.js +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/successfactors.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/teamtailor.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/ukg.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/welcomekit.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/wellfound.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/werecruit.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/workable.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/workatastartup.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/wttj.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/adapters/zohorecruit.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/cli.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/__init__.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/accounts.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/adapter.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/attempts.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/browser.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/commands.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/conditions.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/countries.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/html.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/matching.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/otp.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/passwords.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/paths.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/registry.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/results.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/snapshot.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/core/telemetry.py +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/py.typed +0 -0
- {atsfill-0.0.8.dev219 → atsfill-0.0.8.dev221}/src/atsfill/registry.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atsfill
|
|
3
|
-
Version: 0.0.8.
|
|
3
|
+
Version: 0.0.8.dev221
|
|
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
|
|
@@ -49,6 +49,9 @@ NEXT = "#ja_sv_cw_next_footer_btn"
|
|
|
49
49
|
PREVIOUS = "#ja_sv_cw_prev_footer_btn"
|
|
50
50
|
SUBMIT = "#submitApplication"
|
|
51
51
|
ATTEST = "input[name=self_att_agree_chk]"
|
|
52
|
+
SIGNATURE = "attestation.signature"
|
|
53
|
+
OPT_OUT = "resume.ai_opt_out"
|
|
54
|
+
LATE = {SIGNATURE: OPTION_TIMEOUT_MS, OPT_OUT: UPLOAD_TIMEOUT_MS} # boxes shown a moment after
|
|
52
55
|
PERSON = "/jobApplication/applicant/person"
|
|
53
56
|
PAY = "/jobApplication/requestedPayRate"
|
|
54
57
|
|
|
@@ -74,6 +77,7 @@ SHOWN_BY_COUNTRY = {"county"} # once a country with counties is picked
|
|
|
74
77
|
ADDRESS_MAX = 30 # "Address Line 1 must not exceed 30 characters"
|
|
75
78
|
FIXED = {"personal.email"} # the verified email, read only
|
|
76
79
|
QUESTIONS = "#question_0, #questionYes_0, #desiredSalaryId, #refferalOptions, #commentTextArea"
|
|
80
|
+
NOT_FIELDS = re.compile(r"select-filter-input|react-select-.*") # a list's own inputs
|
|
77
81
|
|
|
78
82
|
# Voluntary Self-ID: its lists and radio groups, each with the meta's indicator that shows it
|
|
79
83
|
# and its code list; a radio's value is its code
|
|
@@ -113,6 +117,7 @@ class ADP:
|
|
|
113
117
|
self._values: dict[str, Answer] = {}
|
|
114
118
|
self._codes: dict[str, dict[str, str]] = {} # a radio's label by its value, by field id
|
|
115
119
|
self._amounts: set[str] = set() # the page shows them formatted: `50,000.00`
|
|
120
|
+
self._shown: dict[str, None] = {} # the fields each step showed, in order
|
|
116
121
|
|
|
117
122
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
118
123
|
return _posting(url) is not None
|
|
@@ -166,7 +171,7 @@ class ADP:
|
|
|
166
171
|
asked = self._questions(control, transforms)
|
|
167
172
|
self._steps = [
|
|
168
173
|
Step("Personal Information", PERSONAL[0][1], self._personal(page, transforms)),
|
|
169
|
-
Step("Resume", "input[type=file]", [self._resume()]),
|
|
174
|
+
Step("Resume", "input[type=file]", [self._resume(), self._opt_out()]),
|
|
170
175
|
] # fmt: skip
|
|
171
176
|
# A wizard with nothing to ask has no Questions step; one that asks nothing the meta
|
|
172
177
|
# lists has no box of its own: Next moves past it
|
|
@@ -176,25 +181,44 @@ class ADP:
|
|
|
176
181
|
fields = self._self_id(transforms, indicators)
|
|
177
182
|
self._steps.append(Step("Voluntary Self-ID", "#vsidWhy, #vsidEthRace", fields))
|
|
178
183
|
self._steps.append(Step("Review Your Application", ""))
|
|
179
|
-
|
|
184
|
+
attest = [self._attestation(control), self._signature()]
|
|
185
|
+
self._steps.append(Step("Self-Attest & Submit", SUBMIT, attest))
|
|
180
186
|
return [f for s in self._steps for f in s.fields]
|
|
181
187
|
|
|
182
188
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
183
189
|
"""Answers each step and moves on with Next, up to the attestation, which it agrees to
|
|
184
190
|
without Submit."""
|
|
185
|
-
self._values = {}
|
|
191
|
+
self._values, self._shown = {}, {}
|
|
186
192
|
answers = {f.id: f.prefilled for s in self._steps for f in s.fields
|
|
187
193
|
if f.prefilled is not None} | answers # fmt: skip
|
|
188
194
|
for step in self._steps:
|
|
189
195
|
_wait_for_step(page, step)
|
|
196
|
+
before = {key for key, _ in page.evaluate(_CONTROLS)}
|
|
190
197
|
for f in step.fields:
|
|
191
198
|
if f.id in answers and f.id not in FIXED:
|
|
192
199
|
self._set(page, f, answers[f.id])
|
|
200
|
+
self._shown |= dict.fromkeys(self._shown_on(page, step, before))
|
|
193
201
|
self._values |= self._read(page, step)
|
|
194
202
|
if step.marker == SUBMIT:
|
|
195
203
|
return
|
|
196
204
|
_next(page, step)
|
|
197
205
|
|
|
206
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
207
|
+
"""The fields each step showed once answered: the wizard holds one step at a time."""
|
|
208
|
+
return list(self._shown)
|
|
209
|
+
|
|
210
|
+
def _shown_on(self, page: Page, step: Step, before: set[str]) -> list[str]:
|
|
211
|
+
"""The open step's fields: those with an enabled box, then the controls its answers
|
|
212
|
+
added that no field has. What it showed before them is not all fields, such as a
|
|
213
|
+
preferred name's box, so a control counts only as an answer's."""
|
|
214
|
+
boxes = {name for box in self._boxes.values() for name in re.findall(r"[\w-]+", box)}
|
|
215
|
+
shown = [f.id for f in step.fields if _enabled(page.locator(self._boxes[f.id]).first)]
|
|
216
|
+
for key, dom in page.evaluate(_CONTROLS):
|
|
217
|
+
listed = key in boxes or dom in boxes or NOT_FIELDS.fullmatch(key)
|
|
218
|
+
if key and key not in before and not listed:
|
|
219
|
+
shown.append(key)
|
|
220
|
+
return shown
|
|
221
|
+
|
|
198
222
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
199
223
|
"""Each step's values as it was left."""
|
|
200
224
|
return dict(self._values)
|
|
@@ -246,6 +270,17 @@ class ADP:
|
|
|
246
270
|
self._boxes["resume"] = "input[type=file]"
|
|
247
271
|
return Field("resume", "Resume", FieldType.FILE, True, accept=[".pdf", ".doc", ".docx"])
|
|
248
272
|
|
|
273
|
+
def _opt_out(self) -> Field:
|
|
274
|
+
"""The opt-out an employer's uploaded résumé shows, in no meta: listed on every form,
|
|
275
|
+
its box left alone unless answered."""
|
|
276
|
+
self._boxes[OPT_OUT] = "input[name=candidateProfileRelevanceOptionDivId]"
|
|
277
|
+
label = (
|
|
278
|
+
"I want to opt out from having my resume reviewed by artificial intelligence as "
|
|
279
|
+
"part of the application process."
|
|
280
|
+
)
|
|
281
|
+
return Field(OPT_OUT, label, FieldType.CHOICE, False, YES_NO, default="No",
|
|
282
|
+
shown_when=Is("resume")) # fmt: skip
|
|
283
|
+
|
|
249
284
|
def _questions(self, control: dict[str, Any], transforms: dict[str, Any]) -> list[Field]:
|
|
250
285
|
"""The questionnaire, each question its `#question_<n>` by position; then the desired
|
|
251
286
|
pay and the comment where the meta does not hide them."""
|
|
@@ -356,8 +391,17 @@ class ADP:
|
|
|
356
391
|
self._boxes["attestation"] = ATTEST
|
|
357
392
|
return Field("attestation", label, FieldType.CHOICE, True, YES_NO)
|
|
358
393
|
|
|
394
|
+
def _signature(self) -> Field:
|
|
395
|
+
"""The name agreeing shows beneath the box, in no meta; the page's own words."""
|
|
396
|
+
self._boxes[SIGNATURE] = "#electronicSignature"
|
|
397
|
+
return Field(SIGNATURE, "Please type your full name.", FieldType.TEXT, True,
|
|
398
|
+
shown_when=Is("attestation", ["Yes"])) # fmt: skip
|
|
399
|
+
|
|
359
400
|
def _set(self, page: Page, f: Field, answer: Answer) -> None:
|
|
360
401
|
box = page.locator(self._boxes[f.id]).first
|
|
402
|
+
if f.id in LATE:
|
|
403
|
+
with suppress(Timeout):
|
|
404
|
+
box.wait_for(state="attached", timeout=LATE[f.id])
|
|
361
405
|
if not box.count():
|
|
362
406
|
return # not shown: its condition is unmet
|
|
363
407
|
if f.type is FieldType.FILE:
|
|
@@ -416,6 +460,10 @@ class ADP:
|
|
|
416
460
|
return values
|
|
417
461
|
|
|
418
462
|
|
|
463
|
+
# The open step's controls: what names each, and its id
|
|
464
|
+
_CONTROLS = """() => [...document.querySelectorAll(
|
|
465
|
+
"input, textarea, select, sdf-select-simple, wfn-dropdown-list"
|
|
466
|
+
)].map(e => [e.name || e.id, e.id])"""
|
|
419
467
|
# The meta lists the questionnaire of the posting its header names
|
|
420
468
|
_FETCH = """async ([url, requisition]) => {
|
|
421
469
|
const headers = {requisitionoid: requisition, 'x-requested-with': 'XMLHttpRequest'};
|
|
@@ -520,6 +568,11 @@ def _label(page: Page, selector: str) -> str:
|
|
|
520
568
|
return ""
|
|
521
569
|
|
|
522
570
|
|
|
571
|
+
def _enabled(box: Locator) -> bool:
|
|
572
|
+
"""A box the step shows and takes an answer in: race stays disabled until ethnicity allows."""
|
|
573
|
+
return box.count() > 0 and not box.evaluate("e => e.disabled === true")
|
|
574
|
+
|
|
575
|
+
|
|
523
576
|
def _wait_for_step(page: Page, step: Step) -> None:
|
|
524
577
|
if not step.marker:
|
|
525
578
|
page.wait_for_timeout(SETTLE_MS)
|
|
@@ -95,6 +95,7 @@ class ADPMyJobs:
|
|
|
95
95
|
self._codes: dict[str, dict[str, str]] = {} # by field id: an option's label by code
|
|
96
96
|
self._values: dict[str, Answer] = {}
|
|
97
97
|
self._fixed: set[str] = set() # disabled questions, their value shown as text
|
|
98
|
+
self._shown: dict[str, None] = {} # the fields each page showed, in order
|
|
98
99
|
|
|
99
100
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
100
101
|
return _posting(url) is not None
|
|
@@ -202,7 +203,7 @@ class ADPMyJobs:
|
|
|
202
203
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
203
204
|
"""Answers each section page by page, saving each with Next, up to the last section's
|
|
204
205
|
last page, or its review."""
|
|
205
|
-
self._values = {}
|
|
206
|
+
self._values, self._shown = {}, {}
|
|
206
207
|
answers = {f.id: f.prefilled for s in self._sections for f in s.fields
|
|
207
208
|
if f.prefilled is not None} | answers # fmt: skip
|
|
208
209
|
for section in self._sections:
|
|
@@ -210,9 +211,10 @@ class ADPMyJobs:
|
|
|
210
211
|
_on_section(page, section)
|
|
211
212
|
_to_page(page, section, ids)
|
|
212
213
|
_add_entries(page, section, number)
|
|
213
|
-
|
|
214
|
-
raise NotTaken(f"{section.title} shows {unlisted}, which the read did not list")
|
|
214
|
+
_all_listed(page, section)
|
|
215
215
|
self._answer(page, section, answers)
|
|
216
|
+
_all_listed(page, section) # before a Next saves what the answers showed
|
|
217
|
+
self._shown |= dict.fromkeys(_shown_ids(page, section))
|
|
216
218
|
self._values |= self._read(page, section)
|
|
217
219
|
last = number == max(len(section.pages), 1) - 1
|
|
218
220
|
if section is self._sections[-1] and last and not section.review:
|
|
@@ -221,6 +223,10 @@ class ADPMyJobs:
|
|
|
221
223
|
if section.review and section is not self._sections[-1]:
|
|
222
224
|
self._next(page, section) # the review's Next saves the section
|
|
223
225
|
|
|
226
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
227
|
+
"""The fields each page showed once answered: the workflow keeps one page at a time."""
|
|
228
|
+
return list(self._shown)
|
|
229
|
+
|
|
224
230
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
225
231
|
"""Each section's values as it was left."""
|
|
226
232
|
return dict(self._values)
|
|
@@ -654,13 +660,24 @@ _SHOWN = """es => es
|
|
|
654
660
|
.map(e => [e.dataset.name, e.querySelector('label')?.innerText.trim()])"""
|
|
655
661
|
|
|
656
662
|
|
|
657
|
-
def
|
|
658
|
-
"""
|
|
663
|
+
def _all_listed(page: Page, section: Section) -> None:
|
|
664
|
+
"""Stops on a question the open page shows that the read did not list, before its Next
|
|
665
|
+
saves the section for good."""
|
|
659
666
|
if section.component.startswith("prescreen"):
|
|
660
|
-
return
|
|
667
|
+
return
|
|
661
668
|
listed = {f.id.removeprefix(f"{section.component}.") for f in section.fields}
|
|
662
669
|
shown = page.locator("adp-form-group").evaluate_all(_SHOWN)
|
|
663
|
-
|
|
670
|
+
unlisted = ", ".join(repr(label or name) for name, label in shown if name not in listed)
|
|
671
|
+
if unlisted:
|
|
672
|
+
raise NotTaken(f"{section.title} shows {unlisted}, which the read did not list")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _shown_ids(page: Page, section: Section) -> list[str]:
|
|
676
|
+
"""The ids of the fields the open page shows; a prescreen's questions have no group."""
|
|
677
|
+
if section.component.startswith("prescreen"):
|
|
678
|
+
return [f.id for f in section.fields if _box(page, section, f).count()]
|
|
679
|
+
shown = page.locator("adp-form-group").evaluate_all(_SHOWN)
|
|
680
|
+
return [f"{section.component}.{name}" for name, _ in shown]
|
|
664
681
|
|
|
665
682
|
|
|
666
683
|
def _on_section(page: Page, section: Section) -> None:
|
|
@@ -95,6 +95,9 @@ TYPES = {
|
|
|
95
95
|
"file": FieldType.FILE,
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
CUSTOM_CONTROL = re.compile(r"customQuestionAnswers\.[a-z_]+?_(\d+)(_other)?")
|
|
99
|
+
VETERAN_RADIOS = ("declineToAnswer", "notVeteran", "veteran")
|
|
100
|
+
|
|
98
101
|
# The form's JSON answer and the state lists, fetched as the page fetches them.
|
|
99
102
|
_FETCH = """async urls => {
|
|
100
103
|
const out = [];
|
|
@@ -105,6 +108,12 @@ _FETCH = """async urls => {
|
|
|
105
108
|
return out;
|
|
106
109
|
}"""
|
|
107
110
|
|
|
111
|
+
# Each control of the form: what names it, and whether a person sees it.
|
|
112
|
+
_CONTROLS = """form => [...form.querySelectorAll("input, select, textarea")].map(e => ({
|
|
113
|
+
name: e.name, id: e.id, date: (e.placeholder ?? "").includes("yyyy"),
|
|
114
|
+
visible: e.offsetParent !== null,
|
|
115
|
+
}))"""
|
|
116
|
+
|
|
108
117
|
# What each present control shows, by the field's kind.
|
|
109
118
|
_READ_BACK = """(form, specs) => {
|
|
110
119
|
const named = name => form.querySelector(`[name="${name}"]`);
|
|
@@ -151,6 +160,7 @@ class BambooHR:
|
|
|
151
160
|
# each field's (kind, control name, extra): a radio's option ids by text, the
|
|
152
161
|
# veteran kinds' texts by id, a date's nothing
|
|
153
162
|
self._specs: dict[str, tuple[str, str | None, Any]] = {}
|
|
163
|
+
self._on_read: set[str] = set() # the controls' names as the form opened: its honeypot
|
|
154
164
|
|
|
155
165
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
156
166
|
return _job(url) is not None
|
|
@@ -208,6 +218,7 @@ class BambooHR:
|
|
|
208
218
|
self._specs = {
|
|
209
219
|
f.id: spec for f, (_, spec) in zip(out, fields, strict=True) if not f.unfillable
|
|
210
220
|
}
|
|
221
|
+
self._on_read = {control["name"] for control in form.evaluate(_CONTROLS)}
|
|
211
222
|
return out
|
|
212
223
|
|
|
213
224
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
@@ -266,6 +277,17 @@ class BambooHR:
|
|
|
266
277
|
continue # its question was not answered Other
|
|
267
278
|
control.fill(value)
|
|
268
279
|
|
|
280
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
281
|
+
"""The fields the form holds a control for, once filled; a visible control that came
|
|
282
|
+
after the read and no field names, by its name."""
|
|
283
|
+
shown: dict[str, None] = {}
|
|
284
|
+
for control in page.locator(FORM).evaluate(_CONTROLS):
|
|
285
|
+
if field_id := _field_of(control):
|
|
286
|
+
shown[field_id] = None
|
|
287
|
+
elif control["visible"] and control["name"] not in self._on_read:
|
|
288
|
+
shown[control["name"]] = None
|
|
289
|
+
return [i for i in shown if i != "dateAvailable" or i in self._specs]
|
|
290
|
+
|
|
269
291
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
270
292
|
specs = {
|
|
271
293
|
i: (kind, name, extra if kind == "veteran" else None)
|
|
@@ -307,6 +329,18 @@ class BambooHR:
|
|
|
307
329
|
return f"{response.url} {response.status}"
|
|
308
330
|
|
|
309
331
|
|
|
332
|
+
def _field_of(control: dict[str, Any]) -> str | None:
|
|
333
|
+
"""The field a control belongs to: by its name, the date by its placeholder and the veteran
|
|
334
|
+
radios, whose name React makes up, by their ids."""
|
|
335
|
+
if control["date"]:
|
|
336
|
+
return "dateAvailable"
|
|
337
|
+
if control["id"] in VETERAN_RADIOS:
|
|
338
|
+
return "veteranStatusId"
|
|
339
|
+
if custom := CUSTOM_CONTROL.fullmatch(control["name"]):
|
|
340
|
+
return f"question_{custom[1]}{custom[2] or ''}"
|
|
341
|
+
return next((k for k, (_, name) in STANDARD.items() if name == control["name"]), None)
|
|
342
|
+
|
|
343
|
+
|
|
310
344
|
def _job(url: str) -> tuple[str, str] | None:
|
|
311
345
|
parts = urlsplit(url)
|
|
312
346
|
if not (parts.hostname and HOST.match(parts.hostname)) or not (m := JOB.match(parts.path)):
|
|
@@ -92,6 +92,9 @@ _DOM_IDS = """keys => {
|
|
|
92
92
|
return out;
|
|
93
93
|
}"""
|
|
94
94
|
|
|
95
|
+
# The element ids of the controls on the page.
|
|
96
|
+
_SHOWN = """() => [...document.querySelectorAll('[id$="_error"]')].map(e => e.id.slice(0, -6))"""
|
|
97
|
+
|
|
95
98
|
_READ_BACK = """controls => {
|
|
96
99
|
const out = {};
|
|
97
100
|
for (const [qid, dom, kind] of controls) {
|
|
@@ -150,6 +153,7 @@ class Eightfold:
|
|
|
150
153
|
def __init__(self) -> None:
|
|
151
154
|
self._controls: list[_Control] = [] # in the form's order
|
|
152
155
|
self._fields: list[Field] = []
|
|
156
|
+
self._ends: dict[str, str] = {} # each question's id, by what its element id ends with
|
|
153
157
|
|
|
154
158
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
155
159
|
if _pid(url) is None:
|
|
@@ -216,6 +220,7 @@ class Eightfold:
|
|
|
216
220
|
if control.field.id in held and control.field.prefilled is None:
|
|
217
221
|
control.field = replace(control.field, default=held[control.field.id])
|
|
218
222
|
self._fields = [c.field for c in self._controls]
|
|
223
|
+
self._ends = {end: c.field.id for c in self._controls for end in c.ends}
|
|
219
224
|
self._controls = [c for c in self._controls if not c.field.unfillable]
|
|
220
225
|
return self._fields
|
|
221
226
|
|
|
@@ -237,6 +242,15 @@ class Eightfold:
|
|
|
237
242
|
_fill(page, control, answer)
|
|
238
243
|
given[control.field.id] = answer
|
|
239
244
|
|
|
245
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
246
|
+
"""The questions rendered once filled, each control's by the longest question id its
|
|
247
|
+
element id ends with; one no question's id ends as the page names it."""
|
|
248
|
+
shown: dict[str, None] = {}
|
|
249
|
+
for dom in page.evaluate(_SHOWN):
|
|
250
|
+
ends = [end for end in self._ends if dom == end or dom.endswith(f"_{end}")]
|
|
251
|
+
shown[self._ends[max(ends, key=len)] if ends else dom] = None
|
|
252
|
+
return list(shown)
|
|
253
|
+
|
|
240
254
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
241
255
|
unplaced = [[c.field.id, c.ends] for c in self._controls if c.dom is None]
|
|
242
256
|
doms = page.evaluate(_DOM_IDS, unplaced) if unplaced else {}
|
|
@@ -9,12 +9,13 @@ information; the questions are saved by submit, for good.
|
|
|
9
9
|
|
|
10
10
|
import re
|
|
11
11
|
import time
|
|
12
|
+
from collections.abc import Callable
|
|
12
13
|
from typing import Any
|
|
13
14
|
from urllib.parse import urlsplit
|
|
14
15
|
|
|
15
16
|
from atsfill.core import browser
|
|
16
17
|
from atsfill.core.adapter import Blocked, Gap, NotTaken, Unsupported, known_gap
|
|
17
|
-
from atsfill.core.browser import Locator, Page, Request, Response, Timeout
|
|
18
|
+
from atsfill.core.browser import BrowserError, Locator, Page, Request, Response, Timeout
|
|
18
19
|
from atsfill.core.results import Answer, Field, FieldType, Is, SideEffect
|
|
19
20
|
|
|
20
21
|
HOST = re.compile(r"^([a-z0-9-]+)\.gupy\.io$")
|
|
@@ -32,9 +33,26 @@ RESUME_WAIT_S = 15
|
|
|
32
33
|
SAVE_TIMEOUT_MS = 30_000
|
|
33
34
|
SIGN_IN_TIMEOUT_MS = 30_000
|
|
34
35
|
FORMS_TIMEOUT_S = 10
|
|
36
|
+
CLICK_ATTEMPTS = 3
|
|
35
37
|
YES_NO = ["Yes", "No"]
|
|
38
|
+
# What a Yes on an Additional information radio shows, the same on every posting, in no source a
|
|
39
|
+
# read can see; the page will not save it empty.
|
|
40
|
+
FOLLOW_UPS = {
|
|
41
|
+
"radioGroupIsIndicatedTitle": {
|
|
42
|
+
"guiddedApplicationAdditionalDataIndicatedByNameInput": (
|
|
43
|
+
"Name of the person who recommended you"
|
|
44
|
+
),
|
|
45
|
+
"guiddedApplicationAdditionalDataIndicatedByEmailInput": (
|
|
46
|
+
"E-mail of the person who has referred you"
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
"radioGroupIsCompanyEmployeeTitle": {
|
|
50
|
+
"guiddedApplicationAdditionalDataCorporateEmailInput": "Provide your corporate email",
|
|
51
|
+
},
|
|
52
|
+
}
|
|
36
53
|
TYPES = {
|
|
37
54
|
"SELECT": FieldType.CHOICE,
|
|
55
|
+
"CONDITIONAL": FieldType.CHOICE, # a SELECT with questions shown by its answer
|
|
38
56
|
"MULTI_SELECT": FieldType.MULTI_CHOICE,
|
|
39
57
|
"TEXT": FieldType.TEXT,
|
|
40
58
|
"TEXT_AREA": FieldType.TEXT,
|
|
@@ -71,6 +89,11 @@ _READ_QUESTIONS = """(ids) => Object.fromEntries(ids.map(([id, title]) => {
|
|
|
71
89
|
return [`question-${id}`, text?.value ?? null];
|
|
72
90
|
}))"""
|
|
73
91
|
|
|
92
|
+
# The questions page's controls: `question-<id>` radios, `checkbox-<id>-<n>` boxes, a text named by
|
|
93
|
+
# its question's title.
|
|
94
|
+
_SHOWN_QUESTIONS = f"""() => [...document.querySelectorAll({QUESTIONS!r})]
|
|
95
|
+
.map(e => e.name || e.id)"""
|
|
96
|
+
|
|
74
97
|
|
|
75
98
|
class Gupy:
|
|
76
99
|
name = "gupy"
|
|
@@ -86,6 +109,8 @@ class Gupy:
|
|
|
86
109
|
self._questions: dict[str, dict[str, Any]] = {}
|
|
87
110
|
self._fields: dict[str, Field] = {}
|
|
88
111
|
self._additional: dict[str, Answer] = {}
|
|
112
|
+
self._additional_shown: list[str] = [] # saved before the questions open
|
|
113
|
+
self._given: set[str] = set()
|
|
89
114
|
|
|
90
115
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
91
116
|
parts = urlsplit(url)
|
|
@@ -145,7 +170,7 @@ class Gupy:
|
|
|
145
170
|
page.wait_for_timeout(STEP_POLL_MS)
|
|
146
171
|
finally:
|
|
147
172
|
page.remove_listener("response", on_response)
|
|
148
|
-
fields =
|
|
173
|
+
fields = _additional_fields(page.evaluate(_READ_ADDITIONAL))
|
|
149
174
|
questions = (forms[-1].json().get("questionForm") or {}).get("questions") if forms else []
|
|
150
175
|
self._questions = {f"question-{q['questionId']}": q for q in questions or []}
|
|
151
176
|
fields += [_question(q) for q in self._questions.values()]
|
|
@@ -167,7 +192,7 @@ class Gupy:
|
|
|
167
192
|
continue
|
|
168
193
|
if button.is_enabled():
|
|
169
194
|
disabled_since = None
|
|
170
|
-
|
|
195
|
+
_click(lambda found=button: found)
|
|
171
196
|
# Continue Application is disabled while the resume loads, for good when the
|
|
172
197
|
# resume is incomplete
|
|
173
198
|
elif (disabled_since := disabled_since or time.monotonic()) < (
|
|
@@ -182,22 +207,44 @@ class Gupy:
|
|
|
182
207
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
183
208
|
"""Fills Additional information and, where the posting has questions, saves it and
|
|
184
209
|
opens them; the questions are filled and left unsaved for submit."""
|
|
210
|
+
self._given = set(answers)
|
|
185
211
|
for field_id, answer in answers.items():
|
|
186
212
|
if field_id not in self._questions:
|
|
187
213
|
_set(page, self._fields[field_id], field_id, answer)
|
|
188
214
|
self._additional = self._read_additional(page)
|
|
215
|
+
self._additional_shown = [raw["id"] for raw in page.evaluate(_READ_ADDITIONAL)]
|
|
189
216
|
if not self._questions:
|
|
190
217
|
return
|
|
191
218
|
if page.locator(ADDITIONAL).count():
|
|
192
219
|
browser.click(page.locator("button:has-text('Save and continue')").first)
|
|
193
220
|
answer_now = page.locator(ANSWER_NOW).first
|
|
194
|
-
|
|
221
|
+
try:
|
|
222
|
+
answer_now.wait_for(timeout=SAVE_TIMEOUT_MS)
|
|
223
|
+
except Timeout:
|
|
224
|
+
if page.locator(ADDITIONAL).count():
|
|
225
|
+
raise NotTaken("the page would not save Additional information") from None
|
|
226
|
+
raise
|
|
195
227
|
browser.click(answer_now)
|
|
196
228
|
page.locator(QUESTIONS).first.wait_for(timeout=SAVE_TIMEOUT_MS)
|
|
197
229
|
for field_id, answer in answers.items():
|
|
198
230
|
if (question := self._questions.get(field_id)) is not None:
|
|
199
231
|
_answer(page, self._fields[field_id], question, answer)
|
|
200
232
|
|
|
233
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
234
|
+
"""Additional information as it was saved, and the questions the page renders: a text
|
|
235
|
+
is known only by its title, which questions can share."""
|
|
236
|
+
by_title: dict[str, list[str]] = {}
|
|
237
|
+
for field_id, question in self._questions.items():
|
|
238
|
+
by_title.setdefault(question["title"], []).append(field_id)
|
|
239
|
+
for ids in by_title.values():
|
|
240
|
+
ids.sort(key=lambda i: i not in self._given)
|
|
241
|
+
shown = list(self._additional_shown)
|
|
242
|
+
for control in page.evaluate(_SHOWN_QUESTIONS) if page.locator(QUESTIONS).count() else []:
|
|
243
|
+
ids = by_title.get(control.removeprefix("input-"))
|
|
244
|
+
field_id = ids.pop(0) if ids else _question_id(control)
|
|
245
|
+
shown += [field_id] if field_id not in shown else []
|
|
246
|
+
return shown
|
|
247
|
+
|
|
201
248
|
def _read_additional(self, page: Page) -> dict[str, Answer]:
|
|
202
249
|
values = {raw["id"]: raw["value"] for raw in page.evaluate(_READ_ADDITIONAL)}
|
|
203
250
|
return {k: v for k, v in values.items() if k in self._fields and v}
|
|
@@ -222,6 +269,23 @@ class Gupy:
|
|
|
222
269
|
raise RuntimeError(f"{response.request.method} {response.url} answered {response.status}")
|
|
223
270
|
|
|
224
271
|
|
|
272
|
+
def _additional_fields(raws: list[dict[str, Any]]) -> list[Field]:
|
|
273
|
+
"""Additional information as the page has it, each radio before what its Yes shows."""
|
|
274
|
+
values = {raw["id"]: raw["value"] for raw in raws}
|
|
275
|
+
shown_by = {name for names in FOLLOW_UPS.values() for name in names}
|
|
276
|
+
fields = []
|
|
277
|
+
for raw in raws:
|
|
278
|
+
if raw["id"] in shown_by:
|
|
279
|
+
continue
|
|
280
|
+
fields.append(_additional(raw))
|
|
281
|
+
for name, label in FOLLOW_UPS.get(raw["id"], {}).items():
|
|
282
|
+
yes = Is(raw["id"], ["Yes"])
|
|
283
|
+
fields.append(
|
|
284
|
+
Field(name, label, FieldType.TEXT, True, prefilled=values.get(name), shown_when=yes)
|
|
285
|
+
)
|
|
286
|
+
return fields
|
|
287
|
+
|
|
288
|
+
|
|
225
289
|
def _additional(raw: dict[str, Any]) -> Field:
|
|
226
290
|
"""A radio group is a choice; the source list, a combobox, is not filled."""
|
|
227
291
|
name, label = raw["id"], raw["label"]
|
|
@@ -243,7 +307,8 @@ def _question(q: dict[str, Any]) -> Field:
|
|
|
243
307
|
shown = Is(f"question-{q['parentQuestionId']}", q.get("conditionalTriggerOptions") or None)
|
|
244
308
|
if kind is None:
|
|
245
309
|
reason = f"unhandled_rendering: {q['title']!r} is a {q['type']} question"
|
|
246
|
-
return Field(field_id, q["title"], FieldType.TEXT, q["required"], unfillable=reason
|
|
310
|
+
return Field(field_id, q["title"], FieldType.TEXT, q["required"], unfillable=reason,
|
|
311
|
+
shown_when=shown) # fmt: skip
|
|
247
312
|
return Field(
|
|
248
313
|
field_id, q["title"], kind, bool(q["required"]),
|
|
249
314
|
options if kind is not FieldType.TEXT else None, prefilled=q.get("answer") or None,
|
|
@@ -251,6 +316,17 @@ def _question(q: dict[str, Any]) -> Field:
|
|
|
251
316
|
) # fmt: skip
|
|
252
317
|
|
|
253
318
|
|
|
319
|
+
def _click(find: Callable[[], Locator]) -> None:
|
|
320
|
+
"""Clicks what `find` finds: the page renders again as it loads, detaching what it found."""
|
|
321
|
+
for _ in range(CLICK_ATTEMPTS - 1):
|
|
322
|
+
try:
|
|
323
|
+
return browser.click(find())
|
|
324
|
+
except BrowserError as e:
|
|
325
|
+
if "not attached" not in str(e):
|
|
326
|
+
raise
|
|
327
|
+
browser.click(find())
|
|
328
|
+
|
|
329
|
+
|
|
254
330
|
def _label(page: Page, selector: str, text: str) -> Locator:
|
|
255
331
|
"""The label, wrapping it, of the input `selector` matches whose text is `text`."""
|
|
256
332
|
inputs = page.locator(selector)
|
|
@@ -263,7 +339,7 @@ def _label(page: Page, selector: str, text: str) -> Locator:
|
|
|
263
339
|
def _set(page: Page, field: Field, field_id: str, answer: Answer) -> None:
|
|
264
340
|
match field.type, answer:
|
|
265
341
|
case FieldType.CHOICE, str():
|
|
266
|
-
|
|
342
|
+
_click(lambda: _label(page, f'main input[name="{field_id}"]', answer))
|
|
267
343
|
case FieldType.TEXT, str():
|
|
268
344
|
page.locator(f'main input[name="{field_id}"], main input[id="{field_id}"]').fill(answer)
|
|
269
345
|
case kind, _:
|
|
@@ -290,5 +366,11 @@ def _answer(page: Page, field: Field, question: dict[str, Any], answer: Answer)
|
|
|
290
366
|
raise Unsupported(f"unhandled_rendering: cannot fill question-{qid}, a {kind}")
|
|
291
367
|
|
|
292
368
|
|
|
369
|
+
def _question_id(control: str) -> str:
|
|
370
|
+
"""The question of a radio or a checkbox's name; anything else as the page names it."""
|
|
371
|
+
box = re.fullmatch(r"checkbox-(\d+)-\d+", control)
|
|
372
|
+
return f"question-{box[1]}" if box else control
|
|
373
|
+
|
|
374
|
+
|
|
293
375
|
def _css(text: str) -> str:
|
|
294
376
|
return text.replace("\\", "\\\\").replace('"', '\\"')
|
|
@@ -109,6 +109,7 @@ class HRMDirect:
|
|
|
109
109
|
def __init__(self) -> None:
|
|
110
110
|
self._jobs: dict[str, str] = {} # by org, the last posting's job
|
|
111
111
|
self._fields: dict[str, Field] = {}
|
|
112
|
+
self._walked: set[str] = set() # the pages the read reached
|
|
112
113
|
self._kinds: dict[str, str] = {}
|
|
113
114
|
self._values: dict[str, Answer] = {}
|
|
114
115
|
self._apply = ""
|
|
@@ -225,8 +226,9 @@ class HRMDirect:
|
|
|
225
226
|
"""Walks the wizard from its first page as far as its pages save with what they hold,
|
|
226
227
|
reading each page it reaches."""
|
|
227
228
|
self._open(page)
|
|
228
|
-
self._fields = {}
|
|
229
|
+
self._fields, self._walked = {}, set()
|
|
229
230
|
for _ in range(MAX_PAGES):
|
|
231
|
+
self._walked.add(_active(page))
|
|
230
232
|
for f in self._page(page):
|
|
231
233
|
self._fields.setdefault(f.id, f)
|
|
232
234
|
if not self._continue(page):
|
|
@@ -234,8 +236,9 @@ class HRMDirect:
|
|
|
234
236
|
return list(self._fields.values())
|
|
235
237
|
|
|
236
238
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
237
|
-
"""Fills each page from the first and saves it, up to the last; a page
|
|
238
|
-
|
|
239
|
+
"""Fills each page from the first and saves it, up to the last; a page that shows a
|
|
240
|
+
field the read did not list, or does not save, stops the fill, the pages before it
|
|
241
|
+
saved."""
|
|
239
242
|
self._open(page)
|
|
240
243
|
self._values = {}
|
|
241
244
|
for _ in range(MAX_PAGES):
|
|
@@ -250,12 +253,25 @@ class HRMDirect:
|
|
|
250
253
|
control = page.locator(PAGE_FORM).locator(f"[id='{f.id}']")
|
|
251
254
|
tag = control.evaluate("el => el.tagName") if control.count() else ""
|
|
252
255
|
_fill(page, f, "select" if tag == "SELECT" else "text", answer)
|
|
253
|
-
|
|
254
|
-
if unread:
|
|
255
|
-
raise NotTaken(f"the page shows fields the read did not list: {unread}; read again")
|
|
256
|
+
self._unread(page)
|
|
256
257
|
self._values |= self._read(page)
|
|
257
|
-
if _last(page)
|
|
258
|
+
if _last(page):
|
|
258
259
|
return
|
|
260
|
+
step = _active(page)
|
|
261
|
+
if not self._continue(page):
|
|
262
|
+
raise NotTaken(f"{step} was not saved")
|
|
263
|
+
|
|
264
|
+
def _unread(self, page: Page) -> None:
|
|
265
|
+
"""Stops on fields the read did not list, optional ones too, once the answers are in. A
|
|
266
|
+
page the read did not reach is read once the pages before it are saved. On one it read,
|
|
267
|
+
an answer opened the field: the page stops unsaved, so no read would list it."""
|
|
268
|
+
unread = [f for f in self._page(page) if f.id not in self._fields]
|
|
269
|
+
if not unread:
|
|
270
|
+
return
|
|
271
|
+
step, labels = _active(page), ", ".join(f.label for f in unread)
|
|
272
|
+
if step in self._walked:
|
|
273
|
+
raise Unsupported(f"unhandled_rendering: an answer showed {labels}, not listed")
|
|
274
|
+
raise NotTaken(f"{step} shows fields the read did not list: {labels}; read again")
|
|
259
275
|
|
|
260
276
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
261
277
|
return self._values | self._read(page)
|
|
@@ -139,6 +139,10 @@ _READ = """html => {
|
|
|
139
139
|
} else field.kind = "text";
|
|
140
140
|
out.push(field);
|
|
141
141
|
}
|
|
142
|
+
if (html.includes("applicantDisabledData") && !seen.has("disabled_employee_id")) {
|
|
143
|
+
out.push({key: "disabled_employee_id", kind: "scripted", label: "Employee ID (if applicable)",
|
|
144
|
+
required: false, options: [], value: "", disabled: false});
|
|
145
|
+
}
|
|
142
146
|
const app = root.querySelector("input[name=applications_id]")?.value;
|
|
143
147
|
return {fields: out, app};
|
|
144
148
|
}"""
|
|
@@ -190,6 +194,7 @@ class IsolvedHire:
|
|
|
190
194
|
self._steps: list[str] = []
|
|
191
195
|
self._fields: dict[str, Field] = {}
|
|
192
196
|
self._raws: dict[str, dict[str, Any]] = {} # by field id, as the read found it
|
|
197
|
+
self._shown: dict[str, None] = {} # the fields each step showed, in order
|
|
193
198
|
|
|
194
199
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
195
200
|
return _job(url) is not None
|
|
@@ -284,14 +289,20 @@ class IsolvedHire:
|
|
|
284
289
|
"""Confirms the account's contact details, then walks the steps in order: each answered
|
|
285
290
|
and saved with Proceed to Next Step, which goes no further while one is invalid."""
|
|
286
291
|
_confirm_contact(page)
|
|
292
|
+
self._shown = {}
|
|
287
293
|
for step in self._steps:
|
|
288
294
|
_go(page, step)
|
|
289
295
|
for field_id, field in self._fields.items():
|
|
290
296
|
if field_id.startswith(f"{step}.") and field_id in answers:
|
|
291
297
|
self._answer(page, field, answers[field_id])
|
|
292
298
|
_settle(page)
|
|
299
|
+
self._shown |= dict.fromkeys(_shown(page, step))
|
|
293
300
|
_next(page, step)
|
|
294
301
|
|
|
302
|
+
def shown_fields(self, page: Page) -> list[str]:
|
|
303
|
+
"""The fields each step showed once answered: the wizard holds one step at a time."""
|
|
304
|
+
return list(self._shown)
|
|
305
|
+
|
|
295
306
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
296
307
|
"""Each step as the system saved it, fetched again."""
|
|
297
308
|
values: dict[str, Answer] = {}
|
|
@@ -476,6 +487,15 @@ def _read_step(page: Page, step: str) -> tuple[list[dict[str, Any]], str | None]
|
|
|
476
487
|
return raws, read["app"]
|
|
477
488
|
|
|
478
489
|
|
|
490
|
+
def _shown(page: Page, step: str) -> list[str]:
|
|
491
|
+
"""The ids of the open step's fields, read as `_read_step` reads them; a disabled and empty
|
|
492
|
+
control takes no answer, `source_other` until a source asks for it."""
|
|
493
|
+
if step in ENTRY_LISTS:
|
|
494
|
+
return [f"{step}.entries"]
|
|
495
|
+
raws = page.evaluate(_READ, page.locator("#landingStrip").inner_html())["fields"]
|
|
496
|
+
return [f"{step}.{raw['key']}" for raw in raws if raw["value"] or not raw["disabled"]]
|
|
497
|
+
|
|
498
|
+
|
|
479
499
|
def _texts(options: Locator) -> list[str]:
|
|
480
500
|
return [" ".join(t.split()) for t in options.all_text_contents()]
|
|
481
501
|
|
|
@@ -220,7 +220,8 @@ class Taleo:
|
|
|
220
220
|
|
|
221
221
|
def __init__(self) -> None:
|
|
222
222
|
self._postings: dict[str, Posting] = {} # by zone, the last posting's
|
|
223
|
-
self.
|
|
223
|
+
self._listed: set[str] = set() # the ids the read listed
|
|
224
|
+
self._walked: set[str] = set() # the pages the read reached
|
|
224
225
|
self._read_back: dict[str, Answer] = {}
|
|
225
226
|
|
|
226
227
|
def detect(self, url: str, page: Page | None) -> bool:
|
|
@@ -289,23 +290,24 @@ class Taleo:
|
|
|
289
290
|
fields listed. Reading saves pages, as a fill does."""
|
|
290
291
|
self._open(page)
|
|
291
292
|
fields: list[Field] = []
|
|
292
|
-
self.
|
|
293
|
+
self._walked = set()
|
|
293
294
|
while not _on_review(page):
|
|
294
295
|
step = _step(page)
|
|
296
|
+
self._walked.add(step)
|
|
295
297
|
if step not in SKIPPED_STEPS:
|
|
296
298
|
for raw in _read(page):
|
|
297
|
-
|
|
298
|
-
self._kinds[field.id] = raw["kind"]
|
|
299
|
-
fields.append(field)
|
|
299
|
+
fields.append(_field(step, raw))
|
|
300
300
|
if raw["id"].endswith(SOURCE_TYPE):
|
|
301
301
|
fields.append(_sources(page, step, raw))
|
|
302
302
|
if not _save(page):
|
|
303
303
|
break
|
|
304
|
+
self._listed = {f.id for f in fields}
|
|
304
305
|
return fields
|
|
305
306
|
|
|
306
307
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
307
308
|
"""Fills each page from the first and saves it, until Review and Submit. A page that
|
|
308
|
-
|
|
309
|
+
shows a field the read did not list stops the fill unsaved; one that does not save, with
|
|
310
|
+
its messages."""
|
|
309
311
|
self._open(page)
|
|
310
312
|
self._read_back = {}
|
|
311
313
|
while not _on_review(page):
|
|
@@ -318,14 +320,26 @@ class Taleo:
|
|
|
318
320
|
if field_id in answers and field_id not in filled:
|
|
319
321
|
_fill(page, raw, answers[field_id])
|
|
320
322
|
filled.add(field_id)
|
|
321
|
-
|
|
323
|
+
answered = _read(page) if raws else []
|
|
324
|
+
self._unread(step, answered)
|
|
325
|
+
for raw in answered:
|
|
322
326
|
value = _value(raw)
|
|
323
327
|
if value not in (None, "", []):
|
|
324
328
|
self._read_back[_field_id(step, raw)] = value
|
|
325
329
|
if not _save(page):
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
330
|
+
raise NotTaken(f"{step} was not saved: {'; '.join(_errors(page))}")
|
|
331
|
+
|
|
332
|
+
def _unread(self, step: str, raws: list[dict[str, Any]]) -> None:
|
|
333
|
+
"""Stops on fields the read did not list, optional ones too, once the answers are in. A
|
|
334
|
+
page the read did not reach is read once the pages before it are saved. On one it read,
|
|
335
|
+
an answer opened the field: the page stops unsaved, so no read would list it."""
|
|
336
|
+
unread = [r["label"] for r in raws if _field_id(step, r) not in self._listed]
|
|
337
|
+
if not unread:
|
|
338
|
+
return
|
|
339
|
+
labels = ", ".join(unread)
|
|
340
|
+
if step in self._walked:
|
|
341
|
+
raise Unsupported(f"unhandled_rendering: an answer showed {labels}, not listed")
|
|
342
|
+
raise NotTaken(f"{step} shows fields the read did not list: {labels}; read again")
|
|
329
343
|
|
|
330
344
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
331
345
|
return dict(self._read_back)
|
|
@@ -213,6 +213,9 @@ class Workday:
|
|
|
213
213
|
def __init__(self) -> None:
|
|
214
214
|
self._sites: dict[str, Site] = {} # by tenant, the last posting's
|
|
215
215
|
self._fields: dict[str, Field] = {}
|
|
216
|
+
self._listed: set[str] = set() # the ids the read listed, unfillable ones too
|
|
217
|
+
self._walked: set[str] = set() # the pages the read reached
|
|
218
|
+
self._walled = False # no read gets past the page this one stopped on
|
|
216
219
|
self._values: dict[str, Answer] = {}
|
|
217
220
|
self._apply = ""
|
|
218
221
|
|
|
@@ -263,9 +266,9 @@ class Workday:
|
|
|
263
266
|
|
|
264
267
|
def browser_read(self, page: Page) -> list[Field]:
|
|
265
268
|
"""Walks the wizard as far as its pages save with what they hold, reading each page
|
|
266
|
-
it reaches: from the page,
|
|
267
|
-
questions from the questionnaires, which also stand for those pages when
|
|
268
|
-
reached. A page after the first that does not save is read once it does."""
|
|
269
|
+
it reaches: from the page, My Experience from the apply flow's sections and then the
|
|
270
|
+
page, and the questions from the questionnaires, which also stand for those pages when
|
|
271
|
+
they are not reached. A page after the first that does not save is read once it does."""
|
|
269
272
|
found = site(page.url)
|
|
270
273
|
if found is None or not (job := JOB.match(found.rest)):
|
|
271
274
|
raise RuntimeError(f"{page.url} is not a Workday posting")
|
|
@@ -289,15 +292,19 @@ class Workday:
|
|
|
289
292
|
page.goto(self._apply)
|
|
290
293
|
_wait_for_page(page)
|
|
291
294
|
seen: dict[str, list[Field]] = {}
|
|
295
|
+
self._walked, self._walled = set(), False
|
|
292
296
|
for _ in range(MAX_PAGES):
|
|
293
297
|
step = _step(page)
|
|
294
298
|
if step == REVIEW: # repeats the answers, an upload without its input
|
|
295
299
|
break
|
|
296
|
-
|
|
297
|
-
|
|
300
|
+
self._walked.add(step)
|
|
301
|
+
if not _questions_page(step):
|
|
302
|
+
phone_codes = f"{calypso}/jobapplication/{found.tenant}{PHONE_CODES}"
|
|
303
|
+
seen[step] = _read(page, phone_codes, {f.id for f in from_api.get(step, [])})
|
|
298
304
|
try:
|
|
299
305
|
_save_and_continue(page)
|
|
300
306
|
except NotTaken:
|
|
307
|
+
self._walled = _only_a_tick_missing(_page_fields(page))
|
|
301
308
|
break
|
|
302
309
|
steps = [p["descriptor"] for p in flow.get("data") or []]
|
|
303
310
|
steps += [s for s in seen if s not in steps]
|
|
@@ -305,6 +312,7 @@ class Workday:
|
|
|
305
312
|
f for s in steps for f in seen.get(s, []) + from_api.get(_questionnaire_step(s), [])
|
|
306
313
|
]
|
|
307
314
|
self._fields = {f.id: f for f in fields if not f.unfillable}
|
|
315
|
+
self._listed = {f.id for f in fields}
|
|
308
316
|
return fields
|
|
309
317
|
|
|
310
318
|
def fill(self, page: Page, answers: dict[str, Answer]) -> None:
|
|
@@ -342,19 +350,22 @@ class Workday:
|
|
|
342
350
|
raise Unsupported(known_gap(self, "long_wizard", f"more than {MAX_PAGES} pages"))
|
|
343
351
|
|
|
344
352
|
def _unread(self, page: Page) -> None:
|
|
345
|
-
"""Stops on
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
353
|
+
"""Stops on fields the read did not list, optional ones too. A page the read did not
|
|
354
|
+
reach is read once the pages before it are saved, unless no read can get there. On one
|
|
355
|
+
it read, an answer opened the field (a country's address, a referral's name): the page
|
|
356
|
+
stops unsaved, so no read would list it."""
|
|
357
|
+
unread = _not_listed(_page_fields(page), self._listed)
|
|
349
358
|
if not unread:
|
|
350
359
|
return
|
|
351
360
|
step, labels = _step(page), ", ".join(r["label"] for r in unread)
|
|
361
|
+
if step not in self._walked and self._walled:
|
|
362
|
+
raise Unsupported(f"unhandled_rendering: {step} shows {labels}, which no read reaches")
|
|
363
|
+
if step not in self._walked:
|
|
364
|
+
raise NotTaken(f"{step} shows fields the read did not: {labels}; read again")
|
|
352
365
|
if step == MY_INFORMATION and any(r["id"].startswith(ADDRESS) for r in unread):
|
|
353
366
|
detail = f"the country answered shows fields the read did not: {labels}"
|
|
354
367
|
raise Unsupported(known_gap(self, "other_country", detail))
|
|
355
|
-
|
|
356
|
-
raise Unsupported(f"unhandled_rendering: an answer showed {labels}, not listed")
|
|
357
|
-
raise NotTaken(f"{step} shows fields the read did not: {labels}; read again")
|
|
368
|
+
raise Unsupported(f"unhandled_rendering: an answer showed {labels}, not listed")
|
|
358
369
|
|
|
359
370
|
def read_back(self, page: Page) -> dict[str, Answer]:
|
|
360
371
|
"""Each page's values, read before it was saved; one file as its name."""
|
|
@@ -472,10 +483,23 @@ def _questionnaire_step(step: str) -> str:
|
|
|
472
483
|
return re.sub(r" 1 of \d+$", "", step)
|
|
473
484
|
|
|
474
485
|
|
|
475
|
-
def
|
|
476
|
-
"""The fields
|
|
477
|
-
|
|
478
|
-
raws
|
|
486
|
+
def _not_listed(raws: list[dict[str, Any]], listed: set[str]) -> list[dict[str, Any]]:
|
|
487
|
+
"""The fields not in `listed`, but for a later entry of a list: the draft's, numbered anew
|
|
488
|
+
by each load, and no answer's."""
|
|
489
|
+
return [r for r in raws if r["id"] not in listed and not ENTRY_ID.match(r["id"])]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _only_a_tick_missing(raws: list[dict[str, Any]]) -> bool:
|
|
493
|
+
"""Whether a page holds all it requires but a box: the draft keeps a page's answers but not
|
|
494
|
+
its consent's tick, so no read saves that page, whatever the fills before it saved."""
|
|
495
|
+
empty = [r for r in raws if r["required"] and r["value"] in (None, "", [], "No")]
|
|
496
|
+
return bool(empty) and all(r["kind"] == "box" for r in empty)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _read(page: Page, phone_codes: str, listed: set[str]) -> list[Field]:
|
|
500
|
+
"""The fields the page shows that are not in `listed`, a dropdown's or a short prompt's
|
|
501
|
+
options read by opening it, the country phone codes from `phone_codes`, the API's list."""
|
|
502
|
+
raws = _not_listed(_page_fields(page), listed)
|
|
479
503
|
for raw in raws:
|
|
480
504
|
box = page.locator(f'[data-fkit-id="{raw["dom"]}"]')
|
|
481
505
|
if raw["kind"] == "list":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|